From cc6f492d38448a207e31e274ca14eb5c7f497346 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 15:26:04 +0200 Subject: [PATCH 1/6] Created the inspector property API --- editor/views/editor/viewport.cpp | 28 ++++++ include/atlas/runtime/c_api.h | 5 + include/atlas/runtime/context.h | 5 + include/editor/views/viewport.h | 5 + runtime/lib/c_api.cpp | 24 +++++ runtime/lib/context.cpp | 157 +++++++++++++++++++++++++++++-- 6 files changed, 217 insertions(+), 7 deletions(-) diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index e377de09..3cfa5075 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -18,6 +18,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -393,6 +396,31 @@ bool ViewportPanel::renameRuntimeObject(int id, const QString &name) { return true; } +bool ViewportPanel::setRuntimeObjectProperty( + int id, const QString &component, int componentIndex, + const QString &propertyPath, const QJsonValue &value) { + if (runtimeContext == nullptr) { + return false; + } + QJsonArray wrapper; + wrapper.append(value); + const QByteArray payload = + QJsonDocument(wrapper).toJson(QJsonDocument::Compact); + try { + const json parsed = json::parse(payload.constData()); + if (!parsed.is_array() || parsed.empty() || + !runtimeContext->setObjectProperty( + id, component.toStdString(), componentIndex, + propertyPath.toStdString(), parsed.front())) { + return false; + } + } catch (const json::exception &) { + return false; + } + refreshSceneSnapshot(); + return true; +} + bool ViewportPanel::setRuntimeObjectParent(int childId, int parentId) { if (runtimeContext == nullptr || !runtimeContext->setObjectParent(childId, parentId)) { diff --git a/include/atlas/runtime/c_api.h b/include/atlas/runtime/c_api.h index 744cc44a..97ebc1b3 100644 --- a/include/atlas/runtime/c_api.h +++ b/include/atlas/runtime/c_api.h @@ -65,6 +65,11 @@ bool atlas_runtime_select_object(void *runtimeContext, int id, bool focusCamera); bool atlas_runtime_rename_object(void *runtimeContext, int id, const char *name); +bool atlas_runtime_set_object_property(void *runtimeContext, int id, + const char *component, + int componentIndex, + const char *propertyPath, + const char *jsonValue); bool atlas_runtime_set_object_parent(void *runtimeContext, int childId, int parentId); bool atlas_runtime_delete_object(void *runtimeContext, int id); diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index a90334e8..5b128400 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -87,6 +87,8 @@ class Context { std::unordered_map objectSceneSolidTypes; std::unordered_map objectParentReferences; std::unordered_map objectParents; + std::unordered_map editorObjectSourceData; + std::unordered_map editorComponentData; std::unordered_map editorPointLights; std::unordered_map editorSpotlights; std::unordered_map editorAreaLights; @@ -111,6 +113,9 @@ class Context { std::string sceneObjectsJson() const; bool selectObject(int id, bool focusCamera); bool renameObject(int id, const std::string &name); + bool setObjectProperty(int id, const std::string &component, + int componentIndex, const std::string &propertyPath, + const json &value); bool setObjectParent(int childId, int parentId); bool deleteObject(int id); int createObject(const std::string &type, const std::string &name); diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h index 2b59dadb..72d9a057 100644 --- a/include/editor/views/viewport.h +++ b/include/editor/views/viewport.h @@ -19,6 +19,7 @@ class Context; class QCloseEvent; class QHideEvent; class QKeyEvent; +class QJsonValue; class QMouseEvent; class QPaintEngine; class QResizeEvent; @@ -39,6 +40,10 @@ class ViewportPanel : public QWidget { void shutdownRuntime(); bool selectRuntimeObject(int id, bool focusCamera = true); bool renameRuntimeObject(int id, const QString &name); + bool setRuntimeObjectProperty(int id, const QString &component, + int componentIndex, + const QString &propertyPath, + const QJsonValue &value); bool setRuntimeObjectParent(int childId, int parentId); bool deleteRuntimeObject(int id); int createRuntimeObject(const QString &type, const QString &name = {}); diff --git a/runtime/lib/c_api.cpp b/runtime/lib/c_api.cpp index 7de753fe..4942642d 100644 --- a/runtime/lib/c_api.cpp +++ b/runtime/lib/c_api.cpp @@ -301,6 +301,30 @@ bool atlas_runtime_rename_object(void *runtimeContext, int id, } } +bool atlas_runtime_set_object_property(void *runtimeContext, int id, + const char *component, + int componentIndex, + const char *propertyPath, + const char *jsonValue) { + if (runtimeContext == nullptr || component == nullptr || + propertyPath == nullptr || jsonValue == nullptr) { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->setObjectProperty( + id, component, componentIndex, propertyPath, + nlohmann::json::parse(jsonValue)); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + bool atlas_runtime_set_object_parent(void *runtimeContext, int childId, int parentId) { if (runtimeContext == nullptr) { diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 752170f1..35ac0e14 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -1418,6 +1418,7 @@ void registerGameObject(Context &context, GameObject &object, context.objectNames[object.getId()] = name; context.objectSceneReferences[object.getId()] = name; context.objectSceneTypes[object.getId()] = objectType; + context.editorObjectSourceData[object.getId()] = objectData; if (objectType == "solid") { std::string solidType; tryReadStringAny(objectData, {"solid_type", "solidType"}, solidType); @@ -1695,6 +1696,20 @@ bool updateObjectNode(json &node, const Context &context, GameObject &object) { const std::string name = serializableObjectName(context, object); const std::string reference = serializableObjectReference(context, object); if (objectNodeMatches(node, name, reference)) { + if (auto source = context.editorObjectSourceData.find(object.getId()); + source != context.editorObjectSourceData.end() && + source->second.is_object()) { + for (const auto &[key, value] : source->second.items()) { + if (key != "objects") { + node[key] = value; + } + } + } + if (auto components = + context.editorComponentData.find(object.getId()); + components != context.editorComponentData.end()) { + node["components"] = components->second; + } writeObjectTransform(node, context, object); return true; } @@ -1741,6 +1756,11 @@ bool removeObjectNode(json &nodes, const std::string &name, json serializeNewObject(const Context &context, GameObject &object) { json node = json::object(); + if (auto source = context.editorObjectSourceData.find(object.getId()); + source != context.editorObjectSourceData.end() && + source->second.is_object()) { + node = source->second; + } const std::string name = serializableObjectName(context, object); if (!name.empty()) { node["name"] = name; @@ -1758,6 +1778,10 @@ json serializeNewObject(const Context &context, GameObject &object) { : "cube"; } writeObjectTransform(node, context, object); + if (auto components = context.editorComponentData.find(object.getId()); + components != context.editorComponentData.end()) { + node["components"] = components->second; + } return node; } @@ -1939,13 +1963,15 @@ int registerEditorLightObject(Context &context, return id; } -void collectPendingComponents(GameObject &object, const json &objectData, +void collectPendingComponents(Context &context, GameObject &object, + const json &objectData, const std::string &baseDir, std::vector &rigidbodies, std::vector &standard, std::vector &joints) { const json *componentsField = findField(objectData, {"components"}); if (componentsField == nullptr) { + context.editorComponentData[object.getId()] = json::array(); return; } @@ -1964,9 +1990,12 @@ void collectPendingComponents(GameObject &object, const json &objectData, } if (componentEntries.empty()) { + context.editorComponentData[object.getId()] = json::array(); return; } + context.editorComponentData[object.getId()] = componentEntries; + std::string objectType; tryReadStringAny(objectData, {"type"}, objectType); objectType = normalizeToken(objectType); @@ -3192,7 +3221,7 @@ createRenderable(Context &context, const json &objectData, } applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -3205,7 +3234,7 @@ createRenderable(Context &context, const json &objectData, generatedIndex); context.objects.push_back(object); applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -3237,7 +3266,7 @@ createRenderable(Context &context, const json &objectData, } applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -3264,7 +3293,7 @@ createRenderable(Context &context, const json &objectData, } applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -3348,7 +3377,7 @@ createRenderable(Context &context, const json &objectData, object->setParticleSettings(settings); } - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -3407,7 +3436,7 @@ createRenderable(Context &context, const json &objectData, } applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -3726,6 +3755,28 @@ GameObject *findContextObject(const Context &context, int id) { return nullptr; } +bool setJsonProperty(json &target, const std::string &propertyPath, + const json &value) { + if (propertyPath.empty()) { + return false; + } + try { + const std::string pointerPath = propertyPath.front() == '/' + ? propertyPath + : '/' + propertyPath; + target[json::json_pointer(pointerPath)] = value; + return true; + } catch (const json::exception &) { + return false; + } +} + +bool readEditorVec3(const json &value, Position3d &result) { + json wrapper = json::object(); + wrapper["value"] = value; + return tryReadVec3Any(wrapper, {"value"}, result); +} + std::string editorObjectName(const Context &context, GameObject &object) { if (!object.name.empty()) { return object.name; @@ -3786,6 +3837,21 @@ json editorObjectJson(const Context &context, GameObject &object, node["position"] = vec3ToJson(object.getPosition()); node["rotation"] = rotationToJson(object.getRotation()); node["scale"] = vec3ToJson(object.getScale()); + if (auto light = context.editorLightSourceData.find(id); + light != context.editorLightSourceData.end()) { + node["properties"] = light->second; + } else if (auto source = context.editorObjectSourceData.find(id); + source != context.editorObjectSourceData.end()) { + node["properties"] = source->second; + } else { + node["properties"] = json::object(); + } + if (auto components = context.editorComponentData.find(id); + components != context.editorComponentData.end()) { + node["components"] = components->second; + } else { + node["components"] = json::array(); + } if (visiting.contains(id)) { return node; @@ -3902,11 +3968,70 @@ bool Context::renameObject(int id, const std::string &name) { object->name = name; objectNames[id] = name; + editorObjectSourceData[id]["name"] = name; + if (editorLightSourceData.contains(id)) { + editorLightSourceData[id]["name"] = name; + } registerObjectReference(*this, name, object); registerObjectReference(*this, std::to_string(id), object); return true; } +bool Context::setObjectProperty(int id, const std::string &component, + int componentIndex, + const std::string &propertyPath, + const json &value) { + GameObject *object = findContextObject(*this, id); + if (object == nullptr) { + return false; + } + + const std::string normalizedComponent = normalizeToken(component); + if (normalizedComponent == "transform") { + Position3d vector; + if (!readEditorVec3(value, vector)) { + return false; + } + const std::string property = normalizeToken(propertyPath); + if (property == "position") { + object->setPosition(vector); + } else if (property == "rotation") { + object->setRotation(Rotation3d{vector.x, vector.y, vector.z}); + } else if (property == "scale") { + object->setScale(vector); + } else { + return false; + } + editorObjectSourceData[id][propertyPath] = value; + syncEditorLightObject(*this, *object); + return true; + } + + if (normalizedComponent == "object") { + json &source = editorLightSourceData.contains(id) + ? editorLightSourceData[id] + : editorObjectSourceData[id]; + return setJsonProperty(source, propertyPath, value); + } + + auto components = editorComponentData.find(id); + if (components == editorComponentData.end() || + !components->second.is_array() || componentIndex < 0 || + componentIndex >= static_cast(components->second.size())) { + return false; + } + json &componentData = components->second[componentIndex]; + if (!componentData.is_object()) { + return false; + } + std::string storedType; + tryReadStringAny(componentData, {"type"}, storedType); + if (normalizeToken(storedType) != normalizedComponent) { + return false; + } + return setJsonProperty(componentData, propertyPath, value); +} + bool Context::setObjectParent(int childId, int parentId) { GameObject *child = findContextObject(*this, childId); if (child == nullptr) { @@ -3936,6 +4061,7 @@ bool Context::setObjectParent(int childId, int parentId) { if (parentId < 0) { objectParents.erase(childId); objectParentReferences.erase(childId); + editorObjectSourceData[childId].erase("parent"); if (window != nullptr) { window->setEditorObjectParent(child, nullptr); } @@ -3944,6 +4070,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); if (window != nullptr) { window->setEditorObjectParent(child, parent); } @@ -4053,6 +4182,8 @@ bool Context::deleteObject(int id) { editorDirectionalLights.erase(it); } editorLightSourceData.erase(id); + editorObjectSourceData.erase(id); + editorComponentData.erase(id); objectNames.erase(id); objectSceneReferences.erase(id); objectSceneTypes.erase(id); @@ -4278,6 +4409,18 @@ int Context::createObject(const std::string &type, const std::string &name) { registerObjectReference(*this, displayName, object.get()); registerObjectReference(*this, std::to_string(id), object.get()); + editorObjectSourceData[id] = json::object( + {{"id", id}, + {"name", displayName}, + {"type", sceneType}, + {"position", vec3ToJson(position)}, + {"rotation", rotationToJson(object->getRotation())}, + {"scale", vec3ToJson(object->getScale())}}); + if (!solidType.empty()) { + editorObjectSourceData[id]["solid_type"] = solidType; + } + editorComponentData[id] = json::array(); + objects.push_back(object); window->addObject(object.get()); window->selectEditorObject(object.get(), true); From 737dc3f0ff56c7ae34ae151ce93b9fdd6b493528 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 15:39:08 +0200 Subject: [PATCH 2/6] Implemented the component inspector --- editor/styling/dark.qss | 158 ++++ editor/views/editor/editor.cpp | 14 +- editor/views/editor/hierarchy.cpp | 3 +- editor/views/editor/inspector.cpp | 945 ++++++++++++++++++++++-- editor/views/general/contentBrowser.cpp | 8 +- include/editor/core/themes.h | 158 ++++ include/editor/views/fileExplorer.h | 3 + include/editor/views/hierarchyPanel.h | 3 + include/editor/views/inspectorView.h | 63 +- runtime/lib/context.cpp | 2 +- 10 files changed, 1286 insertions(+), 71 deletions(-) diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss index 6be8300e..6906c118 100644 --- a/editor/styling/dark.qss +++ b/editor/styling/dark.qss @@ -508,6 +508,164 @@ QListView#contentGrid::item:selected { color: #FFFFFF; } +QScrollArea#inspectorScroll { + background-color: #1B1F20; + border: none; + border-radius: 0; +} + +#inspectorContent { + background-color: #1B1F20; +} + +#inspectorHeader { + background-color: #24292B; + border: 1px solid #353D41; + border-radius: 8px; +} + +#inspectorObjectIcon { + background-color: #1C2022; + border: 1px solid #3B4449; + border-radius: 9px; + padding: 2px; +} + +#inspectorNameField { + background-color: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: #F4F7F8; + font-size: 15px; + font-weight: 650; + padding: 2px 4px; + min-height: 20px; +} + +#inspectorNameField:hover { + background-color: #1D2123; + border-color: #3C464B; +} + +#inspectorNameField:focus { + background-color: #171A1B; + border-color: #5FB3FF; +} + +#inspectorTypeLabel { + color: #879299; + padding-left: 5px; + font-size: 11px; +} + +#inspectorComponent { + background-color: #23282A; + border: 1px solid #363E42; + border-radius: 7px; +} + +#inspectorComponentHeader { + background-color: #2A3033; + border: none; + border-bottom: 1px solid #343D41; + border-radius: 6px; + padding: 7px 9px; + color: #EEF2F4; + font-weight: 650; + text-align: left; +} + +#inspectorComponentHeader:hover { + background-color: #30373A; +} + +#inspectorComponentBody { + background-color: transparent; +} + +#inspectorPropertyRow { + background-color: transparent; + border: none; +} + +#inspectorPropertyLabel { + color: #ABB5BA; + font-size: 12px; +} + +#inspectorVectorField, +#inspectorColorField { + background-color: #191C1D; + border: 1px solid #363D41; + border-radius: 5px; +} + +#inspectorVectorField:focus-within, +#inspectorColorField:focus-within { + border-color: #5FB3FF; +} + +#inspectorAxisLabel { + color: #748088; + font-size: 10px; + font-weight: 700; + min-width: 10px; +} + +#inspectorVectorField QDoubleSpinBox { + background-color: transparent; + border: none; + border-radius: 0; + padding: 4px 2px; +} + +#inspectorColorSwatch { + border: 1px solid #566167; + border-radius: 4px; + padding: 0; + min-height: 18px; +} + +#inspectorColorText { + background-color: transparent; + border: none; + padding: 3px; +} + +#inspectorNestedGroup { + background-color: #202426; + border-top: 1px solid #30373A; + border-bottom: 1px solid #30373A; +} + +#inspectorNestedTitle { + color: #D1D8DC; + font-weight: 650; + padding: 2px 1px 4px 1px; +} + +#inspectorArrayTitle { + color: #7FB9EC; + font-size: 11px; + font-weight: 650; + padding: 5px 2px 1px 2px; +} + +#inspectorEmptyTitle { + color: #DDE3E6; + font-size: 15px; + font-weight: 650; +} + +#inspectorEmptyHint { + color: #77838A; + font-size: 12px; +} + +#inspectorOpenAssetButton { + min-height: 24px; +} + QHeaderView { background-color: #252A2C; border: none; diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 92d38c0c..4304a4d9 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -106,26 +106,34 @@ void EditorWindow::setupDocks() { .area = EditorDockArea::Center, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); + auto *hierarchyPanel = new HierarchyPanel(viewportPanel); dockManager->addPanel( {.id = "hierarchy", .title = "Hierarchy Panel", - .widget = new HierarchyPanel(viewportPanel), + .widget = hierarchyPanel, .area = EditorDockArea::Left, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); + auto *inspectorPanel = new InspectorPanel(viewportPanel); dockManager->addPanel( {.id = "inspector", .title = "Inspector", - .widget = new InspectorPanel(), + .widget = inspectorPanel, .area = EditorDockArea::Right, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); + auto *contentBrowser = new ContentBrowserPanel(projectFile); dockManager->addPanel( {.id = "fileExplorer", .title = "Content Browser", - .widget = new ContentBrowserPanel(projectFile), + .widget = contentBrowser, .area = EditorDockArea::Bottom, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); + + connect(hierarchyPanel, &HierarchyPanel::objectActivated, inspectorPanel, + &InspectorPanel::inspectRuntimeObject); + connect(contentBrowser, &ContentBrowserPanel::selectionChanged, + inspectorPanel, &InspectorPanel::inspectFile); } void EditorWindow::saveLayout() { diff --git a/editor/views/editor/hierarchy.cpp b/editor/views/editor/hierarchy.cpp index 1bf6f582..4103bfb4 100644 --- a/editor/views/editor/hierarchy.cpp +++ b/editor/views/editor/hierarchy.cpp @@ -87,7 +87,7 @@ QString objectSignature(const QJsonArray &objects) { } return signature; } -} +} // namespace HierarchyPanel::HierarchyPanel(ViewportPanel *viewport, QWidget *parent) : QWidget(parent), viewport(viewport) { @@ -366,6 +366,7 @@ void HierarchyPanel::focusSelectedObject() { const int id = selectedObjectId(); if (id >= 0) { viewport->selectRuntimeObject(id, true); + emit objectActivated(id); } } diff --git a/editor/views/editor/inspector.cpp b/editor/views/editor/inspector.cpp index ae9fc0a9..2bf19e96 100644 --- a/editor/views/editor/inspector.cpp +++ b/editor/views/editor/inspector.cpp @@ -1,65 +1,920 @@ /* -* inspector.cpp -* As part of the Atlas project -* Created by Max Van den Eynde in 2026 -* -------------------------------------- -* Description: Inspector definition and functions -* Copyright (c) 2026 Max Van den Eynde -*/ + * inspector.cpp + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Inspector definition and functions + * Copyright (c) 2026 Max Van den Eynde + */ #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 +#include + +#include +#include +#include + +#include "editor/views/viewport.h" + +namespace { +using PropertyChanged = + std::function; + +QString humanize(const QString &value) { + QString result; + for (int i = 0; i < value.size(); ++i) { + const QChar character = value.at(i); + if (i > 0 && character.isUpper() && value.at(i - 1).isLower()) { + result += ' '; + } + result += i == 0 ? character.toUpper() : character; + } + return result.replace('_', ' '); +} + +QString pointerSegment(QString value) { + return value.replace('~', "~0").replace('/', "~1"); +} + +QString childPath(const QString &path, const QString &key) { + return path + '/' + pointerSegment(key); +} + +bool isNumericArray(const QJsonArray &array) { + return std::all_of(array.begin(), array.end(), [](const QJsonValue &value) { + return value.isDouble(); + }); +} + +bool isColorProperty(const QString &name, const QJsonArray &array) { + return name.contains("color", Qt::CaseInsensitive) && + (array.size() == 3 || array.size() == 4) && isNumericArray(array); +} + +QIcon inspectorIcon(QWidget *widget, const QString &type) { + const QString normalized = type.toLower(); + QStyle::StandardPixmap fallback = QStyle::SP_FileIcon; + QString themeName = "application-x-executable"; + if (normalized == "folder") { + fallback = QStyle::SP_DirIcon; + themeName = "folder"; + } else if (normalized.contains("camera")) { + fallback = QStyle::SP_ComputerIcon; + themeName = "camera-photo"; + } else if (normalized.contains("light") || normalized == "sun") { + fallback = QStyle::SP_MessageBoxInformation; + themeName = "weather-clear"; + } else if (normalized.contains("terrain")) { + fallback = QStyle::SP_DriveHDIcon; + themeName = "applications-graphics"; + } else if (normalized.contains("particle")) { + fallback = QStyle::SP_BrowserReload; + themeName = "weather-showers-scattered"; + } else if (normalized == "model") { + fallback = QStyle::SP_FileDialogContentsView; + themeName = "model"; + } else if (normalized == "solid" || normalized == "cube" || + normalized == "sphere" || normalized == "plane" || + normalized == "pyramid" || normalized == "capsule") { + fallback = QStyle::SP_DirIcon; + themeName = "applications-games"; + } + return QIcon::fromTheme(themeName, widget->style()->standardIcon(fallback)); +} + +QString componentTitle(const QString &type) { + const QString normalized = type.toLower().remove('_').remove('-'); + if (normalized == "script") + return "Script"; + if (normalized == "traitscript") + return "Trait Script"; + if (normalized == "rigidbody") + return "Rigidbody"; + if (normalized == "audioplayer") + return "Audio Player"; + if (normalized == "joint") + return "Joint"; + if (normalized == "fixedjoint") + return "Fixed Joint"; + if (normalized == "hingejoint") + return "Hinge Joint"; + if (normalized == "springjoint") + return "Spring Joint"; + if (normalized == "vehicle") + return "Vehicle"; + return humanize(type); +} + +QJsonObject mergeObjects(QJsonObject base, const QJsonObject &values) { + for (auto iterator = values.begin(); iterator != values.end(); ++iterator) { + if (iterator.value().isObject() && + base.value(iterator.key()).isObject()) { + base.insert(iterator.key(), + mergeObjects(base.value(iterator.key()).toObject(), + iterator.value().toObject())); + } else { + base.insert(iterator.key(), iterator.value()); + } + } + return base; +} + +QJsonObject vehicleWheelSchema() { + return {{"position", QJsonArray{0.0, 0.0, 0.0}}, + {"enableSuspensionForcePoint", false}, + {"suspensionForcePoint", QJsonArray{0.0, 0.0, 0.0}}, + {"suspensionDirection", QJsonArray{0.0, -1.0, 0.0}}, + {"steeringAxis", QJsonArray{0.0, 1.0, 0.0}}, + {"wheelUp", QJsonArray{0.0, 1.0, 0.0}}, + {"wheelForward", QJsonArray{0.0, 0.0, 1.0}}, + {"suspensionMinLength", 0.0}, + {"suspensionMaxLength", 0.0}, + {"suspensionPreloadLength", 0.0}, + {"suspensionFrequencyHz", 0.0}, + {"suspensionDampingRatio", 0.0}, + {"radius", 0.0}, + {"width", 0.0}, + {"inertia", 0.0}, + {"angularDamping", 0.0}, + {"maxSteerAngleDeg", 0.0}, + {"maxBrakeTorque", 0.0}, + {"maxHandBrakeTorque", 0.0}}; +} + +QJsonObject vehicleDifferentialSchema() { + return {{"leftWheel", 0}, {"rightWheel", 0}, + {"differentialRatio", 0.0}, {"leftRightSplit", 0.5}, + {"limitedSlipRatio", 0.0}, {"engineTorqueRatio", 1.0}}; +} + +QJsonObject componentSchema(const QString &type) { + const QString normalized = type.toLower().remove('_').remove('-'); + if (normalized == "script") { + return {{"name", ""}, {"source", ""}, {"variables", QJsonObject{}}}; + } + if (normalized == "traitscript") { + return {{"name", ""}, + {"source", ""}, + {"traitedType", ""}, + {"variables", QJsonObject{}}}; + } + if (normalized == "rigidbody") { + return {{"mass", 1.0}, + {"sendSignal", ""}, + {"isSensor", false}, + {"collider", QJsonObject{{"type", "box"}, + {"size", QJsonArray{1.0, 1.0, 1.0}}, + {"radius", 0.5}, + {"height", 1.0}}}, + {"friction", 0.5}, + {"tags", QJsonArray{}}, + {"damping", QJsonObject{{"linear", 0.0}, {"angular", 0.0}}}, + {"restitution", 0.0}, + {"motionType", "dynamic"}}; + } + if (normalized == "audioplayer") { + return {{"source", ""}, + {"position", QJsonArray{0.0, 0.0, 0.0}}, + {"useSpatialization", true}, + {"autoplay", false}}; + } + QJsonObject joint{ + {"parent", ""}, {"child", ""}, + {"space", "world"}, {"anchor", QJsonArray{0.0, 0.0, 0.0}}, + {"breakForce", 0.0}, {"breakTorque", 0.0}}; + if (normalized == "joint" || normalized == "fixedjoint") { + return joint; + } + if (normalized == "hingejoint") { + joint.insert("axis1", QJsonArray{1.0, 0.0, 0.0}); + joint.insert("axis2", QJsonArray{0.0, 1.0, 0.0}); + joint.insert("limits", QJsonObject{{"enabled", false}, + {"minAngle", 0.0}, + {"maxAngle", 0.0}}); + joint.insert("motor", QJsonObject{{"enabled", false}, + {"targetVelocity", 0.0}, + {"maxForce", 0.0}, + {"maxTorque", 0.0}}); + return joint; + } + if (normalized == "springjoint") { + joint.insert("anchorB", QJsonArray{0.0, 0.0, 0.0}); + joint.insert("restLength", 1.0); + joint.insert("useLimits", false); + joint.insert("minLength", 0.0); + joint.insert("maxLength", 1.0); + joint.insert("spring", QJsonObject{{"enabled", true}, + {"mode", "frequencyAndDamping"}, + {"frequencyHz", 1.0}, + {"dampingRatio", 0.5}, + {"stiffness", 1.0}, + {"damping", 0.5}}); + return joint; + } + if (normalized == "vehicle") { + return { + {"settings", + QJsonObject{ + {"up", QJsonArray{0.0, 1.0, 0.0}}, + {"forward", QJsonArray{0.0, 0.0, 1.0}}, + {"maxPitchRollAngleDeg", 60.0}, + {"maxSlopeAngleDeg", 45.0}, + {"wheels", QJsonArray{}}, + {"controller", + QJsonObject{ + {"engine", QJsonObject{{"maxTorque", 0.0}, + {"minRPM", 0.0}, + {"maxRPM", 0.0}, + {"inertia", 0.0}, + {"angularDamping", 0.0}}}, + {"transmission", QJsonObject{{"type", "automatic"}, + {"gearRatios", QJsonArray{}}, + {"reverseGearRatio", 0.0}, + {"switchTime", 0.0}, + {"clutchReleaseTime", 0.0}, + {"switchLatency", 0.0}, + {"shiftUpRPM", 0.0}, + {"shiftDownRPM", 0.0}, + {"clutchStrength", 0.0}}}, + {"differentials", QJsonArray{}}, + {"differentialLimitedSlipRatio", 0.0}}}}}}; + } + return {}; +} -static QDoubleSpinBox* makeNumberBox(QWidget* parent) { - auto* box = new QDoubleSpinBox(parent); - box->setRange(-100000.0, 100000.0); - box->setDecimals(3); - return box; +QJsonObject componentValues(const QString &type, const QJsonObject &raw) { + QJsonObject values = raw; + values.remove("type"); + values = mergeObjects(componentSchema(type), values); + const QString normalized = type.toLower().remove('_').remove('-'); + if (normalized != "vehicle") { + return values; + } + QJsonObject settings = values.value("settings").toObject(); + QJsonArray wheels = settings.value("wheels").toArray(); + for (int index = 0; index < wheels.size(); ++index) { + if (wheels.at(index).isObject()) { + wheels.replace(index, mergeObjects(vehicleWheelSchema(), + wheels.at(index).toObject())); + } + } + settings.insert("wheels", wheels); + QJsonObject controller = settings.value("controller").toObject(); + QJsonArray differentials = controller.value("differentials").toArray(); + for (int index = 0; index < differentials.size(); ++index) { + if (differentials.at(index).isObject()) { + differentials.replace( + index, mergeObjects(vehicleDifferentialSchema(), + differentials.at(index).toObject())); + } + } + controller.insert("differentials", differentials); + settings.insert("controller", controller); + values.insert("settings", settings); + return values; } -InspectorPanel::InspectorPanel(QWidget* parent) - : QWidget(parent) { - auto* rootLayout = new QVBoxLayout(this); - rootLayout->setContentsMargins(8, 8, 8, 8); +QStringList choicesFor(const QString &path) { + const QString key = path.section('/', -1).toLower(); + if (key == "motiontype") + return {"static", "dynamic", "kinematic"}; + if (key == "space") + return {"world", "local"}; + if (key == "mode") + return {"frequencyAndDamping", "stiffnessAndDamping"}; + if (key == "type" && path.contains("transmission")) { + return {"automatic", "manual"}; + } + if (key == "type" && path.contains("collider")) { + return {"box", "sphere", "capsule", "mesh"}; + } + return {}; +} + +QDoubleSpinBox *numberField(double value, QWidget *parent) { + auto *field = new QDoubleSpinBox(parent); + field->setObjectName("inspectorNumberField"); + field->setRange(-1000000000.0, 1000000000.0); + field->setDecimals(4); + field->setSingleStep(0.1); + field->setValue(value); + field->setKeyboardTracking(false); + return field; +} + +QWidget *vectorField(const QJsonArray &value, const PropertyChanged &changed, + const QString &path, QWidget *parent) { + auto *field = new QFrame(parent); + field->setObjectName("inspectorVectorField"); + auto *layout = new QHBoxLayout(field); + layout->setContentsMargins(3, 0, 3, 0); + layout->setSpacing(2); + auto values = value; + while (values.size() < 3) + values.append(0.0); + const QStringList axes{"X", "Y", "Z"}; + QList boxes; + for (int index = 0; index < 3; ++index) { + auto *axis = new QLabel(axes.at(index), field); + axis->setObjectName("inspectorAxisLabel"); + auto *box = numberField(values.at(index).toDouble(), field); + box->setButtonSymbols(QAbstractSpinBox::NoButtons); + box->setMinimumWidth(38); + boxes.append(box); + layout->addWidget(axis); + layout->addWidget(box, 1); + } + auto commit = [boxes, changed, path] { + changed(path, QJsonArray{boxes.at(0)->value(), boxes.at(1)->value(), + boxes.at(2)->value()}); + }; + for (QDoubleSpinBox *box : boxes) { + QObject::connect(box, &QDoubleSpinBox::editingFinished, field, commit); + } + return field; +} + +QWidget *colorField(const QJsonArray &value, const PropertyChanged &changed, + const QString &path, QWidget *parent) { + auto *field = new QFrame(parent); + field->setObjectName("inspectorColorField"); + auto *layout = new QHBoxLayout(field); + layout->setContentsMargins(3, 2, 3, 2); + layout->setSpacing(5); + const bool normalized = + std::all_of(value.begin(), value.end(), [](const QJsonValue &entry) { + return entry.toDouble() <= 1.0; + }); + const double factor = normalized ? 255.0 : 1.0; + QColor color( + std::clamp(static_cast(value.at(0).toDouble() * factor), 0, 255), + std::clamp(static_cast(value.at(1).toDouble() * factor), 0, 255), + std::clamp(static_cast(value.at(2).toDouble() * factor), 0, 255), + value.size() > 3 + ? std::clamp(static_cast(value.at(3).toDouble() * factor), 0, + 255) + : 255); + auto *swatch = new QPushButton(field); + swatch->setObjectName("inspectorColorSwatch"); + swatch->setFixedSize(30, 22); + auto *text = new QLineEdit(field); + text->setObjectName("inspectorColorText"); + auto updateDisplay = [swatch, text](const QColor &next) { + swatch->setStyleSheet( + QStringLiteral("background-color: rgba(%1,%2,%3,%4);") + .arg(next.red()) + .arg(next.green()) + .arg(next.blue()) + .arg(next.alpha())); + text->setText(next.name(QColor::HexArgb).toUpper()); + }; + updateDisplay(color); + layout->addWidget(swatch); + layout->addWidget(text, 1); + QObject::connect( + swatch, &QPushButton::clicked, field, + [field, color, normalized, value, changed, path, + updateDisplay]() mutable { + const QColor next = QColorDialog::getColor( + color, field, "Choose Color", QColorDialog::ShowAlphaChannel); + if (!next.isValid()) + return; + color = next; + updateDisplay(color); + const double divisor = normalized ? 255.0 : 1.0; + QJsonArray result{color.red() / divisor, color.green() / divisor, + color.blue() / divisor}; + if (value.size() > 3) + result.append(color.alpha() / divisor); + changed(path, result); + }); + QObject::connect(text, &QLineEdit::editingFinished, field, + [text, normalized, value, changed, path, updateDisplay] { + const QColor next(text->text()); + if (!next.isValid()) + return; + updateDisplay(next); + const double divisor = normalized ? 255.0 : 1.0; + QJsonArray result{next.red() / divisor, + next.green() / divisor, + next.blue() / divisor}; + if (value.size() > 3) + result.append(next.alpha() / divisor); + changed(path, result); + }); + return field; +} + +void addPropertyRows(QVBoxLayout *layout, const QJsonObject &properties, + const QString &path, const PropertyChanged &changed, + QWidget *parent); - auto* objectGroup = new QGroupBox("Object", this); - auto* objectLayout = new QFormLayout(objectGroup); +QWidget *primitiveField(const QString &name, const QString &path, + const QJsonValue &value, const PropertyChanged &changed, + QWidget *parent) { + if (value.isBool()) { + auto *field = new QCheckBox(parent); + field->setChecked(value.toBool()); + QObject::connect( + field, &QCheckBox::toggled, parent, + [changed, path](bool checked) { changed(path, checked); }); + return field; + } + if (value.isDouble()) { + auto *field = numberField(value.toDouble(), parent); + QObject::connect( + field, &QDoubleSpinBox::editingFinished, parent, + [field, changed, path] { changed(path, field->value()); }); + return field; + } + if (value.isArray()) { + const QJsonArray array = value.toArray(); + if (isColorProperty(name, array)) { + return colorField(array, changed, path, parent); + } + if (array.size() == 3 && isNumericArray(array)) { + return vectorField(array, changed, path, parent); + } + auto *field = new QLineEdit(parent); + QStringList entries; + for (const QJsonValue &entry : array) { + entries.append(entry.isString() + ? entry.toString() + : QString::number(entry.toDouble())); + } + field->setText(entries.join(", ")); + field->setPlaceholderText("No items"); + QObject::connect( + field, &QLineEdit::editingFinished, parent, + [field, array, changed, path] { + QJsonArray result; + for (const QString &entry : + field->text().split(',', Qt::SkipEmptyParts)) { + const QString value = entry.trimmed(); + bool numeric = false; + const double number = value.toDouble(&numeric); + result.append(!array.isEmpty() && + array.first().isDouble() && numeric + ? QJsonValue(number) + : QJsonValue(value)); + } + changed(path, result); + }); + return field; + } + const QStringList choices = choicesFor(path); + if (!choices.isEmpty()) { + auto *field = new QComboBox(parent); + field->addItems(choices); + field->setCurrentText(value.toString()); + QObject::connect( + field, &QComboBox::currentTextChanged, parent, + [changed, path](const QString &text) { changed(path, text); }); + return field; + } + auto *field = new QLineEdit(value.toString(), parent); + QObject::connect(field, &QLineEdit::editingFinished, parent, + [field, changed, path] { changed(path, field->text()); }); + return field; +} - nameField = new QLineEdit(objectGroup); - objectLayout->addRow("Name", nameField); +QFrame *propertyRow(const QString &label, QWidget *editor, QWidget *parent) { + auto *row = new QFrame(parent); + row->setObjectName("inspectorPropertyRow"); + auto *layout = new QHBoxLayout(row); + layout->setContentsMargins(8, 3, 8, 3); + layout->setSpacing(8); + auto *name = new QLabel(label, row); + name->setObjectName("inspectorPropertyLabel"); + name->setMinimumWidth(104); + name->setMaximumWidth(128); + layout->addWidget(name); + layout->addWidget(editor, 1); + return row; +} - auto* active = new QCheckBox(objectGroup); - active->setChecked(true); - objectLayout->addRow("Active", active); +void addPropertyRows(QVBoxLayout *layout, const QJsonObject &properties, + const QString &path, const PropertyChanged &changed, + QWidget *parent) { + for (auto iterator = properties.begin(); iterator != properties.end(); + ++iterator) { + const QString key = iterator.key(); + const QString nextPath = childPath(path, key); + if (iterator.value().isObject()) { + auto *group = new QFrame(parent); + group->setObjectName("inspectorNestedGroup"); + auto *groupLayout = new QVBoxLayout(group); + groupLayout->setContentsMargins(8, 6, 8, 7); + groupLayout->setSpacing(2); + auto *title = new QLabel(humanize(key), group); + title->setObjectName("inspectorNestedTitle"); + groupLayout->addWidget(title); + addPropertyRows(groupLayout, iterator.value().toObject(), nextPath, + changed, group); + layout->addWidget(group); + continue; + } + if (iterator.value().isArray()) { + const QJsonArray array = iterator.value().toArray(); + if (!array.isEmpty() && array.first().isObject()) { + auto *group = new QFrame(parent); + group->setObjectName("inspectorNestedGroup"); + auto *groupLayout = new QVBoxLayout(group); + groupLayout->setContentsMargins(8, 6, 8, 7); + groupLayout->setSpacing(3); + auto *title = new QLabel(humanize(key), group); + title->setObjectName("inspectorNestedTitle"); + groupLayout->addWidget(title); + for (int index = 0; index < array.size(); ++index) { + auto *itemTitle = new QLabel(QStringLiteral("%1 %2") + .arg(humanize(key)) + .arg(index + 1), + group); + itemTitle->setObjectName("inspectorArrayTitle"); + groupLayout->addWidget(itemTitle); + addPropertyRows(groupLayout, array.at(index).toObject(), + nextPath + '/' + QString::number(index), + changed, group); + } + layout->addWidget(group); + continue; + } + } + QWidget *editor = + primitiveField(key, nextPath, iterator.value(), changed, parent); + layout->addWidget(propertyRow(humanize(key), editor, parent)); + } +} - auto* transformGroup = new QGroupBox("Transform", this); - auto* transformLayout = new QFormLayout(transformGroup); +QFrame *componentCard(const QString &title, const QJsonObject &properties, + const QString &path, const PropertyChanged &changed, + QWidget *parent) { + auto *card = new QFrame(parent); + card->setObjectName("inspectorComponent"); + auto *layout = new QVBoxLayout(card); + layout->setContentsMargins(0, 0, 0, 7); + layout->setSpacing(2); + auto *header = new QToolButton(card); + header->setObjectName("inspectorComponentHeader"); + header->setText(title); + header->setCheckable(true); + header->setChecked(true); + header->setArrowType(Qt::DownArrow); + header->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + layout->addWidget(header); + auto *body = new QWidget(card); + body->setObjectName("inspectorComponentBody"); + auto *bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(0, 2, 0, 0); + bodyLayout->setSpacing(1); + addPropertyRows(bodyLayout, properties, path, changed, body); + layout->addWidget(body); + QObject::connect( + header, &QToolButton::toggled, card, [header, body](bool expanded) { + body->setVisible(expanded); + header->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + }); + return card; +} - transformLayout->addRow("Position X", makeNumberBox(transformGroup)); - transformLayout->addRow("Position Y", makeNumberBox(transformGroup)); - transformLayout->addRow("Position Z", makeNumberBox(transformGroup)); +QJsonObject findObjectInArray(const QJsonArray &objects, int id) { + for (const QJsonValue &value : objects) { + const QJsonObject object = value.toObject(); + if (object.value("id").toInt(-1) == id) + return object; + const QJsonObject child = + findObjectInArray(object.value("children").toArray(), id); + if (!child.isEmpty()) + return child; + } + return {}; +} +} // namespace - transformLayout->addRow("Rotation X", makeNumberBox(transformGroup)); - transformLayout->addRow("Rotation Y", makeNumberBox(transformGroup)); - transformLayout->addRow("Rotation Z", makeNumberBox(transformGroup)); +InspectorPanel::InspectorPanel(ViewportPanel *viewport, QWidget *parent) + : QWidget(parent), viewport(viewport) { + setObjectName("inspectorPanel"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + scrollArea = new QScrollArea(this); + scrollArea->setObjectName("inspectorScroll"); + scrollArea->setWidgetResizable(true); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + content = new QWidget(scrollArea); + content->setObjectName("inspectorContent"); + contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(8, 8, 8, 10); + contentLayout->setSpacing(8); + scrollArea->setWidget(content); + layout->addWidget(scrollArea); + if (viewport != nullptr) { + connect(viewport, &ViewportPanel::sceneSnapshotChanged, this, + &InspectorPanel::applySceneSnapshot); + } + showEmptyState(); +} - transformLayout->addRow("Scale X", makeNumberBox(transformGroup)); - transformLayout->addRow("Scale Y", makeNumberBox(transformGroup)); - transformLayout->addRow("Scale Z", makeNumberBox(transformGroup)); +void InspectorPanel::applySceneSnapshot(const QString &snapshot) { + QJsonParseError error; + const QJsonDocument document = + QJsonDocument::fromJson(snapshot.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return; + scene = document.object(); + const int selected = scene.value("selectedId").toInt(-1); + const bool selectionChanged = selected != lastRuntimeSelection; + lastRuntimeSelection = selected; + if (selectionChanged) { + inspectRuntimeObject(selected); + } else if (!fileTarget && inspectedObjectId >= 0) { + const QJsonObject updated = findObject(inspectedObjectId); + const bool contentChanged = + updated.value("name") != inspectedObject.value("name") || + updated.value("properties") != + inspectedObject.value("properties") || + updated.value("components") != inspectedObject.value("components"); + inspectedObject = updated; + if (contentChanged) { + showObject(inspectedObject); + } + } +} - rootLayout->addWidget(objectGroup); - rootLayout->addWidget(transformGroup); - rootLayout->addStretch(); +void InspectorPanel::inspectRuntimeObject(int id) { + fileTarget = false; + inspectedFile.clear(); + inspectedObjectId = id; + inspectedObject = findObject(id); + if (inspectedObject.isEmpty()) { + showEmptyState(); + } else { + showObject(inspectedObject); + } +} + +void InspectorPanel::inspectFile(const QString &path) { + if (path.isEmpty()) { + fileTarget = false; + inspectRuntimeObject(lastRuntimeSelection); + return; + } + fileTarget = true; + inspectedObjectId = -1; + inspectedObject = {}; + inspectedFile = path; + showFile(); +} + +void InspectorPanel::showEmptyState() { + rebuildBody(); + auto *empty = new QWidget(content); + auto *layout = new QVBoxLayout(empty); + layout->setContentsMargins(20, 48, 20, 20); + auto *title = new QLabel("Nothing selected", empty); + title->setObjectName("inspectorEmptyTitle"); + title->setAlignment(Qt::AlignCenter); + auto *hint = new QLabel( + "Select an object in the Hierarchy or an asset in the Content Browser.", + empty); + hint->setObjectName("inspectorEmptyHint"); + hint->setWordWrap(true); + hint->setAlignment(Qt::AlignCenter); + layout->addWidget(title); + layout->addWidget(hint); + layout->addStretch(); + contentLayout->addWidget(empty, 1); +} + +void InspectorPanel::showObject(const QJsonObject &object) { + rebuildBody(); + const QString type = object.value("type").toString("Object"); + auto *header = new QFrame(content); + header->setObjectName("inspectorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(10, 10, 10, 10); + headerLayout->setSpacing(10); + iconLabel = new QLabel(header); + iconLabel->setObjectName("inspectorObjectIcon"); + iconLabel->setPixmap(inspectorIcon(this, type).pixmap(42, 42)); + iconLabel->setFixedSize(46, 46); + auto *identity = new QWidget(header); + auto *identityLayout = new QVBoxLayout(identity); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->setSpacing(2); + nameField = + new QLineEdit(object.value("name").toString("Object"), identity); + nameField->setObjectName("inspectorNameField"); + typeLabel = new QLabel(humanize(type), identity); + typeLabel->setObjectName("inspectorTypeLabel"); + identityLayout->addWidget(nameField); + identityLayout->addWidget(typeLabel); + headerLayout->addWidget(iconLabel); + headerLayout->addWidget(identity, 1); + contentLayout->addWidget(header); + connect(nameField, &QLineEdit::editingFinished, this, + &InspectorPanel::commitHeaderName); + + QPointer runtime(viewport); + const int objectId = inspectedObjectId; + auto update = [runtime, objectId](const QString &component, int index, + const QString &path, + const QJsonValue &value) { + QTimer::singleShot(0, + [runtime, objectId, component, index, path, value] { + if (runtime != nullptr) { + runtime->setRuntimeObjectProperty( + objectId, component, index, path, value); + } + }); + }; + QJsonObject transform{{"position", object.value("position")}, + {"rotation", object.value("rotation")}, + {"scale", object.value("scale")}}; + contentLayout->addWidget(componentCard( + "Transform", transform, QString(), + [update](const QString &path, const QJsonValue &value) { + update("transform", -1, path, value); + }, + content)); + + QJsonObject objectProperties = object.value("properties").toObject(); + const QStringList hidden{"id", "name", "type", + "position", "rotation", "scale", + "parent", "components", "objects"}; + for (const QString &key : hidden) + objectProperties.remove(key); + if (!objectProperties.isEmpty()) { + contentLayout->addWidget(componentCard( + componentTitle(type), objectProperties, QString(), + [update](const QString &path, const QJsonValue &value) { + update("object", -1, path, value); + }, + content)); + } + + const QJsonArray components = object.value("components").toArray(); + for (int index = 0; index < components.size(); ++index) { + const QJsonObject raw = components.at(index).toObject(); + const QString componentType = raw.value("type").toString("component"); + const QJsonObject values = componentValues(componentType, raw); + contentLayout->addWidget(componentCard( + componentTitle(componentType), values, QString(), + [update, componentType, index](const QString &path, + const QJsonValue &value) { + update(componentType, index, path, value); + }, + content)); + } + contentLayout->addStretch(); +} + +void InspectorPanel::showFile() { + rebuildBody(); + const QFileInfo info(inspectedFile); + if (!info.exists()) { + showEmptyState(); + return; + } + auto *header = new QFrame(content); + header->setObjectName("inspectorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(10, 10, 10, 10); + headerLayout->setSpacing(10); + iconLabel = new QLabel(header); + iconLabel->setObjectName("inspectorObjectIcon"); + const QString iconType = info.isDir() ? "folder" : info.suffix(); + iconLabel->setPixmap(inspectorIcon(this, iconType).pixmap(42, 42)); + iconLabel->setFixedSize(46, 46); + auto *identity = new QWidget(header); + auto *identityLayout = new QVBoxLayout(identity); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->setSpacing(2); + nameField = new QLineEdit(info.fileName(), identity); + nameField->setObjectName("inspectorNameField"); + typeLabel = new QLabel( + info.isDir() ? "Folder" : info.suffix().toUpper() + " Asset", identity); + typeLabel->setObjectName("inspectorTypeLabel"); + identityLayout->addWidget(nameField); + identityLayout->addWidget(typeLabel); + headerLayout->addWidget(iconLabel); + headerLayout->addWidget(identity, 1); + contentLayout->addWidget(header); + connect(nameField, &QLineEdit::editingFinished, this, + &InspectorPanel::commitHeaderName); + + QJsonObject metadata{ + {"path", info.absoluteFilePath()}, + {"size", static_cast(info.size())}, + {"modified", info.lastModified().toString(Qt::ISODate)}}; + auto *metadataCard = componentCard( + "Asset", metadata, QString(), + [](const QString &, const QJsonValue &) {}, content); + metadataCard->setEnabled(false); + contentLayout->addWidget(metadataCard); + + if (!info.isDir() && + (info.suffix().compare("ascene", Qt::CaseInsensitive) == 0 || + info.suffix().compare("amat", Qt::CaseInsensitive) == 0 || + info.suffix().compare("json", Qt::CaseInsensitive) == 0)) { + QFile file(info.absoluteFilePath()); + if (file.open(QIODevice::ReadOnly)) { + QJsonParseError error; + const QJsonDocument document = + QJsonDocument::fromJson(file.readAll(), &error); + if (error.error == QJsonParseError::NoError && + document.isObject()) { + auto *propertiesCard = componentCard( + info.suffix().compare("ascene", Qt::CaseInsensitive) == 0 + ? "Scene" + : "Properties", + document.object(), QString(), + [](const QString &, const QJsonValue &) {}, content); + propertiesCard->setEnabled(false); + contentLayout->addWidget(propertiesCard); + } + } + } + auto *open = new QPushButton("Open in Default App", content); + open->setObjectName("inspectorOpenAssetButton"); + connect(open, &QPushButton::clicked, this, [this] { + QDesktopServices::openUrl(QUrl::fromLocalFile(inspectedFile)); + }); + contentLayout->addWidget(open); + contentLayout->addStretch(); +} + +void InspectorPanel::rebuildBody() { + rebuilding = true; + while (QLayoutItem *item = contentLayout->takeAt(0)) { + if (item->widget() != nullptr) + item->widget()->deleteLater(); + delete item; + } + iconLabel = nullptr; + typeLabel = nullptr; + nameField = nullptr; + rebuilding = false; +} - inspectTemporaryObject("Nothing Selected"); +void InspectorPanel::commitHeaderName() { + if (rebuilding || nameField == nullptr) + return; + const QString name = nameField->text().trimmed(); + if (name.isEmpty()) + return; + if (!fileTarget) { + const int id = inspectedObjectId; + QPointer runtime(viewport); + QTimer::singleShot(0, [runtime, id, name] { + if (runtime != nullptr) + runtime->renameRuntimeObject(id, name); + }); + return; + } + const QFileInfo info(inspectedFile); + if (name == info.fileName() || name.contains('/') || name.contains('\\')) { + return; + } + const QString next = info.dir().filePath(name); + if (QFileInfo::exists(next) || !QFile::rename(inspectedFile, next)) { + QMessageBox::warning(this, "Rename Asset", + "The asset could not be renamed."); + nameField->setText(info.fileName()); + return; + } + inspectedFile = next; + showFile(); } -void InspectorPanel::inspectTemporaryObject(const QString& name) { - nameField->setText(name); +QJsonObject InspectorPanel::findObject(int id) const { + if (id < 0) + return {}; + return findObjectInArray(scene.value("objects").toArray(), id); } diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp index 208a9995..c0af9001 100644 --- a/editor/views/general/contentBrowser.cpp +++ b/editor/views/general/contentBrowser.cpp @@ -71,7 +71,7 @@ bool isValidEntryName(const QString &name) { return !name.isEmpty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\'); } -} +} // namespace ContentBrowserPanel::ContentBrowserPanel(const QString &projectFile, QWidget *parent) @@ -222,7 +222,10 @@ ContentBrowserPanel::ContentBrowserPanel(const QString &projectFile, model->setNameFilterDisables(false); }); connect(gridView->selectionModel(), &QItemSelectionModel::selectionChanged, - this, [this] { updateNavigationState(); }); + this, [this] { + updateNavigationState(); + emit selectionChanged(selectedPath()); + }); auto *deleteAction = new QAction(this); deleteAction->setShortcut(QKeySequence::Delete); @@ -279,6 +282,7 @@ void ContentBrowserPanel::navigateTo(const QString &path, bool recordHistory) { historyIndex = history.size() - 1; } updateNavigationState(); + emit selectionChanged(QString()); } void ContentBrowserPanel::openIndex(const QModelIndex &index) { diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index 554ebf79..73dd48f5 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -515,6 +515,164 @@ inline constexpr const char* DARK_THEME = " color: #FFFFFF;\n" "}\n" "\n" +"QScrollArea#inspectorScroll {\n" +" background-color: #1B1F20;\n" +" border: none;\n" +" border-radius: 0;\n" +"}\n" +"\n" +"#inspectorContent {\n" +" background-color: #1B1F20;\n" +"}\n" +"\n" +"#inspectorHeader {\n" +" background-color: #24292B;\n" +" border: 1px solid #353D41;\n" +" border-radius: 8px;\n" +"}\n" +"\n" +"#inspectorObjectIcon {\n" +" background-color: #1C2022;\n" +" border: 1px solid #3B4449;\n" +" border-radius: 9px;\n" +" padding: 2px;\n" +"}\n" +"\n" +"#inspectorNameField {\n" +" background-color: transparent;\n" +" border: 1px solid transparent;\n" +" border-radius: 4px;\n" +" color: #F4F7F8;\n" +" font-size: 15px;\n" +" font-weight: 650;\n" +" padding: 2px 4px;\n" +" min-height: 20px;\n" +"}\n" +"\n" +"#inspectorNameField:hover {\n" +" background-color: #1D2123;\n" +" border-color: #3C464B;\n" +"}\n" +"\n" +"#inspectorNameField:focus {\n" +" background-color: #171A1B;\n" +" border-color: #5FB3FF;\n" +"}\n" +"\n" +"#inspectorTypeLabel {\n" +" color: #879299;\n" +" padding-left: 5px;\n" +" font-size: 11px;\n" +"}\n" +"\n" +"#inspectorComponent {\n" +" background-color: #23282A;\n" +" border: 1px solid #363E42;\n" +" border-radius: 7px;\n" +"}\n" +"\n" +"#inspectorComponentHeader {\n" +" background-color: #2A3033;\n" +" border: none;\n" +" border-bottom: 1px solid #343D41;\n" +" border-radius: 6px;\n" +" padding: 7px 9px;\n" +" color: #EEF2F4;\n" +" font-weight: 650;\n" +" text-align: left;\n" +"}\n" +"\n" +"#inspectorComponentHeader:hover {\n" +" background-color: #30373A;\n" +"}\n" +"\n" +"#inspectorComponentBody {\n" +" background-color: transparent;\n" +"}\n" +"\n" +"#inspectorPropertyRow {\n" +" background-color: transparent;\n" +" border: none;\n" +"}\n" +"\n" +"#inspectorPropertyLabel {\n" +" color: #ABB5BA;\n" +" font-size: 12px;\n" +"}\n" +"\n" +"#inspectorVectorField,\n" +"#inspectorColorField {\n" +" background-color: #191C1D;\n" +" border: 1px solid #363D41;\n" +" border-radius: 5px;\n" +"}\n" +"\n" +"#inspectorVectorField:focus-within,\n" +"#inspectorColorField:focus-within {\n" +" border-color: #5FB3FF;\n" +"}\n" +"\n" +"#inspectorAxisLabel {\n" +" color: #748088;\n" +" font-size: 10px;\n" +" font-weight: 700;\n" +" min-width: 10px;\n" +"}\n" +"\n" +"#inspectorVectorField QDoubleSpinBox {\n" +" background-color: transparent;\n" +" border: none;\n" +" border-radius: 0;\n" +" padding: 4px 2px;\n" +"}\n" +"\n" +"#inspectorColorSwatch {\n" +" border: 1px solid #566167;\n" +" border-radius: 4px;\n" +" padding: 0;\n" +" min-height: 18px;\n" +"}\n" +"\n" +"#inspectorColorText {\n" +" background-color: transparent;\n" +" border: none;\n" +" padding: 3px;\n" +"}\n" +"\n" +"#inspectorNestedGroup {\n" +" background-color: #202426;\n" +" border-top: 1px solid #30373A;\n" +" border-bottom: 1px solid #30373A;\n" +"}\n" +"\n" +"#inspectorNestedTitle {\n" +" color: #D1D8DC;\n" +" font-weight: 650;\n" +" padding: 2px 1px 4px 1px;\n" +"}\n" +"\n" +"#inspectorArrayTitle {\n" +" color: #7FB9EC;\n" +" font-size: 11px;\n" +" font-weight: 650;\n" +" padding: 5px 2px 1px 2px;\n" +"}\n" +"\n" +"#inspectorEmptyTitle {\n" +" color: #DDE3E6;\n" +" font-size: 15px;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#inspectorEmptyHint {\n" +" color: #77838A;\n" +" font-size: 12px;\n" +"}\n" +"\n" +"#inspectorOpenAssetButton {\n" +" min-height: 24px;\n" +"}\n" +"\n" "QHeaderView {\n" " background-color: #252A2C;\n" " border: none;\n" diff --git a/include/editor/views/fileExplorer.h b/include/editor/views/fileExplorer.h index 664548bc..6982295d 100644 --- a/include/editor/views/fileExplorer.h +++ b/include/editor/views/fileExplorer.h @@ -29,6 +29,9 @@ class ContentBrowserPanel : public QWidget { void setRootPath(const QString &path); + signals: + void selectionChanged(const QString &path); + private: void navigateTo(const QString &path, bool recordHistory = true); void openIndex(const QModelIndex &index); diff --git a/include/editor/views/hierarchyPanel.h b/include/editor/views/hierarchyPanel.h index aac5243a..a72a5dc4 100644 --- a/include/editor/views/hierarchyPanel.h +++ b/include/editor/views/hierarchyPanel.h @@ -29,6 +29,9 @@ class HierarchyPanel : public QWidget { public: explicit HierarchyPanel(ViewportPanel *viewport, QWidget *parent = nullptr); + signals: + void objectActivated(int id); + private: void applySceneSnapshot(const QString &snapshot); void rebuildScene(const QString &sceneName, const QJsonArray &objects, diff --git a/include/editor/views/inspectorView.h b/include/editor/views/inspectorView.h index 664649d7..f3c65a2c 100644 --- a/include/editor/views/inspectorView.h +++ b/include/editor/views/inspectorView.h @@ -1,33 +1,58 @@ /* -* inspectorView.h -* As part of the Atlas project -* Created by Max Van den Eynde in 2026 -* -------------------------------------- -* Description: Inspector View declaration -* Copyright (c) 2026 Max Van den Eynde -*/ + * inspectorView.h + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Inspector View declaration + * Copyright (c) 2026 Max Van den Eynde + */ #ifndef ATLAS_INSPECTORVIEW_H #define ATLAS_INSPECTORVIEW_H +#include +#include #include -class QFormLayout; +class QLabel; class QLineEdit; -class QDoubleSpinBox; -class QCheckBox; +class QScrollArea; +class QVBoxLayout; +class ViewportPanel; class InspectorPanel : public QWidget { Q_OBJECT -public: - explicit InspectorPanel(QWidget* parent = nullptr); - - void inspectTemporaryObject(const QString& name); - -private: - QFormLayout* form = nullptr; - QLineEdit* nameField = nullptr; + public: + explicit InspectorPanel(ViewportPanel *viewport, QWidget *parent = nullptr); + + public slots: + void applySceneSnapshot(const QString &snapshot); + void inspectRuntimeObject(int id); + void inspectFile(const QString &path); + + private: + void showEmptyState(); + void showObject(const QJsonObject &object); + void showFile(); + void rebuildBody(); + void commitHeaderName(); + QJsonObject findObject(int id) const; + + ViewportPanel *viewport = nullptr; + QScrollArea *scrollArea = nullptr; + QWidget *content = nullptr; + QVBoxLayout *contentLayout = nullptr; + QLabel *iconLabel = nullptr; + QLabel *typeLabel = nullptr; + QLineEdit *nameField = nullptr; + QJsonObject scene; + QJsonObject inspectedObject; + QString inspectedFile; + int inspectedObjectId = -1; + int lastRuntimeSelection = -1; + bool fileTarget = false; + bool rebuilding = false; }; -#endif //ATLAS_INSPECTORVIEW_H +#endif // ATLAS_INSPECTORVIEW_H diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 35ac0e14..1ebc4d1d 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -4002,7 +4002,7 @@ bool Context::setObjectProperty(int id, const std::string &component, } else { return false; } - editorObjectSourceData[id][propertyPath] = value; + setJsonProperty(editorObjectSourceData[id], "/" + property, value); syncEditorLightObject(*this, *object); return true; } From 3f205f0fcbc15626af06edc101549a7b1b4e108a Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 15:48:16 +0200 Subject: [PATCH 3/6] Polished inspector selection and component arrays --- editor/styling/dark.qss | 21 ++++++--- editor/views/editor/editor.cpp | 15 ++++++- editor/views/editor/inspector.cpp | 59 +++++++++++++++++++++---- editor/views/editor/viewport.cpp | 7 ++- editor/views/general/contentBrowser.cpp | 15 ++++++- include/editor/core/themes.h | 21 ++++++--- include/editor/views/fileExplorer.h | 1 + include/editor/views/viewport.h | 1 + runtime/lib/context.cpp | 4 ++ 9 files changed, 122 insertions(+), 22 deletions(-) diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss index 6906c118..b6a0ce57 100644 --- a/editor/styling/dark.qss +++ b/editor/styling/dark.qss @@ -600,11 +600,6 @@ QScrollArea#inspectorScroll { border-radius: 5px; } -#inspectorVectorField:focus-within, -#inspectorColorField:focus-within { - border-color: #5FB3FF; -} - #inspectorAxisLabel { color: #748088; font-size: 10px; @@ -651,6 +646,22 @@ QScrollArea#inspectorScroll { padding: 5px 2px 1px 2px; } +#inspectorArrayButton { + background-color: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: #AEB8BE; + min-width: 20px; + min-height: 18px; + padding: 1px 4px; +} + +#inspectorArrayButton:hover { + background-color: #30373A; + border-color: #465157; + color: #FFFFFF; +} + #inspectorEmptyTitle { color: #DDE3E6; font-size: 15px; diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 4304a4d9..21108e03 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -132,8 +132,19 @@ void EditorWindow::setupDocks() { connect(hierarchyPanel, &HierarchyPanel::objectActivated, inspectorPanel, &InspectorPanel::inspectRuntimeObject); - connect(contentBrowser, &ContentBrowserPanel::selectionChanged, - inspectorPanel, &InspectorPanel::inspectFile); + connect(hierarchyPanel, &HierarchyPanel::objectActivated, contentBrowser, + &ContentBrowserPanel::clearSelection); + connect(viewportPanel, &ViewportPanel::runtimeObjectActivated, + inspectorPanel, &InspectorPanel::inspectRuntimeObject); + connect(viewportPanel, &ViewportPanel::runtimeObjectActivated, + contentBrowser, &ContentBrowserPanel::clearSelection); + connect(contentBrowser, &ContentBrowserPanel::selectionChanged, this, + [this, inspectorPanel](const QString &path) { + if (!path.isEmpty()) { + viewportPanel->selectRuntimeObject(-1, false); + } + inspectorPanel->inspectFile(path); + }); } void EditorWindow::saveLayout() { diff --git a/editor/views/editor/inspector.cpp b/editor/views/editor/inspector.cpp index 2bf19e96..91ae9b49 100644 --- a/editor/views/editor/inspector.cpp +++ b/editor/views/editor/inspector.cpp @@ -543,22 +543,65 @@ void addPropertyRows(QVBoxLayout *layout, const QJsonObject &properties, } if (iterator.value().isArray()) { const QJsonArray array = iterator.value().toArray(); - if (!array.isEmpty() && array.first().isObject()) { + const bool structuredArray = + (!array.isEmpty() && array.first().isObject()) || + key.compare("wheels", Qt::CaseInsensitive) == 0 || + key.compare("differentials", Qt::CaseInsensitive) == 0; + if (structuredArray) { auto *group = new QFrame(parent); group->setObjectName("inspectorNestedGroup"); auto *groupLayout = new QVBoxLayout(group); groupLayout->setContentsMargins(8, 6, 8, 7); groupLayout->setSpacing(3); - auto *title = new QLabel(humanize(key), group); + auto *heading = new QWidget(group); + auto *headingLayout = new QHBoxLayout(heading); + headingLayout->setContentsMargins(0, 0, 0, 0); + headingLayout->setSpacing(4); + auto *title = new QLabel(humanize(key), heading); title->setObjectName("inspectorNestedTitle"); - groupLayout->addWidget(title); + auto *add = new QToolButton(heading); + add->setObjectName("inspectorArrayButton"); + add->setText("+"); + add->setToolTip(QStringLiteral("Add %1").arg(humanize(key))); + headingLayout->addWidget(title, 1); + headingLayout->addWidget(add); + groupLayout->addWidget(heading); + QObject::connect( + add, &QToolButton::clicked, group, + [array, key, nextPath, changed] { + QJsonArray result = array; + result.append( + key.compare("wheels", Qt::CaseInsensitive) == 0 + ? vehicleWheelSchema() + : vehicleDifferentialSchema()); + changed(nextPath, result); + }); for (int index = 0; index < array.size(); ++index) { - auto *itemTitle = new QLabel(QStringLiteral("%1 %2") - .arg(humanize(key)) - .arg(index + 1), - group); + auto *itemHeading = new QWidget(group); + auto *itemHeadingLayout = new QHBoxLayout(itemHeading); + itemHeadingLayout->setContentsMargins(0, 0, 0, 0); + itemHeadingLayout->setSpacing(4); + auto *itemTitle = new QLabel( + QStringLiteral("%1 %2") + .arg(key.compare("wheels", Qt::CaseInsensitive) == 0 + ? "Wheel" + : "Differential") + .arg(index + 1), + itemHeading); itemTitle->setObjectName("inspectorArrayTitle"); - groupLayout->addWidget(itemTitle); + auto *remove = new QToolButton(itemHeading); + remove->setObjectName("inspectorArrayButton"); + remove->setText("−"); + remove->setToolTip("Remove"); + itemHeadingLayout->addWidget(itemTitle, 1); + itemHeadingLayout->addWidget(remove); + groupLayout->addWidget(itemHeading); + QObject::connect(remove, &QToolButton::clicked, group, + [array, index, nextPath, changed] { + QJsonArray result = array; + result.removeAt(index); + changed(nextPath, result); + }); addPropertyRows(groupLayout, array.at(index).toObject(), nextPath + '/' + QString::number(index), changed, group); diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index 3cfa5075..0cb89404 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -199,6 +199,9 @@ void ViewportPanel::mousePressEvent(QMouseEvent *event) { sendPointerEvent(0, static_cast(event->position().x()), static_cast(event->position().y()), runtimeMouseButton(event->button())); + if (event->button() == Qt::LeftButton && runtimeContext != nullptr) { + emit runtimeObjectActivated(runtimeContext->selectedObjectId()); + } event->accept(); } @@ -383,7 +386,9 @@ bool ViewportPanel::selectRuntimeObject(int id, bool focusCamera) { return false; } refreshSceneSnapshot(); - setFocus(Qt::OtherFocusReason); + if (id >= 0) { + setFocus(Qt::OtherFocusReason); + } return true; } diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp index c0af9001..82845c41 100644 --- a/editor/views/general/contentBrowser.cpp +++ b/editor/views/general/contentBrowser.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -258,6 +259,16 @@ void ContentBrowserPanel::setRootPath(const QString &path) { navigateTo(projectRoot); } +void ContentBrowserPanel::clearSelection() { + if (gridView->selectionModel()->selectedIndexes().isEmpty()) { + return; + } + const QSignalBlocker blocker(gridView->selectionModel()); + gridView->clearSelection(); + gridView->setCurrentIndex(QModelIndex()); + updateNavigationState(); +} + void ContentBrowserPanel::navigateTo(const QString &path, bool recordHistory) { const QFileInfo info(path); const QString target = info.canonicalFilePath().isEmpty() @@ -453,7 +464,9 @@ void ContentBrowserPanel::copySelectionPath() const { QString ContentBrowserPanel::selectedPath() const { const QModelIndex index = gridView->currentIndex(); - return index.isValid() ? model->filePath(index) : QString(); + return index.isValid() && gridView->selectionModel()->isSelected(index) + ? model->filePath(index) + : QString(); } QString ContentBrowserPanel::uniquePath(const QString &baseName) const { diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index 73dd48f5..7f916e03 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -607,11 +607,6 @@ inline constexpr const char* DARK_THEME = " border-radius: 5px;\n" "}\n" "\n" -"#inspectorVectorField:focus-within,\n" -"#inspectorColorField:focus-within {\n" -" border-color: #5FB3FF;\n" -"}\n" -"\n" "#inspectorAxisLabel {\n" " color: #748088;\n" " font-size: 10px;\n" @@ -658,6 +653,22 @@ inline constexpr const char* DARK_THEME = " padding: 5px 2px 1px 2px;\n" "}\n" "\n" +"#inspectorArrayButton {\n" +" background-color: transparent;\n" +" border: 1px solid transparent;\n" +" border-radius: 4px;\n" +" color: #AEB8BE;\n" +" min-width: 20px;\n" +" min-height: 18px;\n" +" padding: 1px 4px;\n" +"}\n" +"\n" +"#inspectorArrayButton:hover {\n" +" background-color: #30373A;\n" +" border-color: #465157;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" "#inspectorEmptyTitle {\n" " color: #DDE3E6;\n" " font-size: 15px;\n" diff --git a/include/editor/views/fileExplorer.h b/include/editor/views/fileExplorer.h index 6982295d..17953fcb 100644 --- a/include/editor/views/fileExplorer.h +++ b/include/editor/views/fileExplorer.h @@ -28,6 +28,7 @@ class ContentBrowserPanel : public QWidget { QWidget *parent = nullptr); void setRootPath(const QString &path); + void clearSelection(); signals: void selectionChanged(const QString &path); diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h index 72d9a057..aba930b5 100644 --- a/include/editor/views/viewport.h +++ b/include/editor/views/viewport.h @@ -52,6 +52,7 @@ class ViewportPanel : public QWidget { signals: void sceneSnapshotChanged(const QString &snapshot); void runtimeAvailabilityChanged(bool available); + void runtimeObjectActivated(int id); protected: QPaintEngine *paintEngine() const override; diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 1ebc4d1d..b53a34b4 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -3942,6 +3942,10 @@ bool Context::selectObject(int id, bool focusCamera) { if (window == nullptr) { return false; } + if (id < 0) { + window->selectEditorObject(nullptr, false); + return true; + } GameObject *object = findContextObject(*this, id); if (object == nullptr) { return false; From 62ead5654018ff663038ff3ffc4048c883fe2525 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:05:54 +0200 Subject: [PATCH 4/6] More changes to the editor --- include/editor/core/themes.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index 7f916e03..45bd8778 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -3,7 +3,7 @@ // This file is generated. Do not edit manually. // Generated from .qss theme files. -// Source: editor/styling/dark.qss +// Source: /Users/maxvdec/Coding/Projects/Atlas/editor/styling/dark.qss inline constexpr const char* DARK_THEME = "* {\n" " font-family: \"Helvetica Neue\", \"Segoe UI\", Arial, sans-serif;\n" From 12a4cc92537356466e65e2f2d203ee08fd93e630 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:17:56 +0200 Subject: [PATCH 5/6] Made inspector components live and persistent --- atlas/physics/atlas_joints.cpp | 137 +++++----- editor/styling/dark.qss | 15 + editor/views/editor/editor.cpp | 47 +++- editor/views/editor/inspector.cpp | 135 +++++++-- editor/views/editor/viewport.cpp | 22 ++ include/atlas/runtime/c_api.h | 2 + include/atlas/runtime/context.h | 4 + include/editor/core/themes.h | 15 + include/editor/views/editorWindow.h | 2 + include/editor/views/viewport.h | 3 + runtime/lib/c_api.cpp | 19 ++ runtime/lib/context.cpp | 411 +++++++++++++++++++++++++++- 12 files changed, 713 insertions(+), 99 deletions(-) diff --git a/atlas/physics/atlas_joints.cpp b/atlas/physics/atlas_joints.cpp index 56986125..7c0bba86 100644 --- a/atlas/physics/atlas_joints.cpp +++ b/atlas/physics/atlas_joints.cpp @@ -17,9 +17,8 @@ #include void FixedJoint::beforePhysics() { - bool isFirstFrame = Window::mainWindow->firstFrame; - if (isFirstFrame) { - joint = std::make_shared(); + if (!joint) { + auto nextJoint = std::make_shared(); if (std::holds_alternative(parent)) { GameObject *parentObject = *std::get_if(&parent); if (!parentObject || !parentObject->rigidbody || @@ -28,9 +27,9 @@ void FixedJoint::beforePhysics() { "FixedJoint parent GameObject has no Rigidbody component."); return; } - joint->parent = parentObject->rigidbody->body.get(); + nextJoint->parent = parentObject->rigidbody->body.get(); } else { - joint->parent = bezel::WorldBody{}; + nextJoint->parent = bezel::WorldBody{}; } if (std::holds_alternative(child)) { @@ -41,9 +40,9 @@ void FixedJoint::beforePhysics() { "FixedJoint child GameObject has no Rigidbody component."); return; } - joint->child = childObject->rigidbody->body.get(); + nextJoint->child = childObject->rigidbody->body.get(); } else { - joint->child = bezel::WorldBody{}; + nextJoint->child = bezel::WorldBody{}; } if (std::holds_alternative(parent) && @@ -54,25 +53,30 @@ void FixedJoint::beforePhysics() { } switch (space) { case Space::Global: - joint->space = bezel::Space::Global; + nextJoint->space = bezel::Space::Global; break; case Space::Local: - joint->space = bezel::Space::Local; + nextJoint->space = bezel::Space::Local; break; } - joint->anchor = anchor; - joint->breakForce = breakForce; - joint->breakTorque = breakTorque; - joint->create(Window::mainWindow->physicsWorld); + nextJoint->anchor = anchor; + nextJoint->breakForce = breakForce; + nextJoint->breakTorque = breakTorque; + nextJoint->create(Window::mainWindow->physicsWorld); + joint = std::move(nextJoint); } } -void FixedJoint::breakJoint() { joint->breakJoint(); } +void FixedJoint::breakJoint() { + if (joint) { + joint->breakJoint(); + joint.reset(); + } +} void HingeJoint::beforePhysics() { - bool isFirstFrame = Window::mainWindow->firstFrame; - if (isFirstFrame) { - joint = std::make_shared(); + if (!joint) { + auto nextJoint = std::make_shared(); if (std::holds_alternative(parent)) { GameObject *parentObject = *std::get_if(&parent); if (!parentObject || !parentObject->rigidbody || @@ -81,9 +85,9 @@ void HingeJoint::beforePhysics() { "HingeJoint parent GameObject has no Rigidbody component."); return; } - joint->parent = parentObject->rigidbody->body.get(); + nextJoint->parent = parentObject->rigidbody->body.get(); } else { - joint->parent = bezel::WorldBody{}; + nextJoint->parent = bezel::WorldBody{}; } if (std::holds_alternative(child)) { @@ -94,9 +98,9 @@ void HingeJoint::beforePhysics() { "HingeJoint child GameObject has no Rigidbody component."); return; } - joint->child = childObject->rigidbody->body.get(); + nextJoint->child = childObject->rigidbody->body.get(); } else { - joint->child = bezel::WorldBody{}; + nextJoint->child = bezel::WorldBody{}; } if (std::holds_alternative(parent) && @@ -107,36 +111,41 @@ void HingeJoint::beforePhysics() { } switch (space) { case Space::Global: - joint->space = bezel::Space::Global; + nextJoint->space = bezel::Space::Global; break; case Space::Local: - joint->space = bezel::Space::Local; + nextJoint->space = bezel::Space::Local; break; } - joint->anchor = anchor; - joint->breakForce = breakForce; - joint->breakTorque = breakTorque; + nextJoint->anchor = anchor; + nextJoint->breakForce = breakForce; + nextJoint->breakTorque = breakTorque; - joint->axis1 = axis1; - joint->axis2 = axis2; - joint->limits.enabled = limits.enabled; - joint->limits.minAngle = + nextJoint->axis1 = axis1; + nextJoint->axis2 = axis2; + nextJoint->limits.enabled = limits.enabled; + nextJoint->limits.minAngle = limits.minAngle * (std::numbers::pi_v / 180.0f); - joint->limits.maxAngle = + nextJoint->limits.maxAngle = limits.maxAngle * (std::numbers::pi_v / 180.0f); - joint->motor.enabled = motor.enabled; - joint->motor.maxForce = motor.maxForce; - joint->motor.maxTorque = motor.maxTorque; - joint->create(Window::mainWindow->physicsWorld); + nextJoint->motor.enabled = motor.enabled; + nextJoint->motor.maxForce = motor.maxForce; + nextJoint->motor.maxTorque = motor.maxTorque; + nextJoint->create(Window::mainWindow->physicsWorld); + joint = std::move(nextJoint); } } -void HingeJoint::breakJoint() { joint->breakJoint(); } +void HingeJoint::breakJoint() { + if (joint) { + joint->breakJoint(); + joint.reset(); + } +} void SpringJoint::beforePhysics() { - bool isFirstFrame = Window::mainWindow->firstFrame; - if (isFirstFrame) { - joint = std::make_shared(); + if (!joint) { + auto nextJoint = std::make_shared(); if (std::holds_alternative(parent)) { GameObject *parentObject = *std::get_if(&parent); if (!parentObject || !parentObject->rigidbody || @@ -145,9 +154,9 @@ void SpringJoint::beforePhysics() { "component."); return; } - joint->parent = parentObject->rigidbody->body.get(); + nextJoint->parent = parentObject->rigidbody->body.get(); } else { - joint->parent = bezel::WorldBody{}; + nextJoint->parent = bezel::WorldBody{}; } if (std::holds_alternative(child)) { @@ -158,9 +167,9 @@ void SpringJoint::beforePhysics() { "SpringJoint child GameObject has no Rigidbody component."); return; } - joint->child = childObject->rigidbody->body.get(); + nextJoint->child = childObject->rigidbody->body.get(); } else { - joint->child = bezel::WorldBody{}; + nextJoint->child = bezel::WorldBody{}; } if (std::holds_alternative(parent) && @@ -171,29 +180,35 @@ void SpringJoint::beforePhysics() { } switch (space) { case Space::Global: - joint->space = bezel::Space::Global; + nextJoint->space = bezel::Space::Global; break; case Space::Local: - joint->space = bezel::Space::Local; + nextJoint->space = bezel::Space::Local; break; } - joint->anchor = anchor; - joint->breakForce = breakForce; - joint->breakTorque = breakTorque; + nextJoint->anchor = anchor; + nextJoint->breakForce = breakForce; + nextJoint->breakTorque = breakTorque; - joint->restLength = restLength; - joint->useLimits = useLimits; - joint->minLength = minLength; - joint->maxLength = maxLength; - joint->spring.damping = spring.damping; - joint->spring.enabled = spring.enabled; - joint->spring.mode = static_cast(spring.mode); - joint->spring.frequencyHz = spring.frequencyHz; - joint->spring.dampingRatio = spring.dampingRatio; - joint->spring.stiffness = spring.stiffness; - joint->spring.damping = spring.damping; - joint->create(Window::mainWindow->physicsWorld); + nextJoint->restLength = restLength; + nextJoint->useLimits = useLimits; + nextJoint->minLength = minLength; + nextJoint->maxLength = maxLength; + nextJoint->spring.damping = spring.damping; + nextJoint->spring.enabled = spring.enabled; + nextJoint->spring.mode = static_cast(spring.mode); + nextJoint->spring.frequencyHz = spring.frequencyHz; + nextJoint->spring.dampingRatio = spring.dampingRatio; + nextJoint->spring.stiffness = spring.stiffness; + nextJoint->spring.damping = spring.damping; + nextJoint->create(Window::mainWindow->physicsWorld); + joint = std::move(nextJoint); } } -void SpringJoint::breakJoint() { joint->breakJoint(); } \ No newline at end of file +void SpringJoint::breakJoint() { + if (joint) { + joint->breakJoint(); + joint.reset(); + } +} diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss index b6a0ce57..da9935d0 100644 --- a/editor/styling/dark.qss +++ b/editor/styling/dark.qss @@ -677,6 +677,21 @@ QScrollArea#inspectorScroll { min-height: 24px; } +#inspectorAddComponentButton { + background-color: #2B3235; + border: 1px solid #465158; + border-radius: 6px; + color: #E9EEF0; + font-weight: 600; + min-height: 25px; + padding: 6px 12px; +} + +#inspectorAddComponentButton:hover { + background-color: #343C40; + border-color: #5B6970; +} + QHeaderView { background-color: #252A2C; border: none; diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 21108e03..81f6c6d8 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -12,9 +12,12 @@ #include #include +#include #include #include #include +#include +#include #include #include "DockManager.h" @@ -114,7 +117,7 @@ void EditorWindow::setupDocks() { .area = EditorDockArea::Left, .icon = style()->standardIcon(QStyle::SP_DirOpenIcon)}); - auto *inspectorPanel = new InspectorPanel(viewportPanel); + inspectorPanel = new InspectorPanel(viewportPanel); dockManager->addPanel( {.id = "inspector", .title = "Inspector", @@ -153,6 +156,20 @@ void EditorWindow::saveLayout() { settings.setValue("window/geometry", saveGeometry()); settings.setValue("window/state", saveState()); settings.setValue("docking/state/v2", coreManager->saveState(2)); + if (inspectorPanel != nullptr) { + settings.setValue("panels/inspector/width", inspectorPanel->width()); + } + if (dockManager != nullptr) { + auto *dock = dockManager->panel("inspector"); + if (dock != nullptr && dock->dockAreaWidget() != nullptr) { + QVariantList sizes; + for (int size : + coreManager->splitterSizes(dock->dockAreaWidget())) { + sizes.append(size); + } + settings.setValue("panels/inspector/splitterSizes", sizes); + } + } } void EditorWindow::restoreLayout() { @@ -167,6 +184,34 @@ void EditorWindow::restoreLayout() { if (!dockState.isEmpty()) { coreManager->restoreState(dockState, 2); } + + const int inspectorWidth = + settings.value("panels/inspector/width", 320).toInt(); + const QVariantList storedSizes = + settings.value("panels/inspector/splitterSizes").toList(); + QTimer::singleShot(0, this, + [this, inspectorWidth, storedSizes] { + if (inspectorPanel != nullptr && + inspectorWidth > 0) { + inspectorPanel->resize( + inspectorWidth, inspectorPanel->height()); + } + if (dockManager == nullptr || + storedSizes.isEmpty()) { + return; + } + auto *dock = dockManager->panel("inspector"); + if (dock == nullptr || + dock->dockAreaWidget() == nullptr) { + return; + } + QList sizes; + for (const QVariant &size : storedSizes) { + sizes.append(size.toInt()); + } + coreManager->setSplitterSizes( + dock->dockAreaWidget(), sizes); + }); } void EditorWindow::closeEvent(QCloseEvent *event) { diff --git a/editor/views/editor/inspector.cpp b/editor/views/editor/inspector.cpp index 91ae9b49..2b86170a 100644 --- a/editor/views/editor/inspector.cpp +++ b/editor/views/editor/inspector.cpp @@ -26,6 +26,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -301,6 +304,37 @@ QJsonObject componentValues(const QString &type, const QJsonObject &raw) { return values; } +QString jsonShape(const QJsonValue &value) { + if (value.isObject()) { + QString result = "{"; + const QJsonObject object = value.toObject(); + for (auto iterator = object.begin(); iterator != object.end(); + ++iterator) { + result += iterator.key() + ':' + jsonShape(iterator.value()) + ';'; + } + return result + '}'; + } + if (value.isArray()) { + QString result = "["; + const QJsonArray array = value.toArray(); + for (const QJsonValue &entry : array) { + result += jsonShape(entry) + ';'; + } + return result + ']'; + } + return "v"; +} + +QString componentShape(const QJsonArray &components) { + QString result; + for (const QJsonValue &entry : components) { + const QJsonObject component = entry.toObject(); + const QString type = component.value("type").toString(); + result += type + jsonShape(componentValues(type, component)); + } + return result; +} + QStringList choicesFor(const QString &path) { const QString key = path.section('/', -1).toLower(); if (key == "motiontype") @@ -325,10 +359,24 @@ QDoubleSpinBox *numberField(double value, QWidget *parent) { field->setDecimals(4); field->setSingleStep(0.1); field->setValue(value); - field->setKeyboardTracking(false); + field->setKeyboardTracking(true); return field; } +void connectLiveText(QLineEdit *field, const std::function &commit) { + auto *timer = new QTimer(field); + timer->setSingleShot(true); + timer->setInterval(160); + QObject::connect(field, &QLineEdit::textEdited, timer, + [timer] { timer->start(); }); + QObject::connect(timer, &QTimer::timeout, field, commit); + QObject::connect(field, &QLineEdit::editingFinished, field, + [timer, commit] { + timer->stop(); + commit(); + }); +} + QWidget *vectorField(const QJsonArray &value, const PropertyChanged &changed, const QString &path, QWidget *parent) { auto *field = new QFrame(parent); @@ -356,7 +404,8 @@ QWidget *vectorField(const QJsonArray &value, const PropertyChanged &changed, boxes.at(2)->value()}); }; for (QDoubleSpinBox *box : boxes) { - QObject::connect(box, &QDoubleSpinBox::editingFinished, field, commit); + QObject::connect(box, &QDoubleSpinBox::valueChanged, field, + [commit](double) { commit(); }); } return field; } @@ -449,9 +498,10 @@ QWidget *primitiveField(const QString &name, const QString &path, } if (value.isDouble()) { auto *field = numberField(value.toDouble(), parent); - QObject::connect( - field, &QDoubleSpinBox::editingFinished, parent, - [field, changed, path] { changed(path, field->value()); }); + QObject::connect(field, &QDoubleSpinBox::valueChanged, parent, + [field, changed, path](double) { + changed(path, field->value()); + }); return field; } if (value.isArray()) { @@ -471,22 +521,20 @@ QWidget *primitiveField(const QString &name, const QString &path, } field->setText(entries.join(", ")); field->setPlaceholderText("No items"); - QObject::connect( - field, &QLineEdit::editingFinished, parent, - [field, array, changed, path] { - QJsonArray result; - for (const QString &entry : - field->text().split(',', Qt::SkipEmptyParts)) { - const QString value = entry.trimmed(); - bool numeric = false; - const double number = value.toDouble(&numeric); - result.append(!array.isEmpty() && - array.first().isDouble() && numeric - ? QJsonValue(number) - : QJsonValue(value)); - } - changed(path, result); - }); + connectLiveText(field, [field, array, changed, path] { + QJsonArray result; + for (const QString &entry : + field->text().split(',', Qt::SkipEmptyParts)) { + const QString value = entry.trimmed(); + bool numeric = false; + const double number = value.toDouble(&numeric); + result.append(!array.isEmpty() && array.first().isDouble() && + numeric + ? QJsonValue(number) + : QJsonValue(value)); + } + changed(path, result); + }); return field; } const QStringList choices = choicesFor(path); @@ -500,8 +548,8 @@ QWidget *primitiveField(const QString &name, const QString &path, return field; } auto *field = new QLineEdit(value.toString(), parent); - QObject::connect(field, &QLineEdit::editingFinished, parent, - [field, changed, path] { changed(path, field->text()); }); + connectLiveText(field, + [field, changed, path] { changed(path, field->text()); }); return field; } @@ -700,9 +748,8 @@ void InspectorPanel::applySceneSnapshot(const QString &snapshot) { const QJsonObject updated = findObject(inspectedObjectId); const bool contentChanged = updated.value("name") != inspectedObject.value("name") || - updated.value("properties") != - inspectedObject.value("properties") || - updated.value("components") != inspectedObject.value("components"); + componentShape(updated.value("components").toArray()) != + componentShape(inspectedObject.value("components").toArray()); inspectedObject = updated; if (contentChanged) { showObject(inspectedObject); @@ -835,6 +882,42 @@ void InspectorPanel::showObject(const QJsonObject &object) { }, content)); } + auto *addComponent = new QToolButton(content); + addComponent->setObjectName("inspectorAddComponentButton"); + addComponent->setText("+ Add Component"); + addComponent->setPopupMode(QToolButton::InstantPopup); + auto *componentMenu = new QMenu(addComponent); + const QList> componentTypes{ + {"Rigidbody", "rigidbody"}, + {"Audio Player", "audio_player"}, + {"Fixed Joint", "fixed_joint"}, + {"Hinge Joint", "hinge_joint"}, + {"Spring Joint", "spring_joint"}, + {"Vehicle", "vehicle"}, + {"Script", "script"}, + {"Trait Script", "trait_script"}, + }; + for (const auto &[label, componentType] : componentTypes) { + componentMenu->addAction(label, this, [this, componentType, objectId] { + QPointer runtime(viewport); + QPointer inspector(this); + QTimer::singleShot(0, [runtime, inspector, componentType, objectId] { + if (runtime == nullptr || inspector == nullptr) { + return; + } + if (runtime->addRuntimeObjectComponent( + objectId, componentType, + componentSchema(componentType)) < 0) { + QMessageBox::information( + inspector.data(), "Add Component", + "This component is already attached or cannot be added " + "to the selected object."); + } + }); + }); + } + addComponent->setMenu(componentMenu); + contentLayout->addWidget(addComponent); contentLayout->addStretch(); } diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp index 0cb89404..4d8c29d1 100644 --- a/editor/views/editor/viewport.cpp +++ b/editor/views/editor/viewport.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -426,6 +427,27 @@ bool ViewportPanel::setRuntimeObjectProperty( return true; } +int ViewportPanel::addRuntimeObjectComponent( + int id, const QString &type, const QJsonObject &properties) { + if (runtimeContext == nullptr || type.isEmpty()) { + return -1; + } + QJsonObject definition = properties; + definition.insert("type", type); + const QByteArray payload = + QJsonDocument(definition).toJson(QJsonDocument::Compact); + try { + const json parsed = json::parse(payload.constData()); + const int index = runtimeContext->addObjectComponent(id, parsed); + if (index >= 0) { + refreshSceneSnapshot(); + } + return index; + } catch (const json::exception &) { + return -1; + } +} + bool ViewportPanel::setRuntimeObjectParent(int childId, int parentId) { if (runtimeContext == nullptr || !runtimeContext->setObjectParent(childId, parentId)) { diff --git a/include/atlas/runtime/c_api.h b/include/atlas/runtime/c_api.h index 97ebc1b3..753ec4da 100644 --- a/include/atlas/runtime/c_api.h +++ b/include/atlas/runtime/c_api.h @@ -70,6 +70,8 @@ bool atlas_runtime_set_object_property(void *runtimeContext, int id, int componentIndex, const char *propertyPath, const char *jsonValue); +int atlas_runtime_add_object_component(void *runtimeContext, int id, + const char *jsonComponent); bool atlas_runtime_set_object_parent(void *runtimeContext, int childId, int parentId); bool atlas_runtime_delete_object(void *runtimeContext, int id); diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index 5b128400..94498cb9 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -89,6 +89,9 @@ class Context { std::unordered_map objectParents; std::unordered_map editorObjectSourceData; std::unordered_map editorComponentData; + std::unordered_map> editorComponentBaseDirs; + std::unordered_map>> + editorRuntimeComponents; std::unordered_map editorPointLights; std::unordered_map editorSpotlights; std::unordered_map editorAreaLights; @@ -116,6 +119,7 @@ class Context { bool setObjectProperty(int id, const std::string &component, int componentIndex, const std::string &propertyPath, const json &value); + int addObjectComponent(int id, const json &component); bool setObjectParent(int childId, int parentId); bool deleteObject(int id); int createObject(const std::string &type, const std::string &name); diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h index 45bd8778..38461cb2 100644 --- a/include/editor/core/themes.h +++ b/include/editor/core/themes.h @@ -684,6 +684,21 @@ inline constexpr const char* DARK_THEME = " min-height: 24px;\n" "}\n" "\n" +"#inspectorAddComponentButton {\n" +" background-color: #2B3235;\n" +" border: 1px solid #465158;\n" +" border-radius: 6px;\n" +" color: #E9EEF0;\n" +" font-weight: 600;\n" +" min-height: 25px;\n" +" padding: 6px 12px;\n" +"}\n" +"\n" +"#inspectorAddComponentButton:hover {\n" +" background-color: #343C40;\n" +" border-color: #5B6970;\n" +"}\n" +"\n" "QHeaderView {\n" " background-color: #252A2C;\n" " border: none;\n" diff --git a/include/editor/views/editorWindow.h b/include/editor/views/editorWindow.h index 06395eed..487ab340 100644 --- a/include/editor/views/editorWindow.h +++ b/include/editor/views/editorWindow.h @@ -21,6 +21,7 @@ namespace ads { } class ViewportPanel; +class InspectorPanel; class EditorWindow : public QMainWindow { Q_OBJECT @@ -40,6 +41,7 @@ class EditorWindow : public QMainWindow { EditorDockManager* dockManager = nullptr; ads::CDockManager* coreManager = nullptr; ViewportPanel* viewportPanel = nullptr; + InspectorPanel* inspectorPanel = nullptr; QString projectFile; bool closing = false; diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h index aba930b5..d43da4b1 100644 --- a/include/editor/views/viewport.h +++ b/include/editor/views/viewport.h @@ -19,6 +19,7 @@ class Context; class QCloseEvent; class QHideEvent; class QKeyEvent; +class QJsonObject; class QJsonValue; class QMouseEvent; class QPaintEngine; @@ -44,6 +45,8 @@ class ViewportPanel : public QWidget { int componentIndex, const QString &propertyPath, const QJsonValue &value); + int addRuntimeObjectComponent(int id, const QString &type, + const QJsonObject &properties); bool setRuntimeObjectParent(int childId, int parentId); bool deleteRuntimeObject(int id); int createRuntimeObject(const QString &type, const QString &name = {}); diff --git a/runtime/lib/c_api.cpp b/runtime/lib/c_api.cpp index 4942642d..800890b6 100644 --- a/runtime/lib/c_api.cpp +++ b/runtime/lib/c_api.cpp @@ -325,6 +325,25 @@ bool atlas_runtime_set_object_property(void *runtimeContext, int id, } } +int atlas_runtime_add_object_component(void *runtimeContext, int id, + const char *jsonComponent) { + if (runtimeContext == nullptr || jsonComponent == nullptr) { + return -1; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return -1; + } + return (*handle)->addObjectComponent( + id, nlohmann::json::parse(jsonComponent)); + } catch (const std::exception &) { + return -1; + } catch (...) { + return -1; + } +} + bool atlas_runtime_set_object_parent(void *runtimeContext, int childId, int parentId) { if (runtimeContext == nullptr) { diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index b53a34b4..e29ccd79 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -90,6 +90,7 @@ struct PendingComponent { std::string objectType; std::string baseDir; json data; + int componentIndex = -1; }; struct RuntimeEnvironmentDefinition { @@ -1972,6 +1973,7 @@ void collectPendingComponents(Context &context, GameObject &object, const json *componentsField = findField(objectData, {"components"}); if (componentsField == nullptr) { context.editorComponentData[object.getId()] = json::array(); + context.editorComponentBaseDirs[object.getId()] = {}; return; } @@ -1991,16 +1993,21 @@ void collectPendingComponents(Context &context, GameObject &object, if (componentEntries.empty()) { context.editorComponentData[object.getId()] = json::array(); + context.editorComponentBaseDirs[object.getId()] = {}; return; } context.editorComponentData[object.getId()] = componentEntries; + context.editorComponentBaseDirs[object.getId()] = + std::vector(componentEntries.size(), definition.baseDir); std::string objectType; tryReadStringAny(objectData, {"type"}, objectType); objectType = normalizeToken(objectType); - for (const auto &componentData : componentEntries) { + for (std::size_t componentIndex = 0; + componentIndex < componentEntries.size(); ++componentIndex) { + const json &componentData = componentEntries[componentIndex]; if (!componentData.is_object()) { continue; } @@ -2017,6 +2024,7 @@ void collectPendingComponents(Context &context, GameObject &object, .objectType = objectType, .baseDir = definition.baseDir, .data = componentData, + .componentIndex = static_cast(componentIndex), }; if (normalizedType == "rigidbody") { @@ -2435,14 +2443,29 @@ void configureVehicleSettings(bezel::VehicleSettings &settings, } } -void attachComponent(Context &context, const PendingComponent &pending) { +std::shared_ptr attachComponent(Context &context, + const PendingComponent &pending) { if (pending.object == nullptr || !pending.data.is_object()) { - return; + return nullptr; } std::string type; tryReadStringAny(pending.data, {"type"}, type); const std::string token = normalizeToken(type); + auto finish = [&](const std::shared_ptr &component) { + if (component != nullptr && pending.componentIndex >= 0) { + auto &components = + context.editorRuntimeComponents[pending.object->getId()]; + if (components.size() <= + static_cast(pending.componentIndex)) { + components.resize( + static_cast(pending.componentIndex) + 1); + } + components[static_cast(pending.componentIndex)] = + component; + } + return component; + }; if (token == "script" || token == "traitscript") { auto component = std::make_shared(); @@ -2520,7 +2543,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { } pending.object->addComponent(component); - return; + return finish(component); } if (token == "rigidbody") { @@ -2579,7 +2602,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { context.context, context.scriptHost, pending.object->getId(), rigidbody); - return; + return finish(rigidbody); } if (token == "audioplayer") { @@ -2615,7 +2638,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { component->play(); } - return; + return finish(component); } if (token == "joint" || token == "fixedjoint") { @@ -2625,7 +2648,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { runtime::scripting::registerNativeFixedJoint( context.context, context.scriptHost, pending.object->getId(), component); - return; + return finish(component); } if (token == "hingejoint") { @@ -2661,7 +2684,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { context.context, context.scriptHost, pending.object->getId(), component); - return; + return finish(component); } if (token == "springjoint") { @@ -2696,7 +2719,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { context.context, context.scriptHost, pending.object->getId(), component); - return; + return finish(component); } if (token == "vehicle") { @@ -2706,12 +2729,271 @@ void attachComponent(Context &context, const PendingComponent &pending) { runtime::scripting::registerNativeVehicle( context.context, context.scriptHost, pending.object->getId(), component); - return; + return finish(component); } throw std::runtime_error("Unknown component type: " + type); } +bool updateAttachedComponent(Context &context, GameObject &object, + const std::shared_ptr &component, + const json &data, const std::string &baseDir, + const std::string &propertyPath) { + if (component == nullptr || !data.is_object()) { + return false; + } + + if (auto script = + std::dynamic_pointer_cast(component); + script != nullptr) { + if (const json *variables = findField(data, {"variables"}); + variables != nullptr) { + script->variables = *variables; + if (script->instance != nullptr) { + const std::string serialized = variables->dump(); + JSValue parsed = JS_ParseJSON( + context.context, serialized.c_str(), serialized.size(), + ""); + if (!JS_IsException(parsed)) { + JS_SetPropertyStr(context.context, + script->instance->instance, "variables", + parsed); + } else { + runtime::scripting::dumpExecution(context.context); + JS_FreeValue(context.context, parsed); + } + } + } + tryReadStringAny(data, {"traitedType"}, script->traitedType); + if (!propertyPath.starts_with("/variables")) { + tryReadStringAny(data, {"name", "class", "className"}, + script->className); + std::string source; + if (tryReadStringAny(data, {"source"}, source) && + !source.empty()) { + const std::string resolvedSource = + resolveRuntimePath(baseDir, source); + std::string extension = + std::filesystem::path(resolvedSource).extension().string(); + std::transform(extension.begin(), extension.end(), + extension.begin(), [](unsigned char value) { + return static_cast( + std::tolower(value)); + }); + if (extension == ".js" || extension == ".mjs") { + script->entryModuleName = + context.registerScriptModule(resolvedSource); + script->source.clear(); + } else { + script->entryModuleName = context.scriptBundleModuleName; + script->source = + context.toProjectScriptPath(resolvedSource); + } + if (script->className.empty()) { + script->className = inferScriptClassName(resolvedSource); + } + } + if (script->className.empty() || + script->entryModuleName.empty()) { + throw std::runtime_error( + "Script component is missing a valid class or source"); + } + const int objectId = static_cast(object.getId()); + if (script->isTrait && !script->traitedType.empty() && + context.objectSceneTypes.contains(objectId) && + normalizeToken(script->traitedType) != + normalizeToken(context.objectSceneTypes[objectId])) { + throw std::runtime_error( + "Trait script is incompatible with the object type"); + } + script->instance.reset(); + script->initialized = false; + script->atAttach(); + script->init(); + } + return true; + } + + if (auto rigidbody = std::dynamic_pointer_cast(component); + rigidbody != nullptr) { + tryReadStringAny(data, {"sendSignal", "signal"}, + rigidbody->sendSignal); + tryReadBoolAny(data, {"isSensor"}, rigidbody->isSensor); + if (rigidbody->body != nullptr) { + rigidbody->body->sensorSignal = rigidbody->sendSignal; + rigidbody->body->isSensor = rigidbody->isSensor; + } + if (propertyPath.starts_with("/collider")) { + if (const json *collider = findField(data, {"collider"}); + collider != nullptr) { + configureRigidbodyCollider(rigidbody, object, *collider); + } + } + float value = 0.0f; + if (tryReadFloatAny(data, {"friction"}, value)) { + rigidbody->setFriction(value); + } + if (tryReadFloatAny(data, {"mass"}, value)) { + rigidbody->setMass(value); + } + if (tryReadFloatAny(data, {"restitution", "restituition"}, value)) { + rigidbody->setRestitution(value); + } + if (const json *damping = findField(data, {"damping"}); + damping != nullptr && damping->is_object()) { + float linear = 0.0f; + float angular = 0.0f; + tryReadFloatAny(*damping, {"linear"}, linear); + tryReadFloatAny(*damping, {"angular"}, angular); + rigidbody->setDamping(linear, angular); + } + if (propertyPath.starts_with("/tags") && rigidbody->body != nullptr) { + rigidbody->body->tags.clear(); + if (const json *tags = findField(data, {"tags"}); + tags != nullptr && tags->is_array()) { + for (const auto &tag : *tags) { + if (tag.is_string()) { + rigidbody->addTag(tag.get()); + } + } + } + } + std::string motionType; + if (tryReadStringAny(data, {"motionType"}, motionType)) { + rigidbody->setMotionType(parseMotionType(motionType)); + } + if (rigidbody->body != nullptr && + rigidbody->body->collider != nullptr && + Window::mainWindow != nullptr && + Window::mainWindow->physicsWorld != nullptr) { + rigidbody->body->position = object.getPosition(); + rigidbody->body->rotation = object.getRotation(); + rigidbody->body->create(Window::mainWindow->physicsWorld); + auto attached = + context.editorRuntimeComponents.find(object.getId()); + if (attached != context.editorRuntimeComponents.end()) { + for (const auto &entry : attached->second) { + const std::shared_ptr related = entry.lock(); + if (auto joint = + std::dynamic_pointer_cast(related); + joint != nullptr) { + joint->breakJoint(); + } + if (auto vehicle = + std::dynamic_pointer_cast(related); + vehicle != nullptr) { + vehicle->requestRecreate(); + } + } + } + } + return true; + } + + if (auto audio = std::dynamic_pointer_cast(component); + audio != nullptr) { + if (propertyPath == "/source") { + std::string source; + if (tryReadStringAny(data, {"source"}, source) && + !source.empty()) { + audio->setSource(createRuntimeResource( + baseDir, source, ResourceType::Audio, "runtime-audio")); + } + } + bool spatialization = false; + if (tryReadBoolAny(data, {"useSpatialization"}, spatialization)) { + if (spatialization) { + audio->useSpatialization(); + } else { + audio->disableSpatialization(); + } + } + Position3d position; + if (tryReadVec3Any(data, {"position"}, position)) { + audio->setPosition(position); + } + if (propertyPath == "/autoplay" || propertyPath == "/playOnStart") { + bool autoPlay = false; + tryReadBoolAny(data, {"autoplay", "autoPlay", "playOnStart"}, + autoPlay); + if (autoPlay) { + audio->play(); + } else { + audio->stop(); + } + } + return true; + } + + if (auto joint = std::dynamic_pointer_cast(component); + joint != nullptr) { + joint->breakJoint(); + configureJointBase(*joint, context, object, data); + if (auto hinge = std::dynamic_pointer_cast(component); + hinge != nullptr) { + Position3d axis; + if (tryReadVec3Any(data, {"axis1"}, axis)) { + hinge->axis1 = normalizeVector(axis, {0.0f, 1.0f, 0.0f}); + } + if (tryReadVec3Any(data, {"axis2"}, axis)) { + hinge->axis2 = normalizeVector(axis, {0.0f, 1.0f, 0.0f}); + } + if (const json *limits = findField(data, {"limits"}); + limits != nullptr && limits->is_object()) { + tryReadBoolAny(*limits, {"isEnabled", "enabled"}, + hinge->limits.enabled); + tryReadFloatAny(*limits, {"minAngle"}, + hinge->limits.minAngle); + tryReadFloatAny(*limits, {"maxAngle"}, + hinge->limits.maxAngle); + } + if (const json *motor = findField(data, {"motor"}); + motor != nullptr && motor->is_object()) { + tryReadBoolAny(*motor, {"isEnabled", "enabled"}, + hinge->motor.enabled); + tryReadFloatAny(*motor, {"maxForce"}, hinge->motor.maxForce); + tryReadFloatAny(*motor, {"maxTorque"}, + hinge->motor.maxTorque); + } + } + if (auto spring = std::dynamic_pointer_cast(component); + spring != nullptr) { + tryReadVec3Any(data, {"anchorB"}, spring->anchorB); + tryReadFloatAny(data, {"restLength"}, spring->restLength); + tryReadBoolAny(data, {"useLimits"}, spring->useLimits); + tryReadFloatAny(data, {"minLength"}, spring->minLength); + tryReadFloatAny(data, {"maxLength"}, spring->maxLength); + if (const json *settings = findField(data, {"spring"}); + settings != nullptr && settings->is_object()) { + tryReadBoolAny(*settings, {"enabled", "isEnabled"}, + spring->spring.enabled); + std::string mode; + if (tryReadStringAny(*settings, {"mode"}, mode)) { + spring->spring.mode = parseSpringMode(mode); + } + tryReadFloatAny(*settings, {"frequencyHz"}, + spring->spring.frequencyHz); + tryReadFloatAny(*settings, {"dampingRatio"}, + spring->spring.dampingRatio); + tryReadFloatAny(*settings, {"stiffness"}, + spring->spring.stiffness); + tryReadFloatAny(*settings, {"damping"}, + spring->spring.damping); + } + } + return true; + } + + if (auto vehicle = std::dynamic_pointer_cast(component); + vehicle != nullptr) { + configureVehicleSettings(vehicle->settings, data); + vehicle->requestRecreate(); + return true; + } + + return false; +} + Key parseKeyString(const std::string &value) { SDL_Scancode scancode = SDL_GetScancodeFromName(value.c_str()); if (scancode != SDL_SCANCODE_UNKNOWN) { @@ -4033,7 +4315,107 @@ bool Context::setObjectProperty(int id, const std::string &component, if (normalizeToken(storedType) != normalizedComponent) { return false; } - return setJsonProperty(componentData, propertyPath, value); + if (!setJsonProperty(componentData, propertyPath, value)) { + return false; + } + + std::shared_ptr runtimeComponent; + auto runtimeComponents = editorRuntimeComponents.find(id); + if (runtimeComponents != editorRuntimeComponents.end() && + componentIndex < static_cast(runtimeComponents->second.size())) { + runtimeComponent = + runtimeComponents->second[static_cast(componentIndex)] + .lock(); + } + + std::string componentBaseDir = sceneDir; + auto baseDirs = editorComponentBaseDirs.find(id); + if (baseDirs != editorComponentBaseDirs.end() && + componentIndex < static_cast(baseDirs->second.size())) { + componentBaseDir = + baseDirs->second[static_cast(componentIndex)]; + } + PendingComponent pending{ + .object = object, + .objectType = objectSceneTypes.contains(id) ? objectSceneTypes[id] : "", + .baseDir = componentBaseDir, + .data = componentData, + .componentIndex = componentIndex, + }; + try { + if (runtimeComponent == nullptr) { + runtimeComponent = attachComponent(*this, pending); + if (runtimeComponent != nullptr) { + runtimeComponent->init(); + } + } else { + updateAttachedComponent(*this, *object, runtimeComponent, + componentData, componentBaseDir, + propertyPath); + } + } catch (const std::exception &error) { + RUNTIME_LOG("Component update is waiting for valid values: " + + std::string(error.what())); + } + return true; +} + +int Context::addObjectComponent(int id, const json &component) { + GameObject *object = findContextObject(*this, id); + if (object == nullptr || !component.is_object()) { + return -1; + } + std::string type; + if (!tryReadStringAny(component, {"type"}, type) || type.empty()) { + return -1; + } + const std::string normalizedType = normalizeToken(type); + static const std::unordered_set supported{ + "script", "traitscript", "rigidbody", "audioplayer", + "joint", "fixedjoint", "hingejoint", "springjoint", + "vehicle", + }; + if (!supported.contains(normalizedType)) { + return -1; + } + + json &components = editorComponentData[id]; + if (!components.is_array()) { + components = json::array(); + editorComponentBaseDirs[id].clear(); + editorRuntimeComponents[id].clear(); + } + if (normalizedType != "script" && normalizedType != "traitscript") { + for (const auto &existing : components) { + std::string existingType; + tryReadStringAny(existing, {"type"}, existingType); + if (normalizeToken(existingType) == normalizedType) { + return -1; + } + } + } + + const int index = static_cast(components.size()); + components.push_back(component); + editorComponentBaseDirs[id].resize(static_cast(index)); + editorComponentBaseDirs[id].push_back(sceneDir); + PendingComponent pending{ + .object = object, + .objectType = objectSceneTypes.contains(id) ? objectSceneTypes[id] : "", + .baseDir = sceneDir, + .data = component, + .componentIndex = index, + }; + try { + std::shared_ptr attached = attachComponent(*this, pending); + if (attached != nullptr) { + attached->init(); + } + } catch (const std::exception &error) { + RUNTIME_LOG("Component added and waiting for valid values: " + + std::string(error.what())); + } + return index; } bool Context::setObjectParent(int childId, int parentId) { @@ -4188,6 +4570,8 @@ bool Context::deleteObject(int id) { editorLightSourceData.erase(id); editorObjectSourceData.erase(id); editorComponentData.erase(id); + editorComponentBaseDirs.erase(id); + editorRuntimeComponents.erase(id); objectNames.erase(id); objectSceneReferences.erase(id); objectSceneTypes.erase(id); @@ -4424,6 +4808,7 @@ int Context::createObject(const std::string &type, const std::string &name) { editorObjectSourceData[id]["solid_type"] = solidType; } editorComponentData[id] = json::array(); + editorComponentBaseDirs[id] = {}; objects.push_back(object); window->addObject(object.get()); @@ -4703,6 +5088,10 @@ void Context::loadScene(Window &window, const json &sceneData) { objectSceneSolidTypes.clear(); objectParentReferences.clear(); objectParents.clear(); + editorObjectSourceData.clear(); + editorComponentData.clear(); + editorComponentBaseDirs.clear(); + editorRuntimeComponents.clear(); editorPointLights.clear(); editorSpotlights.clear(); editorAreaLights.clear(); From a4341f6d3d327ce790c12219cd397a93af477d54 Mon Sep 17 00:00:00 2001 From: Max Van den Eynde Date: Thu, 16 Jul 2026 16:34:01 +0200 Subject: [PATCH 6/6] Fixed inspector selection callback --- editor/views/editor/editor.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp index 81f6c6d8..3e0d2b71 100644 --- a/editor/views/editor/editor.cpp +++ b/editor/views/editor/editor.cpp @@ -142,11 +142,11 @@ void EditorWindow::setupDocks() { connect(viewportPanel, &ViewportPanel::runtimeObjectActivated, contentBrowser, &ContentBrowserPanel::clearSelection); connect(contentBrowser, &ContentBrowserPanel::selectionChanged, this, - [this, inspectorPanel](const QString &path) { + [this](const QString &path) { if (!path.isEmpty()) { viewportPanel->selectRuntimeObject(-1, false); } - inspectorPanel->inspectFile(path); + this->inspectorPanel->inspectFile(path); }); }