diff --git a/.github/workflows/app.yaml b/.github/workflows/app.yaml deleted file mode 100644 index 7423db83..00000000 --- a/.github/workflows/app.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build editor - -on: - push: - pull_request: - branches: [main, editor] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: macos-latest - - defaults: - run: - working-directory: ./editor - - steps: - - name: Check out repo - uses: actions/checkout@v4 - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Lint - run: bun run lint - - - name: Typecheck - run: bunx tsc -b tsconfig.json --pretty false - - - name: Build renderer - run: bun run build:renderer - - - name: Build Electron - run: bun run build:electron diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2296c298..e83b9045 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -10,13 +10,21 @@ jobs: steps: - uses: actions/checkout@v5 with: - submodules: true + submodules: recursive + - name: Fetch Qt Docking System + run: | + if [ ! -d extern/QtDockingSystem ]; then + git clone https://github.com/githubuser0xFFFF/Qt-Advanced-Docking-System.git extern/QtDockingSystem + git -C extern/QtDockingSystem checkout f35d2bd95e7a324433ccce6b79efc57204b53231 + fi - name: Install Dependencies run: | - brew install cmake sdl3 glm openal-soft assimp vulkan-loader molten-vk vulkan-validationlayers spirv-cross spirv-headers spirv-tools ninja + brew install cmake ninja qt@6 sdl3 glm openal-soft assimp vulkan-loader molten-vk vulkan-validationlayers spirv-cross spirv-headers spirv-tools + - name: Configure Qt + run: | + echo "$(brew --prefix qt@6)/bin" >> "$GITHUB_PATH" + echo "CMAKE_PREFIX_PATH=$(brew --prefix qt@6)" >> "$GITHUB_ENV" - name: Build run: | - mkdir build - cd build - cmake .. -DBACKEND=AUTO -G Ninja - ninja -j4 + cmake -S . -B build -DBACKEND=AUTO -G Ninja + cmake --build build --parallel 4 diff --git a/.gitignore b/.gitignore index 6a3cea84..823592fe 100644 --- a/.gitignore +++ b/.gitignore @@ -16,10 +16,8 @@ libhydra.a lib/* build .vscode -Cargo.lock target cli/target -cli/Cargo.lock libfinewave.a _deps docs/html diff --git a/.gitmodules b/.gitmodules index 1702b47a..1d9d30cc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "extern/quickjs"] path = extern/quickjs url = https://github.com/quickjs-ng/quickjs.git +[submodule "extern/imgui"] + path = extern/imgui + url = https://github.com/ocornut/imgui.git +[submodule "extern/QtDockingSystem"] + path = extern/QtDockingSystem + url = https://github.com/githubuser0xFFFF/Qt-Advanced-Docking-System.git diff --git a/CMakeLists.txt b/CMakeLists.txt index d5a032e5..83fb92a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,7 @@ set(CMAKE_CXX_COMPILER "/usr/bin/clang++") project(Atlas LANGUAGES C CXX) + set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -15,41 +16,41 @@ message(STATUS "Using Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VE message(STATUS "Compiler at path:" ${CMAKE_CXX_COMPILER}) add_compile_definitions( - ATLAS_VERSION="Alpha 9" - OPAL_VERSION="3 Tetrahedron" + ATLAS_VERSION="Alpha 9" + OPAL_VERSION="3 Tetrahedron" ) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF) # ─── Compiler Diagnostics ──────────────────────────────────────────────────── -if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") +if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") add_compile_options(-fdiagnostics-color=always) -elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") +elseif (CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-fcolor-diagnostics) -endif() +endif () add_library(atlas_warnings INTERFACE) -if(MSVC) +if (MSVC) target_compile_options(atlas_warnings INTERFACE - /W4 - /WX - /permissive- + /W4 + /WX + /permissive- ) -else() +else () target_compile_options(atlas_warnings INTERFACE - -Wall - -Wextra - -Wpedantic - -Werror - -Wno-gnu-anonymous-struct + -Wall + -Wextra + -Wpedantic + -Werror + -Wno-gnu-anonymous-struct ) -endif() +endif () # ─── Build Type ────────────────────────────────────────────────────────────── message(STATUS "Building for platform: ${CMAKE_SYSTEM_NAME}") -if(NOT CMAKE_BUILD_TYPE) +if (NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Debug) -endif() +endif () # ─── Output Directories ────────────────────────────────────────────────────── set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) @@ -61,27 +62,27 @@ set(BACKEND "AUTO" CACHE STRING "Rendering backend: AUTO, VULKAN, METAL, OPENGL" set_property(CACHE BACKEND PROPERTY STRINGS AUTO VULKAN METAL OPENGL) string(TOUPPER "${BACKEND}" BACKEND) -if(BACKEND STREQUAL "AUTO") - if(APPLE) +if (BACKEND STREQUAL "AUTO") + if (APPLE) set(BACKEND "METAL") - else() + else () set(BACKEND "VULKAN") - endif() -endif() + endif () +endif () set(BACKEND_OPENGL OFF) set(BACKEND_VULKAN OFF) -set(BACKEND_METAL OFF) +set(BACKEND_METAL OFF) -if(BACKEND STREQUAL "OPENGL") +if (BACKEND STREQUAL "OPENGL") set(BACKEND_OPENGL ON) -elseif(BACKEND STREQUAL "VULKAN") +elseif (BACKEND STREQUAL "VULKAN") set(BACKEND_VULKAN ON) -elseif(BACKEND STREQUAL "METAL") +elseif (BACKEND STREQUAL "METAL") set(BACKEND_METAL ON) -else() +else () message(FATAL_ERROR "Invalid BACKEND='${BACKEND}'. Valid values are: AUTO, VULKAN, METAL, OPENGL") -endif() +endif () message(STATUS "Selected backend: ${BACKEND}") @@ -97,27 +98,27 @@ cmake_policy(SET CMP0135 NEW) # Doxygen CSS FetchContent_Declare( - doxygen-awesome-css - URL https://github.com/jothepro/doxygen-awesome-css/archive/refs/tags/v2.4.0.zip + doxygen-awesome-css + URL https://github.com/jothepro/doxygen-awesome-css/archive/refs/tags/v2.4.0.zip ) FetchContent_MakeAvailable(doxygen-awesome-css) FetchContent_GetProperties(doxygen-awesome-css SOURCE_DIR AWESOME_CSS_DIR) # glm (header-only) FetchContent_Declare( - glm - GIT_REPOSITORY https://github.com/g-truc/glm.git - GIT_TAG 1.0.1 - GIT_SHALLOW TRUE + glm + GIT_REPOSITORY https://github.com/g-truc/glm.git + GIT_TAG 1.0.1 + GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(glm) # fmt FetchContent_Declare( - fmt - GIT_REPOSITORY https://github.com/fmtlib/fmt.git - GIT_TAG 12.1.0 - GIT_SHALLOW TRUE + fmt + GIT_REPOSITORY https://github.com/fmtlib/fmt.git + GIT_TAG 12.1.0 + GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(fmt) @@ -127,10 +128,10 @@ set(SDL_STATIC ON CACHE BOOL "" FORCE) set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE) set(SDL_TESTS OFF CACHE BOOL "" FORCE) FetchContent_Declare( - SDL3 - GIT_REPOSITORY https://github.com/libsdl-org/SDL.git - GIT_TAG release-3.2.10 - GIT_SHALLOW TRUE + SDL3 + GIT_REPOSITORY https://github.com/libsdl-org/SDL.git + GIT_TAG release-3.2.10 + GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(SDL3) @@ -142,16 +143,16 @@ set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE) set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) FetchContent_Declare( - freetype - URL https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz - DOWNLOAD_EXTRACT_TIMESTAMP FALSE + freetype + URL https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz + DOWNLOAD_EXTRACT_TIMESTAMP FALSE ) FetchContent_GetProperties(freetype) -if(NOT freetype_POPULATED) +if (NOT freetype_POPULATED) FetchContent_Populate(freetype) add_subdirectory(${freetype_SOURCE_DIR} ${freetype_BINARY_DIR} EXCLUDE_FROM_ALL) -endif() +endif () # Assimp set(ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE) @@ -159,10 +160,10 @@ set(ASSIMP_BUILD_ASSIMP_TOOLS OFF CACHE BOOL "" FORCE) set(ASSIMP_INSTALL OFF CACHE BOOL "" FORCE) set(ASSIMP_INJECT_DEBUG_POSTFIX OFF CACHE BOOL "" FORCE) FetchContent_Declare( - assimp - GIT_REPOSITORY https://github.com/assimp/assimp.git - GIT_TAG v6.0.2 - GIT_SHALLOW TRUE + assimp + GIT_REPOSITORY https://github.com/assimp/assimp.git + GIT_TAG v6.0.2 + GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(assimp) @@ -174,66 +175,66 @@ set(ALSOFT_CONFIG OFF CACHE BOOL "" FORCE) set(ALSOFT_INSTALL OFF CACHE BOOL "" FORCE) set(LIBTYPE STATIC CACHE STRING "" FORCE) FetchContent_Declare( - openal_soft - GIT_REPOSITORY https://github.com/kcat/openal-soft.git - GIT_TAG 1.24.3 - GIT_SHALLOW TRUE + openal_soft + GIT_REPOSITORY https://github.com/kcat/openal-soft.git + GIT_TAG 1.24.3 + GIT_SHALLOW TRUE ) FetchContent_MakeAvailable(openal_soft) # Jolt -if(NOT BEZEL_NATIVE) +if (NOT BEZEL_NATIVE) FetchContent_Declare( - JoltPhysics - GIT_REPOSITORY https://github.com/jrouwe/JoltPhysics.git - GIT_TAG v${JOLT_VERSION} - GIT_SHALLOW TRUE - SOURCE_SUBDIR Build + JoltPhysics + GIT_REPOSITORY https://github.com/jrouwe/JoltPhysics.git + GIT_TAG v${JOLT_VERSION} + GIT_SHALLOW TRUE + SOURCE_SUBDIR Build ) - set(JPH_DEBUG_RENDERER ON CACHE BOOL "" FORCE) - set(JPH_PROFILE_ENABLED ON CACHE BOOL "" FORCE) - set(JPH_OBJECT_STREAM ON CACHE BOOL "" FORCE) - set(JPH_ENABLE_IPO OFF CACHE BOOL "" FORCE) + set(JPH_DEBUG_RENDERER ON CACHE BOOL "" FORCE) + set(JPH_PROFILE_ENABLED ON CACHE BOOL "" FORCE) + set(JPH_OBJECT_STREAM ON CACHE BOOL "" FORCE) + set(JPH_ENABLE_IPO OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(JoltPhysics) - if(TARGET Jolt) + if (TARGET Jolt) set_target_properties(Jolt PROPERTIES INTERPROCEDURAL_OPTIMIZATION OFF) - endif() + endif () message(STATUS "Downloaded Jolt Physics, version ${JOLT_VERSION}") -endif() +endif () # ─── FetchContent include roots ────────────────────────────────────────────── set(ATLAS_DEP_INCLUDE_DIRS - ${glm_SOURCE_DIR} - ${SDL3_SOURCE_DIR}/include - ${freetype_SOURCE_DIR}/include - ${assimp_SOURCE_DIR}/include - ${openal_soft_SOURCE_DIR}/include + ${glm_SOURCE_DIR} + ${SDL3_SOURCE_DIR}/include + ${freetype_SOURCE_DIR}/include + ${assimp_SOURCE_DIR}/include + ${openal_soft_SOURCE_DIR}/include ) -if(NOT BEZEL_NATIVE) +if (NOT BEZEL_NATIVE) list(APPEND ATLAS_DEP_INCLUDE_DIRS ${joltphysics_SOURCE_DIR}) -endif() +endif () # ─── Backend Definitions ───────────────────────────────────────────────────── -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) add_compile_definitions(OPENGL) message(STATUS "Building using OpenGL backend") -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) add_compile_definitions(VULKAN GLM_FORCE_DEPTH_ZERO_TO_ONE) message(STATUS "Building using Vulkan backend") -elseif(BACKEND_METAL) +elseif (BACKEND_METAL) add_compile_definitions(METAL GLM_FORCE_DEPTH_ZERO_TO_ONE) message(STATUS "Building using Metal backend") -endif() +endif () # ─── Shader Generation ─────────────────────────────────────────────────────── -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) set(SHADER_INPUT_DIR "${CMAKE_SOURCE_DIR}/shaders/opengl") -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) set(SHADER_INPUT_DIR "${CMAKE_SOURCE_DIR}/shaders/vulkan") -elseif(BACKEND_METAL) +elseif (BACKEND_METAL) set(SHADER_INPUT_DIR "${CMAKE_SOURCE_DIR}/shaders/metal") -endif() +endif () set(SHADER_OUTPUT_FILE "${CMAKE_SOURCE_DIR}/include/atlas/core/default_shaders.h") set(RUNTIME_SCRIPT_INPUT_DIR "${CMAKE_SOURCE_DIR}/runtime/scripts") @@ -242,223 +243,223 @@ set(RUNTIME_SCRIPT_OUTPUT_FILE "${CMAKE_SOURCE_DIR}/include/atlas/runtime/atlasS find_package(Python3 COMPONENTS Interpreter REQUIRED) file(GLOB_RECURSE SHADER_SOURCES - ${SHADER_INPUT_DIR}/*.vert - ${SHADER_INPUT_DIR}/*.frag - ${SHADER_INPUT_DIR}/*.glsl - ${SHADER_INPUT_DIR}/*.geom - ${SHADER_INPUT_DIR}/*.tesc - ${SHADER_INPUT_DIR}/*.tese - ${SHADER_INPUT_DIR}/*.metal + ${SHADER_INPUT_DIR}/*.vert + ${SHADER_INPUT_DIR}/*.frag + ${SHADER_INPUT_DIR}/*.glsl + ${SHADER_INPUT_DIR}/*.geom + ${SHADER_INPUT_DIR}/*.tesc + ${SHADER_INPUT_DIR}/*.tese + ${SHADER_INPUT_DIR}/*.metal ) file(GLOB_RECURSE RUNTIME_SCRIPT_SOURCES CONFIGURE_DEPENDS - ${RUNTIME_SCRIPT_INPUT_DIR}/*.js - ${RUNTIME_SCRIPT_INPUT_DIR}/*.mjs + ${RUNTIME_SCRIPT_INPUT_DIR}/*.js + ${RUNTIME_SCRIPT_INPUT_DIR}/*.mjs ) -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) set(SHADER_BACKEND_ARG "opengl") -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) set(SHADER_BACKEND_ARG "vulkan") -elseif(BACKEND_METAL) +elseif (BACKEND_METAL) set(SHADER_BACKEND_ARG "metal") -endif() +endif () message(STATUS "Packing shaders for ${BACKEND} backend") add_custom_command( - OUTPUT ${SHADER_OUTPUT_FILE} - COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR}/include/atlas/core - COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/pack_shaders.py - ${SHADER_INPUT_DIR} ${SHADER_OUTPUT_FILE} ${SHADER_BACKEND_ARG} - DEPENDS ${SHADER_SOURCES} - COMMENT "Packing shaders into default_shaders.h" - VERBATIM + OUTPUT ${SHADER_OUTPUT_FILE} + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR}/include/atlas/core + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/pack_shaders.py + ${SHADER_INPUT_DIR} ${SHADER_OUTPUT_FILE} ${SHADER_BACKEND_ARG} + DEPENDS ${SHADER_SOURCES} + COMMENT "Packing shaders into default_shaders.h" + VERBATIM ) add_custom_target(generate_shaders DEPENDS ${SHADER_OUTPUT_FILE}) add_custom_command( - OUTPUT ${RUNTIME_SCRIPT_OUTPUT_FILE} - COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR}/include/atlas/runtime - COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/pack_runtime_scripts.py - ${RUNTIME_SCRIPT_INPUT_DIR} ${RUNTIME_SCRIPT_OUTPUT_FILE} - DEPENDS ${RUNTIME_SCRIPT_SOURCES} ${CMAKE_SOURCE_DIR}/pack_runtime_scripts.py - COMMENT "Packing runtime scripts into atlasScripts.h" - VERBATIM + OUTPUT ${RUNTIME_SCRIPT_OUTPUT_FILE} + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_SOURCE_DIR}/include/atlas/runtime + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/pack_runtime_scripts.py + ${RUNTIME_SCRIPT_INPUT_DIR} ${RUNTIME_SCRIPT_OUTPUT_FILE} + DEPENDS ${RUNTIME_SCRIPT_SOURCES} ${CMAKE_SOURCE_DIR}/scripts/pack_runtime_scripts.py + COMMENT "Packing runtime scripts into atlasScripts.h" + VERBATIM ) add_custom_target(generate_runtime_scripts DEPENDS ${RUNTIME_SCRIPT_OUTPUT_FILE}) # ─── Typst Docs ────────────────────────────────────────────────────────────── find_program(TYPST_EXECUTABLE typst) -if(TYPST_EXECUTABLE) +if (TYPST_EXECUTABLE) message(STATUS "Found Typst: ${TYPST_EXECUTABLE}") file(GLOB_RECURSE TYPST_SOURCES ${CMAKE_SOURCE_DIR}/*.typ) - if(TYPST_SOURCES) + if (TYPST_SOURCES) set(TYPST_OUTPUT_DIR ${CMAKE_BINARY_DIR}/docs) file(MAKE_DIRECTORY ${TYPST_OUTPUT_DIR}) set(TYPST_OUTPUTS "") - foreach(TYPST_FILE ${TYPST_SOURCES}) + foreach (TYPST_FILE ${TYPST_SOURCES}) get_filename_component(TYPST_NAME ${TYPST_FILE} NAME_WE) set(PDF_OUTPUT ${TYPST_OUTPUT_DIR}/${TYPST_NAME}.pdf) add_custom_command( - OUTPUT ${PDF_OUTPUT} - COMMAND ${TYPST_EXECUTABLE} compile ${TYPST_FILE} ${PDF_OUTPUT} - DEPENDS ${TYPST_FILE} - COMMENT "Compiling Typst document: ${TYPST_NAME}.typ -> ${TYPST_NAME}.pdf" - VERBATIM + OUTPUT ${PDF_OUTPUT} + COMMAND ${TYPST_EXECUTABLE} compile ${TYPST_FILE} ${PDF_OUTPUT} + DEPENDS ${TYPST_FILE} + COMMENT "Compiling Typst document: ${TYPST_NAME}.typ -> ${TYPST_NAME}.pdf" + VERBATIM ) list(APPEND TYPST_OUTPUTS ${PDF_OUTPUT}) - endforeach() + endforeach () add_custom_target(typst_docs ALL DEPENDS ${TYPST_OUTPUTS}) message(STATUS "Typst documents will be compiled to: ${TYPST_OUTPUT_DIR}") - else() + else () message(STATUS "No Typst documents found") - endif() -else() + endif () +else () message(WARNING "Typst not found - skipping document compilation") -endif() +endif () # ─── Platform Packages ─────────────────────────────────────────────────────── -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) find_package(OpenGL REQUIRED) -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) find_package(Vulkan REQUIRED) message(STATUS "Found Vulkan SDK at ${Vulkan_LIBRARY}") message(STATUS "Found Vulkan include dirs at ${Vulkan_INCLUDE_DIRS}") # Optional: still find spirv-cross through package manager / SDK. # Convert this to FetchContent too later if you want fully source-based Vulkan deps. - if(APPLE) + if (APPLE) list(APPEND CMAKE_PREFIX_PATH "/opt/homebrew/opt/spirv-cross") - endif() + endif () find_package(spirv_cross_core REQUIRED) find_package(spirv_cross_glsl REQUIRED) find_package(spirv_cross_reflect REQUIRED) -elseif(BACKEND_METAL) +elseif (BACKEND_METAL) message(STATUS "Using Metal backend") -endif() +endif () # ─── Global Includes ───────────────────────────────────────────────────────── include_directories( - include - extern - extern/quickjs + include + extern + extern/quickjs ) -if(NOT BEZEL_NATIVE) +if (NOT BEZEL_NATIVE) include_directories(${joltphysics_SOURCE_DIR}) -endif() +endif () # ─── Sources ───────────────────────────────────────────────────────────────── -if(BEZEL_NATIVE) +if (BEZEL_NATIVE) file(GLOB_RECURSE BEZEL_SOURCES CONFIGURE_DEPENDS bezel/native/*.cpp) message(STATUS "Compiling with Bezel Native") -else() +else () file(GLOB_RECURSE BEZEL_SOURCES CONFIGURE_DEPENDS bezel/jolt/*.cpp) message(STATUS "Compiling with Bezel Jolt (Jolt Physics ${JOLT_VERSION})") -endif() - -file(GLOB_RECURSE FINEWAVE_SOURCES CONFIGURE_DEPENDS finewave/*.cpp) -file(GLOB_RECURSE AURORA_SOURCES CONFIGURE_DEPENDS aurora/*.cpp) -file(GLOB_RECURSE HYDRA_SOURCES CONFIGURE_DEPENDS hydra/*.cpp) -file(GLOB_RECURSE ATLAS_SOURCES CONFIGURE_DEPENDS atlas/*.cpp extern/glad/*.c) -file(GLOB_RECURSE OPAL_SOURCES CONFIGURE_DEPENDS opal/*.cpp) -file(GLOB_RECURSE PHOTON_SOURCES CONFIGURE_DEPENDS photon/*.cpp) -file(GLOB_RECURSE GRAPHITE_SOURCES CONFIGURE_DEPENDS graphite/*.cpp) +endif () + +file(GLOB_RECURSE FINEWAVE_SOURCES CONFIGURE_DEPENDS finewave/*.cpp) +file(GLOB_RECURSE AURORA_SOURCES CONFIGURE_DEPENDS aurora/*.cpp) +file(GLOB_RECURSE HYDRA_SOURCES CONFIGURE_DEPENDS hydra/*.cpp) +file(GLOB_RECURSE ATLAS_SOURCES CONFIGURE_DEPENDS atlas/*.cpp extern/glad/*.c) +file(GLOB_RECURSE OPAL_SOURCES CONFIGURE_DEPENDS opal/*.cpp) +file(GLOB_RECURSE PHOTON_SOURCES CONFIGURE_DEPENDS photon/*.cpp) +file(GLOB_RECURSE GRAPHITE_SOURCES CONFIGURE_DEPENDS graphite/*.cpp) file(GLOB_RECURSE RUNTIME_LIB_SOURCES CONFIGURE_DEPENDS runtime/lib/*.cpp) -file(GLOB_RECURSE RUNTIME_SOURCES CONFIGURE_DEPENDS runtime/executable/*.cpp) -file(GLOB_RECURSE QUICKJS_SOURCES CONFIGURE_DEPENDS extern/quickjs/*.c) +file(GLOB_RECURSE RUNTIME_SOURCES CONFIGURE_DEPENDS runtime/executable/*.cpp) +file(GLOB_RECURSE QUICKJS_SOURCES CONFIGURE_DEPENDS extern/quickjs/*.c) # ─── quickjs ───────────────────────────────────────────────────────────────── add_library(quickjs STATIC ${QUICKJS_SOURCES}) target_include_directories(quickjs PUBLIC ${CMAKE_SOURCE_DIR}/extern/quickjs) -if(MSVC) +if (MSVC) target_compile_options(quickjs PRIVATE /W0) -else() +else () target_compile_options(quickjs PRIVATE -w -Wno-error) -endif() +endif () # Pick the right OpenAL target name from openal-soft. set(ATLAS_OPENAL_TARGET "") -if(TARGET OpenAL) +if (TARGET OpenAL) set(ATLAS_OPENAL_TARGET OpenAL) -elseif(TARGET openal) +elseif (TARGET openal) set(ATLAS_OPENAL_TARGET openal) -elseif(TARGET OpenAL::OpenAL) +elseif (TARGET OpenAL::OpenAL) set(ATLAS_OPENAL_TARGET OpenAL::OpenAL) -else() +else () message(FATAL_ERROR "Could not determine OpenAL target exported by openal-soft") -endif() +endif () # Pick SDL static target. set(ATLAS_SDL_TARGET "") -if(TARGET SDL3::SDL3-static) +if (TARGET SDL3::SDL3-static) set(ATLAS_SDL_TARGET SDL3::SDL3-static) -elseif(TARGET SDL3::SDL3) +elseif (TARGET SDL3::SDL3) set(ATLAS_SDL_TARGET SDL3::SDL3) -elseif(TARGET SDL3-static) +elseif (TARGET SDL3-static) set(ATLAS_SDL_TARGET SDL3-static) -else() +else () message(FATAL_ERROR "Could not determine SDL3 target") -endif() +endif () # Pick Freetype target. set(ATLAS_FREETYPE_TARGET "") -if(TARGET Freetype::Freetype) +if (TARGET Freetype::Freetype) set(ATLAS_FREETYPE_TARGET Freetype::Freetype) -elseif(TARGET freetype) +elseif (TARGET freetype) set(ATLAS_FREETYPE_TARGET freetype) -else() +else () message(FATAL_ERROR "Could not determine Freetype target") -endif() +endif () # Pick Assimp target. set(ATLAS_ASSIMP_TARGET "") -if(TARGET assimp::assimp) +if (TARGET assimp::assimp) set(ATLAS_ASSIMP_TARGET assimp::assimp) -elseif(TARGET assimp) +elseif (TARGET assimp) set(ATLAS_ASSIMP_TARGET assimp) -else() +else () message(FATAL_ERROR "Could not determine Assimp target") -endif() +endif () # Pick glm target. set(ATLAS_GLM_TARGET "") -if(TARGET glm::glm-header-only) +if (TARGET glm::glm-header-only) set(ATLAS_GLM_TARGET glm::glm-header-only) -elseif(TARGET glm::glm) +elseif (TARGET glm::glm) set(ATLAS_GLM_TARGET glm::glm) -elseif(TARGET glm-header-only) +elseif (TARGET glm-header-only) set(ATLAS_GLM_TARGET glm-header-only) -else() +else () message(FATAL_ERROR "Could not determine GLM target") -endif() +endif () # ─── bezel ─────────────────────────────────────────────────────────────────── add_library(bezel STATIC ${BEZEL_SOURCES}) target_link_libraries(bezel PRIVATE ${ATLAS_SDL_TARGET} ${ATLAS_GLM_TARGET}) target_include_directories(bezel PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) -if(BEZEL_NATIVE) +if (BEZEL_NATIVE) target_compile_definitions(bezel PRIVATE NATIVE) -else() +else () target_compile_definitions(bezel PRIVATE - JPH_PROFILE_ENABLED=1 - JPH_DEBUG_RENDERER=1 - JPH_OBJECT_STREAM=1 + JPH_PROFILE_ENABLED=1 + JPH_DEBUG_RENDERER=1 + JPH_OBJECT_STREAM=1 ) -endif() +endif () # ─── finewave ──────────────────────────────────────────────────────────────── add_library(finewave STATIC ${FINEWAVE_SOURCES}) target_include_directories(finewave PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) target_link_libraries(finewave PRIVATE - ${ATLAS_SDL_TARGET} - ${ATLAS_GLM_TARGET} - ${ATLAS_OPENAL_TARGET} - fmt::fmt + ${ATLAS_SDL_TARGET} + ${ATLAS_GLM_TARGET} + ${ATLAS_OPENAL_TARGET} + fmt::fmt ) # ─── opal ──────────────────────────────────────────────────────────────────── @@ -467,12 +468,12 @@ target_include_directories(opal PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) target_link_libraries(opal PRIVATE ${ATLAS_GLM_TARGET} ${ATLAS_SDL_TARGET}) add_dependencies(opal generate_shaders) -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) target_link_libraries(opal PRIVATE OpenGL::GL) -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) target_link_libraries(opal PRIVATE Vulkan::Vulkan spirv-cross-core spirv-cross-glsl spirv-cross-reflect) target_compile_definitions(opal PRIVATE VULKAN) -endif() +endif () # ─── photon ────────────────────────────────────────────────────────────────── add_library(photon STATIC ${PHOTON_SOURCES}) @@ -486,11 +487,11 @@ target_link_libraries(aurora PRIVATE ${ATLAS_GLM_TARGET} ${ATLAS_SDL_TARGET} bez target_include_directories(aurora PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) add_dependencies(aurora generate_shaders) -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) target_link_libraries(aurora PRIVATE OpenGL::GL) -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) target_link_libraries(aurora PRIVATE Vulkan::Vulkan) -endif() +endif () # ─── hydra ─────────────────────────────────────────────────────────────────── add_library(hydra STATIC ${HYDRA_SOURCES}) @@ -498,19 +499,19 @@ target_link_libraries(hydra PRIVATE ${ATLAS_GLM_TARGET} ${ATLAS_SDL_TARGET} beze target_include_directories(hydra PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) add_dependencies(hydra generate_shaders) -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) target_link_libraries(hydra PRIVATE OpenGL::GL) -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) target_link_libraries(hydra PRIVATE Vulkan::Vulkan) -endif() +endif () # ─── graphite ──────────────────────────────────────────────────────────────── add_library(graphite STATIC ${GRAPHITE_SOURCES}) target_link_libraries(graphite PRIVATE - ${ATLAS_GLM_TARGET} - ${ATLAS_SDL_TARGET} - ${ATLAS_FREETYPE_TARGET} - ${ATLAS_ASSIMP_TARGET} + ${ATLAS_GLM_TARGET} + ${ATLAS_SDL_TARGET} + ${ATLAS_FREETYPE_TARGET} + ${ATLAS_ASSIMP_TARGET} ) target_include_directories(graphite PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) add_dependencies(graphite generate_shaders) @@ -520,7 +521,7 @@ add_library(atlas STATIC ${ATLAS_SOURCES}) add_dependencies(atlas generate_shaders) target_link_libraries(atlas - PUBLIC + PUBLIC ${ATLAS_SDL_TARGET} ${ATLAS_GLM_TARGET} bezel @@ -534,81 +535,83 @@ target_link_libraries(atlas graphite ) -if(NOT BEZEL_NATIVE) +if (NOT BEZEL_NATIVE) target_link_libraries(atlas PUBLIC Jolt::Jolt) -endif() +endif () target_include_directories(atlas PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) -if(BACKEND_OPENGL) +if (BACKEND_OPENGL) target_link_libraries(atlas PRIVATE OpenGL::GL) -elseif(BACKEND_VULKAN) +elseif (BACKEND_VULKAN) target_link_libraries(atlas PRIVATE Vulkan::Vulkan) - if(APPLE) + if (APPLE) message(STATUS "Configuring MoltenVK for macOS") target_compile_definitions(atlas PRIVATE VK_USE_PLATFORM_MACOS_MVK) - endif() -endif() + endif () +endif () target_compile_definitions(atlas PRIVATE ATLAS_LIB GL_SILENT_DEPRECATION) target_compile_options(atlas PRIVATE -Wno-overlength-strings) -if(WIN32) +if (WIN32) target_compile_definitions(atlas PRIVATE _CRT_SECURE_NO_WARNINGS NOMINMAX) target_link_libraries(atlas PRIVATE opengl32) -elseif(APPLE) - if(BACKEND_OPENGL) +elseif (APPLE) + if (BACKEND_OPENGL) target_link_libraries(atlas PRIVATE - "-framework OpenGL" - "-framework Cocoa" - "-framework IOKit" + "-framework OpenGL" + "-framework Cocoa" + "-framework IOKit" ) - elseif(BACKEND_VULKAN) + elseif (BACKEND_VULKAN) target_link_libraries(atlas PRIVATE - "-framework Cocoa" - "-framework IOKit" - "-framework QuartzCore" + "-framework Cocoa" + "-framework IOKit" + "-framework QuartzCore" ) - elseif(BACKEND_METAL) + elseif (BACKEND_METAL) target_link_libraries(atlas PRIVATE - "-framework Metal" - "-framework MetalKit" - "-framework MetalFX" - "-framework Cocoa" - "-framework IOKit" - "-framework QuartzCore" + "-framework Metal" + "-framework MetalKit" + "-framework MetalFX" + "-framework Cocoa" + "-framework IOKit" + "-framework QuartzCore" ) - endif() -elseif(UNIX) + endif () +elseif (UNIX) target_link_libraries(atlas PRIVATE dl pthread) -endif() +endif () # ─── runtime_lib ───────────────────────────────────────────────────────────── add_library(runtime_lib SHARED ${RUNTIME_LIB_SOURCES}) target_link_libraries(runtime_lib PRIVATE atlas quickjs fmt::fmt) target_compile_definitions(runtime_lib PRIVATE - RUNTIME_LIB - JPH_PROFILE_ENABLED=1 - JPH_DEBUG_RENDERER=1 - JPH_OBJECT_STREAM=1 + RUNTIME_LIB + JPH_PROFILE_ENABLED=1 + JPH_DEBUG_RENDERER=1 + JPH_OBJECT_STREAM=1 ) add_dependencies(runtime_lib generate_shaders generate_runtime_scripts) target_include_directories(runtime_lib PUBLIC ${CMAKE_SOURCE_DIR}/extern/quickjs) target_include_directories(runtime_lib PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) set_target_properties(runtime_lib PROPERTIES - OUTPUT_NAME runtime - PREFIX "" + OUTPUT_NAME runtime + PREFIX "" ) # ─── runtime executable ────────────────────────────────────────────────────── add_executable(atlasrun ${RUNTIME_SOURCES}) target_link_libraries(atlasrun PRIVATE runtime_lib) target_compile_definitions(atlasrun PRIVATE - RUNTIME_BIN - JPH_PROFILE_ENABLED=1 - JPH_DEBUG_RENDERER=1 - JPH_OBJECT_STREAM=1 + RUNTIME_BIN + JPH_PROFILE_ENABLED=1 + JPH_DEBUG_RENDERER=1 + JPH_OBJECT_STREAM=1 ) target_include_directories(atlasrun PRIVATE ${CMAKE_SOURCE_DIR}/runtime/include) target_include_directories(atlasrun PRIVATE ${ATLAS_DEP_INCLUDE_DIRS}) add_dependencies(atlasrun generate_shaders) + +add_subdirectory(editor) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..1c052834 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2444 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atlas-cli" +version = "0.1.0" +dependencies = [ + "clap", + "colored", + "dialoguer", + "indicatif", + "openssl", + "reqwest", + "serde", + "serde_json", + "tokio", + "toml", + "zip", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.0", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deflate64" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.1", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-uring" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.2+3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.0", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.1", +] + +[[package]] +name = "rustls" +version = "0.23.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.1", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link 0.2.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +dependencies = [ + "windows-link 0.2.0", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.3", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/README.md b/README.md index 7490cebc..5c355645 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ Each archive bundles Atlas and its internal engine modules into one static archi ### Build / Run / Pack / Clangd -- `atlas build` configures and builds using backend from `atlas.toml`. +- `atlas build` configures and builds using the backend from `project.atlas`. - `atlas run` builds then runs the produced executable. -- `atlas pack` builds and creates distributable output: +- `atlas pack` creates distributable output from the configured Atlas runtime and project resources: - macOS: `.app` bundle in `dist/` - other platforms: executable in `dist/` - `atlas clangd` generates `build/compile_commands.json` and exposes it at project root as `compile_commands.json`. diff --git a/atlas/application/input.cpp b/atlas/application/input.cpp index 65937fa2..563efa4e 100644 --- a/atlas/application/input.cpp +++ b/atlas/application/input.cpp @@ -158,4 +158,4 @@ void Gamepad::rumble(float strength, float duration) const { } SDL_RumbleGamepad(gamepad, (Uint16)(strength * 65535), (Uint16)(strength * 65535), (Uint32)(duration * 1000)); -} \ No newline at end of file +} diff --git a/atlas/application/window.cpp b/atlas/application/window.cpp index c30af2a5..35f80db2 100644 --- a/atlas/application/window.cpp +++ b/atlas/application/window.cpp @@ -26,6 +26,7 @@ #include "finewave/audio.h" #include #include +#include #include #include #include @@ -48,6 +49,8 @@ #define GLM_ENABLE_EXPERIMENTAL #include #include + +#define EDITOR_ZOOM_SENSITIVITY 0.07f #include Window *Window::mainWindow = nullptr; @@ -88,9 +91,32 @@ bool objectBounds(GameObject *object, glm::vec3 &boundsMin, return false; } + if (auto *compound = dynamic_cast(object); + compound != nullptr && !compound->objects.empty()) { + bool any = false; + boundsMin = glm::vec3(std::numeric_limits::max()); + boundsMax = glm::vec3(std::numeric_limits::lowest()); + for (GameObject *child : compound->objects) { + glm::vec3 childMin; + glm::vec3 childMax; + if (!objectBounds(child, childMin, childMax)) { + continue; + } + boundsMin = any ? glm::min(boundsMin, childMin) : childMin; + boundsMax = any ? glm::max(boundsMax, childMax) : childMax; + any = true; + } + return any; + } + std::vector vertices = object->getVertices(); if (vertices.empty()) { - return false; + glm::vec3 center = object->getPosition().toGlm(); + glm::vec3 radius = + glm::max(object->getScale().toGlm() * 0.25f, glm::vec3(0.25f)); + boundsMin = center - radius; + boundsMax = center + radius; + return true; } glm::mat4 transform = objectTransform(object); @@ -105,6 +131,61 @@ bool objectBounds(GameObject *object, glm::vec3 &boundsMin, return true; } +bool objectLocalBounds(GameObject *object, glm::vec3 &boundsMin, + glm::vec3 &boundsMax) { + if (object == nullptr) { + return false; + } + + std::vector vertices = object->getVertices(); + if (vertices.empty()) { + return false; + } + + boundsMin = glm::vec3(std::numeric_limits::max()); + boundsMax = glm::vec3(std::numeric_limits::lowest()); + for (const auto &vertex : vertices) { + glm::vec3 p = vertex.position.toGlm(); + boundsMin = glm::min(boundsMin, p); + boundsMax = glm::max(boundsMax, p); + } + return true; +} + +std::array boundsCorners(const glm::vec3 &boundsMin, + const glm::vec3 &boundsMax) { + return { + glm::vec3(boundsMin.x, boundsMin.y, boundsMin.z), + glm::vec3(boundsMin.x, boundsMin.y, boundsMax.z), + glm::vec3(boundsMin.x, boundsMax.y, boundsMin.z), + glm::vec3(boundsMin.x, boundsMax.y, boundsMax.z), + glm::vec3(boundsMax.x, boundsMin.y, boundsMin.z), + glm::vec3(boundsMax.x, boundsMin.y, boundsMax.z), + glm::vec3(boundsMax.x, boundsMax.y, boundsMin.z), + glm::vec3(boundsMax.x, boundsMax.y, boundsMax.z), + }; +} + +std::array +transformBoundsCorners(const std::array &corners, + const glm::mat4 &transform) { + std::array transformed{}; + for (std::size_t i = 0; i < corners.size(); ++i) { + transformed[i] = glm::vec3(transform * glm::vec4(corners[i], 1.0f)); + } + return transformed; +} + +void boundsFromCorners(const std::array &corners, + glm::vec3 &boundsMin, glm::vec3 &boundsMax) { + boundsMin = glm::vec3(std::numeric_limits::max()); + boundsMax = glm::vec3(std::numeric_limits::lowest()); + for (const auto &corner : corners) { + boundsMin = glm::min(boundsMin, corner); + boundsMax = glm::max(boundsMax, corner); + } +} + bool projectBoundsToScreen(const glm::vec3 &boundsMin, const glm::vec3 &boundsMax, const glm::mat4 &viewProjection, float viewWidth, @@ -143,10 +224,85 @@ bool projectBoundsToScreen(const glm::vec3 &boundsMin, return any; } +bool projectPointToScreen(const glm::vec3 &point, + const glm::mat4 &viewProjection, float viewWidth, + float viewHeight, glm::vec2 &screen, float &depth) { + glm::vec4 clip = viewProjection * glm::vec4(point, 1.0f); + if (std::abs(clip.w) < 0.000001f || clip.w < 0.0f) { + return false; + } + glm::vec3 ndc = glm::vec3(clip) / clip.w; + screen = glm::vec2((ndc.x * 0.5f + 0.5f) * viewWidth, + (ndc.y * 0.5f + 0.5f) * viewHeight); + depth = ndc.z; + return true; +} + +float worldUnitsPerScreenPixel(Camera *camera, const glm::vec3 &point, + float viewHeight) { + if (camera == nullptr) { + return 0.001f; + } + + float safeViewHeight = std::max(1.0f, viewHeight); + if (camera->useOrthographic) { + return std::max(0.000001f, + (camera->orthographicSize * 2.0f) / safeViewHeight); + } + + glm::vec3 cameraPosition = camera->position.toGlm(); + glm::vec3 cameraDirection = camera->target.toGlm() - cameraPosition; + float distance = glm::length(point - cameraPosition); + if (glm::length(cameraDirection) > 0.000001f) { + glm::vec3 forward = glm::normalize(cameraDirection); + distance = std::abs(glm::dot(point - cameraPosition, forward)); + } + distance = std::max(distance, camera->nearClip); + float visibleHeight = + 2.0f * std::tan(glm::radians(camera->fov) * 0.5f) * distance; + return std::max(0.000001f, visibleHeight / safeViewHeight); +} + +float distanceToScreenSegment(const glm::vec2 &point, const glm::vec2 &from, + const glm::vec2 &to) { + glm::vec2 segment = to - from; + float lengthSquared = glm::dot(segment, segment); + if (lengthSquared < 0.000001f) { + return glm::length(point - from); + } + float t = + glm::clamp(glm::dot(point - from, segment) / lengthSquared, 0.0f, 1.0f); + return glm::length(point - (from + segment * t)); +} + +float distanceToScreenPoint(const glm::vec2 &point, const glm::vec2 &target) { + return glm::length(point - target); +} + +glm::vec3 editorAxisVector(int axis) { + if (axis == 1) { + return glm::vec3(1.0f, 0.0f, 0.0f); + } + if (axis == 2) { + return glm::vec3(0.0f, 1.0f, 0.0f); + } + if (axis == 3) { + return glm::vec3(0.0f, 0.0f, 1.0f); + } + return glm::vec3(0.0f); +} + +glm::vec3 editorScaleAxisVector(GameObject *object, int axis) { + const glm::vec3 localAxis = editorAxisVector(axis); + if (object == nullptr || glm::length(localAxis) < 0.000001f) + return localAxis; + return glm::normalize(object->getRotation().toGlmQuat() * localAxis); +} + void ensureEditorLineObject(std::unique_ptr &object, bool &initialized, const std::vector &vertices) { - if (!object) { + if (!object || object->vertices.size() != vertices.size()) { object = std::make_unique(); object->attachVertices(vertices); object->attachProgram(ShaderProgram::fromDefaultShaders( @@ -186,6 +342,80 @@ void appendEditorLine(std::vector &vertices, const glm::vec3 &from, vertices.push_back(editorVertex(to, color)); } +void linePlaneBasis(const glm::vec3 &axis, glm::vec3 &u, glm::vec3 &v) { + glm::vec3 direction = axis; + if (glm::length(direction) < 0.000001f) { + direction = glm::vec3(0.0f, 1.0f, 0.0f); + } + direction = glm::normalize(direction); + glm::vec3 seed = std::abs(direction.y) < 0.92f + ? glm::vec3(0.0f, 1.0f, 0.0f) + : glm::vec3(1.0f, 0.0f, 0.0f); + u = glm::normalize(glm::cross(direction, seed)); + v = glm::normalize(glm::cross(direction, u)); +} + +void appendEditorTriangle(std::vector &vertices, const glm::vec3 &a, + const glm::vec3 &b, const glm::vec3 &c, + const Color &color) { + vertices.push_back(editorVertex(a, color)); + vertices.push_back(editorVertex(b, color)); + vertices.push_back(editorVertex(c, color)); +} + +void appendEditorQuad(std::vector &vertices, const glm::vec3 &a, + const glm::vec3 &b, const glm::vec3 &c, + const glm::vec3 &d, const Color &color) { + appendEditorTriangle(vertices, a, b, c, color); + appendEditorTriangle(vertices, a, c, d, color); +} + +void appendSolidCube(std::vector &vertices, const glm::vec3 ¢er, + float halfSize, const Color &color) { + glm::vec3 p000 = center + glm::vec3(-halfSize, -halfSize, -halfSize); + glm::vec3 p001 = center + glm::vec3(-halfSize, -halfSize, halfSize); + glm::vec3 p010 = center + glm::vec3(-halfSize, halfSize, -halfSize); + glm::vec3 p011 = center + glm::vec3(-halfSize, halfSize, halfSize); + glm::vec3 p100 = center + glm::vec3(halfSize, -halfSize, -halfSize); + glm::vec3 p101 = center + glm::vec3(halfSize, -halfSize, halfSize); + glm::vec3 p110 = center + glm::vec3(halfSize, halfSize, -halfSize); + glm::vec3 p111 = center + glm::vec3(halfSize, halfSize, halfSize); + + appendEditorQuad(vertices, p000, p100, p110, p010, color); + appendEditorQuad(vertices, p001, p011, p111, p101, color); + appendEditorQuad(vertices, p000, p001, p101, p100, color); + appendEditorQuad(vertices, p010, p110, p111, p011, color); + appendEditorQuad(vertices, p000, p010, p011, p001, color); + appendEditorQuad(vertices, p100, p101, p111, p110, color); +} + +void appendRibbonLine(std::vector &vertices, const glm::vec3 &from, + const glm::vec3 &to, float halfWidth, + const glm::vec3 &cameraPosition, const Color &color) { + glm::vec3 axis = to - from; + if (glm::length(axis) < 0.000001f) { + return; + } + + glm::vec3 viewDirection = cameraPosition - ((from + to) * 0.5f); + if (glm::length(viewDirection) < 0.000001f) { + viewDirection = glm::vec3(0.0f, 0.0f, 1.0f); + } + + glm::vec3 side = + glm::cross(glm::normalize(axis), glm::normalize(viewDirection)); + if (glm::length(side) < 0.000001f) { + glm::vec3 u; + glm::vec3 v; + linePlaneBasis(axis, u, v); + side = u; + } + + side = glm::normalize(side) * halfWidth; + appendEditorQuad(vertices, from - side, to - side, to + side, from + side, + color); +} + void axisPlaneBasis(const glm::vec3 &axis, glm::vec3 &u, glm::vec3 &v) { if (std::abs(axis.x) > 0.5f) { u = glm::vec3(0.0f, 1.0f, 0.0f); @@ -199,22 +429,32 @@ void axisPlaneBasis(const glm::vec3 &axis, glm::vec3 &u, glm::vec3 &v) { } } -void appendSquareCap(std::vector &vertices, const glm::vec3 &tip, - const glm::vec3 &axis, float halfSize, - const Color &color) { +void appendSolidCubeCap(std::vector &vertices, + const glm::vec3 ¢er, const glm::vec3 &axis, + float halfSize, const Color &color) { glm::vec3 u; glm::vec3 v; axisPlaneBasis(axis, u, v); - - glm::vec3 p0 = tip + (u + v) * halfSize; - glm::vec3 p1 = tip + (u - v) * halfSize; - glm::vec3 p2 = tip + (-u - v) * halfSize; - glm::vec3 p3 = tip + (-u + v) * halfSize; - - appendEditorLine(vertices, p0, p1, color); - appendEditorLine(vertices, p1, p2, color); - appendEditorLine(vertices, p2, p3, color); - appendEditorLine(vertices, p3, p0, color); + glm::vec3 w = glm::normalize(axis); + if (glm::length(w) < 0.000001f) { + w = glm::vec3(1.0f, 0.0f, 0.0f); + } + + glm::vec3 p000 = center + (-u - v - w) * halfSize; + glm::vec3 p001 = center + (-u - v + w) * halfSize; + glm::vec3 p010 = center + (-u + v - w) * halfSize; + glm::vec3 p011 = center + (-u + v + w) * halfSize; + glm::vec3 p100 = center + (u - v - w) * halfSize; + glm::vec3 p101 = center + (u - v + w) * halfSize; + glm::vec3 p110 = center + (u + v - w) * halfSize; + glm::vec3 p111 = center + (u + v + w) * halfSize; + + appendEditorQuad(vertices, p000, p100, p110, p010, color); + appendEditorQuad(vertices, p001, p011, p111, p101, color); + appendEditorQuad(vertices, p000, p001, p101, p100, color); + appendEditorQuad(vertices, p010, p110, p111, p011, color); + appendEditorQuad(vertices, p000, p010, p011, p001, color); + appendEditorQuad(vertices, p100, p101, p111, p110, color); } void appendArrowHead(std::vector &vertices, const glm::vec3 &tip, @@ -229,10 +469,11 @@ void appendArrowHead(std::vector &vertices, const glm::vec3 &tip, glm::vec3 p2 = baseCenter + v * size; glm::vec3 p3 = baseCenter - v * size; - appendEditorLine(vertices, p0, tip, color); - appendEditorLine(vertices, p1, tip, color); - appendEditorLine(vertices, p2, tip, color); - appendEditorLine(vertices, p3, tip, color); + appendEditorTriangle(vertices, p0, p2, tip, color); + appendEditorTriangle(vertices, p2, p1, tip, color); + appendEditorTriangle(vertices, p1, p3, tip, color); + appendEditorTriangle(vertices, p3, p0, tip, color); + appendEditorQuad(vertices, p0, p3, p1, p2, color); } #if defined(METAL) && defined(__APPLE__) @@ -685,12 +926,12 @@ Window::Window(const WindowConfiguration &config) #ifdef METAL this->externalMetalView = config.metalTargetView; this->renderToExternalMetalView = this->externalMetalView != nullptr; - this->showHostWindow = !this->renderToExternalMetalView; + this->showHostWindow = config.showHostWindow && !this->renderToExternalMetalView; #else (void)config.metalTargetView; this->externalMetalView = nullptr; this->renderToExternalMetalView = false; - this->showHostWindow = true; + this->showHostWindow = config.showHostWindow; #endif #ifdef VULKAN @@ -730,6 +971,7 @@ Window::Window(const WindowConfiguration &config) context->setAlwaysOnTop(config.alwaysOnTop); context->setSamples(config.multisampling ? 4 : 0); context->setHighPixelDensity(true); + context->setHidden(!this->showHostWindow); #ifdef METAL if (this->renderToExternalMetalView) { @@ -1007,6 +1249,9 @@ void Window::initializeRunLoop() { } void Window::pollEvents() { + if (this->renderToExternalMetalView) { + return; + } SDL_Event event; while (SDL_PollEvent(&event)) { switch (event.type) { @@ -1116,6 +1361,15 @@ void Window::pollEvents() { } break; case SDL_EVENT_MOUSE_WHEEL: + if (this->editorControlsEnabled && + event.wheel.windowID == this->runLoopWindowID) { + float offsetY = event.wheel.y; + if (event.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) { + offsetY = -offsetY; + } + this->editorScrollEvent(offsetY, 1.0f); + break; + } if ((!this->editorControlsEnabled || this->editorSimulationEnabled) && event.wheel.windowID == this->runLoopWindowID && @@ -1191,6 +1445,7 @@ bool Window::stepFrame() { } if (this->editorControlsEnabled && !this->editorSimulationEnabled) { this->updateEditorCameraMovement(editorDeltaTime); + this->updateEditorCameraInertia(editorDeltaTime); } device->frameCount++; @@ -1214,11 +1469,6 @@ bool Window::stepFrame() { this->uiRenderables.erase(std::remove(this->uiRenderables.begin(), this->uiRenderables.end(), obj), this->uiRenderables.end()); - if (auto *fluid = dynamic_cast(obj)) { - this->lateFluids.erase(std::remove(this->lateFluids.begin(), - this->lateFluids.end(), fluid), - this->lateFluids.end()); - } } this->pendingRemovals.clear(); @@ -1309,21 +1559,38 @@ bool Window::stepFrame() { renderLightsToShadowMaps(commandBuffer); - static std::unique_ptr modeScreenTarget = nullptr; std::vector activeRenderTargets = this->renderTargets; bool usesModeScreenTarget = false; + bool editorControlsRenderedInScenePass = false; if (activeRenderTargets.empty() && (this->usePathTracing || this->usesDeferred)) { - if (!modeScreenTarget) { - modeScreenTarget = + int modeTargetFbWidth = 0; + int modeTargetFbHeight = 0; + this->queryDrawableSizeInPixels(&modeTargetFbWidth, + &modeTargetFbHeight); + const int modeTargetWidth = std::max( + 1, static_cast(modeTargetFbWidth * this->getRenderScale())); + const int modeTargetHeight = std::max( + 1, static_cast(modeTargetFbHeight * this->getRenderScale())); + if (!this->modeScreenTarget || + this->modeScreenTarget->getWidth() != modeTargetWidth || + this->modeScreenTarget->getHeight() != modeTargetHeight) { + if (this->modeScreenTarget) { + auto *target = this->modeScreenTarget.get(); + this->preferenceRenderables.erase( + std::remove(this->preferenceRenderables.begin(), + this->preferenceRenderables.end(), target), + this->preferenceRenderables.end()); + } + this->modeScreenTarget = std::make_unique(*this, RenderTargetType::Scene); } - modeScreenTarget->display(*this, 0.0f); - modeScreenTarget->show(); - activeRenderTargets.push_back(modeScreenTarget.get()); + this->modeScreenTarget->display(*this, 0.0f); + this->modeScreenTarget->show(); + activeRenderTargets.push_back(this->modeScreenTarget.get()); usesModeScreenTarget = true; - } else if (modeScreenTarget) { - modeScreenTarget->hide(); + } else if (this->modeScreenTarget) { + this->modeScreenTarget->hide(); } for (auto &target : activeRenderTargets) { @@ -1390,6 +1657,7 @@ bool Window::stepFrame() { opal::CompareOp::Less); updatePipelineStateField(this->writeDepth, true); updatePipelineStateField(this->cullMode, opal::CullMode::Back); + renderEditorGrid(commandBuffer); auto renderForwardOnly = [&](Renderable *obj) { if (obj == nullptr) { @@ -1428,6 +1696,7 @@ bool Window::stepFrame() { if (obj->canUseDeferredRendering()) { return; } + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) return; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, @@ -1446,12 +1715,15 @@ bool Window::stepFrame() { } for (auto &obj : this->lateForwardRenderables) { + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, shouldRefreshPipeline(obj)); } + renderEditorOverlays(commandBuffer); + editorControlsRenderedInScenePass = true; commandBuffer->endPass(); continue; } @@ -1459,8 +1731,10 @@ bool Window::stepFrame() { commandBuffer->clearColor(this->clearColor.r, this->clearColor.g, this->clearColor.b, this->clearColor.a); commandBuffer->clearDepth(1.0f); + renderEditorGrid(commandBuffer); for (auto &obj : this->firstRenderables) { + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, @@ -1471,6 +1745,7 @@ bool Window::stepFrame() { if (obj->renderLateForward) { continue; } + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, @@ -1478,11 +1753,14 @@ bool Window::stepFrame() { } updateFluidCaptures(commandBuffer); for (auto &obj : this->lateForwardRenderables) { + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, shouldRefreshPipeline(obj)); } + renderEditorOverlays(commandBuffer); + editorControlsRenderedInScenePass = true; commandBuffer->endPass(); target->resolve(); } @@ -1494,6 +1772,19 @@ bool Window::stepFrame() { } } + if (!this->renderDefaultFramebuffer) { + commandBuffer->commit(); + commandBuffer->getAndResetDrawCallCount(); + ResourceTracker::getInstance().createdResources = 0; + ResourceTracker::getInstance().loadedResources = 0; + ResourceTracker::getInstance().unloadedResources = 0; + ResourceTracker::getInstance().totalMemoryMb = 0.0f; + if (this->firstFrame) { + this->firstFrame = false; + } + return !this->shouldClose; + } + commandBuffer->beginPass(renderPass); int fbWidth, fbHeight; this->queryDrawableSizeInPixels(&fbWidth, &fbHeight); @@ -1505,7 +1796,9 @@ bool Window::stepFrame() { if (this->renderTargets.empty() && !usesModeScreenTarget) { updateBackbufferTarget(fbWidth, fbHeight); this->currentRenderTarget = this->screenRenderTarget.get(); + renderEditorGrid(commandBuffer); for (auto &obj : this->firstRenderables) { + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, @@ -1516,6 +1809,7 @@ bool Window::stepFrame() { if (obj->renderLateForward) { continue; } + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, @@ -1525,22 +1819,28 @@ bool Window::stepFrame() { updateFluidCaptures(commandBuffer); for (auto &obj : this->lateForwardRenderables) { + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, shouldRefreshPipeline(obj)); } + renderEditorOverlays(commandBuffer); + editorControlsRenderedInScenePass = true; } else { this->currentRenderTarget = nullptr; } for (auto &obj : this->preferenceRenderables) { + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(this->camera->calculateViewMatrix()); obj->setProjectionMatrix(calculateProjectionMatrix()); obj->render(getDeltaTime(), commandBuffer, shouldRefreshPipeline(obj)); } - renderEditorControls(commandBuffer); + if (!editorControlsRenderedInScenePass) { + renderEditorOverlays(commandBuffer); + } updatePipelineStateField(this->useBlending, true); @@ -1643,25 +1943,30 @@ void Window::resize(int width, int height, float scale) { const int clampedWidth = std::max(1, width); const int clampedHeight = std::max(1, height); const float clampedScale = scale > 0.0f ? scale : 1.0f; + const int pixelWidth = + std::max(1, static_cast(std::lround(clampedWidth * clampedScale))); + const int pixelHeight = std::max( + 1, static_cast(std::lround(clampedHeight * clampedScale))); + if (this->width == clampedWidth && this->height == clampedHeight && + this->viewportWidth == pixelWidth && + this->viewportHeight == pixelHeight) { + return; + } this->width = clampedWidth; this->height = clampedHeight; - if (this->windowRef != nullptr) { + if (this->windowRef != nullptr && this->showHostWindow) { SDL_SetWindowSize(this->windowRef, clampedWidth, clampedHeight); } - const int pixelWidth = - std::max(1, static_cast(std::lround(clampedWidth * clampedScale))); - const int pixelHeight = std::max( - 1, static_cast(std::lround(clampedHeight * clampedScale))); - if (device == nullptr) { return; } device->getDefaultFramebuffer()->setViewport(0, 0, pixelWidth, pixelHeight); setViewportState(0, 0, pixelWidth, pixelHeight); + this->editorGridInitialized = false; this->shadowMapsDirty = true; this->ssaoMapsDirty = true; } @@ -1672,7 +1977,13 @@ void Window::setEditorControlsEnabled(bool enabled) { if (!enabled) { selectedEditorObject = nullptr; editorDragging = false; + editorKeyboardTransform = false; + editorActiveGizmoAxis = 0; editorCameraDragging = false; + editorCameraPanning = false; + editorOrbitVelocityX = 0.0f; + editorOrbitVelocityY = 0.0f; + editorZoomVelocity = 0.0f; editorCameraKeys.fill(false); } } @@ -1680,18 +1991,217 @@ void Window::setEditorControlsEnabled(bool enabled) { void Window::setEditorSimulationEnabled(bool enabled) { editorSimulationEnabled = enabled; editorDragging = false; + editorKeyboardTransform = false; + editorActiveGizmoAxis = 0; editorCameraDragging = false; + editorCameraPanning = false; + editorOrbitVelocityX = 0.0f; + editorOrbitVelocityY = 0.0f; + editorZoomVelocity = 0.0f; } void Window::setEditorControlMode(EditorControlMode mode) { editorControlMode = mode; editorDragging = false; + editorActiveGizmoAxis = 0; +} + +bool Window::beginEditorKeyboardTransform(EditorControlMode mode, float x, + float y, float scale) { + if (!editorControlsEnabled || editorSimulationEnabled || + selectedEditorObject == nullptr || mode == EditorControlMode::None) { + return false; + } + editorControlMode = mode; + editorKeyboardTransform = true; + editorKeyboardTransformAxes = 7; + editorDragging = false; + editorActiveGizmoAxis = 0; + editorDragStartX = x; + editorDragStartY = y; + editorKeyboardLastX = x; + editorKeyboardLastY = y; + editorKeyboardAccumulatedX = 0.0f; + editorKeyboardAccumulatedY = 0.0f; + editorDragStartScale = scale > 0.0f ? scale : 1.0f; + editorDragStartPosition = selectedEditorObject->getPosition(); + editorDragStartRotation = selectedEditorObject->getRotation(); + editorDragStartObjectScale = selectedEditorObject->getScale(); + return true; +} + +bool Window::toggleEditorTransformSpace() { + editorLocalTransformSpace = !editorLocalTransformSpace; + return editorLocalTransformSpace; +} + +bool Window::toggleEditorTransformSnapping() { + editorTransformSnapping = !editorTransformSnapping; + return editorTransformSnapping; +} + +float Window::changeEditorTransformSnapIncrement(float factor) { + if (factor > 0.0f) { + editorTransformSnapIncrement = + std::clamp(editorTransformSnapIncrement * factor, 0.001f, 1000.0f); + if (std::abs(factor - 1.0f) > 0.000001f) + editorTransformSnapping = true; + } + return editorTransformSnapIncrement; +} + +void Window::setEditorKeyboardTransformAxes(int axes) { + if (!editorKeyboardTransform) + return; + editorKeyboardTransformAxes = std::clamp(axes, 1, 7); + editorActiveGizmoAxis = + editorKeyboardTransformAxes == 1 ? 1 + : editorKeyboardTransformAxes == 2 ? 2 + : editorKeyboardTransformAxes == 4 ? 3 + : 0; +} + +void Window::finishEditorKeyboardTransform(bool commit) { + if (!editorKeyboardTransform || selectedEditorObject == nullptr) + return; + if (!commit) { + const Position3d childDelta = + editorDragStartPosition - selectedEditorObject->getPosition(); + selectedEditorObject->setPosition(editorDragStartPosition); + selectedEditorObject->setRotation(editorDragStartRotation); + selectedEditorObject->setScale(editorDragStartObjectScale); + moveEditorObjectChildren(selectedEditorObject, childDelta); + shadowMapsDirty = true; + ssaoMapsDirty = true; + } + editorKeyboardTransform = false; + editorActiveGizmoAxis = 0; +} + +void Window::setEditorShadingMode(EditorShadingMode mode) { + editorShadingMode = mode; + opal::RasterizerMode nextMode = opal::RasterizerMode::Fill; + if (mode == EditorShadingMode::Wireframe) { + nextMode = opal::RasterizerMode::Line; + } else if (mode == EditorShadingMode::Points) { + nextMode = opal::RasterizerMode::Point; + } + updatePipelineStateField(rasterizerMode, nextMode); } unsigned int Window::getSelectedEditorObjectId() const { return selectedEditorObject != nullptr ? selectedEditorObject->getId() : 0; } +void Window::selectEditorObject(GameObject *object, bool focusCamera) { + selectedEditorObject = object; + editorDragging = false; + editorActiveGizmoAxis = 0; + if (!focusCamera || selectedEditorObject == nullptr || camera == nullptr) { + return; + } + + glm::vec3 boundsMin; + glm::vec3 boundsMax; + glm::vec3 center = selectedEditorObject->getPosition().toGlm(); + float radius = 1.0f; + if (editorSelectionBounds(selectedEditorObject, boundsMin, boundsMax)) { + center = (boundsMin + boundsMax) * 0.5f; + radius = std::max(0.5f, glm::length(boundsMax - boundsMin) * 0.5f); + } + + glm::vec3 offset = camera->position.toGlm() - camera->target.toGlm(); + if (glm::length(offset) < 0.000001f) { + glm::vec3 front = camera->getFrontVector().toGlm(); + offset = glm::length(front) > 0.000001f ? -glm::normalize(front) + : glm::vec3(0.0f, 0.0f, 1.0f); + } else { + offset = glm::normalize(offset); + } + + float distance = std::max(3.0f, radius * 3.2f); + camera->position = Position3d::fromGlm(center + offset * distance); + camera->lookAt(Position3d::fromGlm(center)); + editorOrbitPivot = Position3d::fromGlm(center); + editorOrbitDistance = distance; + editorOrbitPivotInitialized = true; + shadowMapsDirty = true; + ssaoMapsDirty = true; +} + +void Window::focusEditorObjects(const std::vector &objects) { + if (camera == nullptr || objects.empty()) + return; + bool found = false; + glm::vec3 boundsMin(0.0f); + glm::vec3 boundsMax(0.0f); + for (GameObject *object : objects) { + glm::vec3 objectMin; + glm::vec3 objectMax; + if (!editorSelectionBounds(object, objectMin, objectMax)) + continue; + boundsMin = found ? glm::min(boundsMin, objectMin) : objectMin; + boundsMax = found ? glm::max(boundsMax, objectMax) : objectMax; + found = true; + } + if (!found) + return; + const glm::vec3 center = (boundsMin + boundsMax) * 0.5f; + const float radius = + std::max(0.5f, glm::length(boundsMax - boundsMin) * 0.5f); + glm::vec3 offset = camera->position.toGlm() - camera->target.toGlm(); + if (glm::length(offset) < 0.000001f) + offset = glm::vec3(0.0f, 0.0f, 1.0f); + else + offset = glm::normalize(offset); + const float distance = std::max(3.0f, radius * 3.2f); + camera->position = Position3d::fromGlm(center + offset * distance); + camera->lookAt(Position3d::fromGlm(center)); + editorOrbitPivot = Position3d::fromGlm(center); + editorOrbitDistance = distance; + editorOrbitPivotInitialized = true; + shadowMapsDirty = true; + ssaoMapsDirty = true; +} + +void Window::setEditorObjectParent(GameObject *child, GameObject *parent) { + if (child == nullptr || child == parent) { + return; + } + + auto previousParent = editorObjectParents.find(child); + if (previousParent != editorObjectParents.end()) { + auto childrenIt = editorObjectChildren.find(previousParent->second); + if (childrenIt != editorObjectChildren.end()) { + childrenIt->second.erase(std::remove(childrenIt->second.begin(), + childrenIt->second.end(), + child), + childrenIt->second.end()); + } + editorObjectParents.erase(previousParent); + } + + if (parent == nullptr) { + return; + } + + GameObject *cursor = parent; + while (cursor != nullptr) { + if (cursor == child) { + return; + } + auto cursorIt = editorObjectParents.find(cursor); + cursor = + cursorIt != editorObjectParents.end() ? cursorIt->second : nullptr; + } + + editorObjectParents[child] = parent; + auto &children = editorObjectChildren[parent]; + if (std::ranges::find(children, child) == children.end()) { + children.push_back(child); + } +} + void Window::editorPointerEvent(int action, float x, float y, int button, float scale) { if (!editorControlsEnabled) { @@ -1699,15 +2209,64 @@ void Window::editorPointerEvent(int action, float x, float y, int button, } float effectiveScale = scale > 0.0f ? scale : 1.0f; - if (button == 2 || button == 3) { + if (action == 3) { + editorScrollEvent(y, effectiveScale); + return; + } + + if (editorKeyboardTransform) { + if (action == 1) + updateEditorKeyboardTransform(x, y, effectiveScale); + return; + } + + if (button == 3) { + if (action == 0) { + editorCameraPanning = true; + editorCameraLastX = x; + editorCameraLastY = y; + } else if (action == 1 && editorCameraPanning) { + updateEditorCameraPan(x, y, effectiveScale); + } else if (action == 2) { + editorCameraPanning = false; + } + return; + } + + if (button == 2) { if (action == 0) { editorCameraDragging = true; editorCameraLastX = x; editorCameraLastY = y; + glm::vec3 position = camera->position.toGlm(); + glm::vec3 target = camera->target.toGlm(); + glm::vec3 front = target - position; + float distance = glm::length(front); + if (distance < 0.000001f) { + front = camera->getFrontVector().toGlm(); + distance = std::max(editorOrbitDistance, 3.0f); + target = position + glm::normalize(front) * distance; + } + editorOrbitPivot = Position3d::fromGlm(target); + editorOrbitDistance = std::max(distance, 0.1f); + editorOrbitPivotInitialized = true; } else if (action == 1 && editorCameraDragging) { updateEditorCameraDrag(x, y, effectiveScale); } else if (action == 2) { editorCameraDragging = false; + editorOrbitVelocityX *= 0.65f; + editorOrbitVelocityY *= 0.65f; + } + return; + } + + if (action == 1 && !editorDragging && !editorCameraDragging) { + if (selectedEditorObject != nullptr && + editorControlMode != EditorControlMode::None) { + editorActiveGizmoAxis = + hitTestEditorGizmoAxis(x, y, effectiveScale); + } else { + editorActiveGizmoAxis = 0; } return; } @@ -1717,17 +2276,24 @@ void Window::editorPointerEvent(int action, float x, float y, int button, } if (action == 0) { - selectEditorObjectAt(x, y, effectiveScale); if (selectedEditorObject != nullptr && editorControlMode != EditorControlMode::None) { - editorDragging = true; - editorDragStartX = x; - editorDragStartY = y; - editorDragStartScale = effectiveScale; - editorDragStartPosition = selectedEditorObject->getPosition(); - editorDragStartRotation = selectedEditorObject->getRotation(); - editorDragStartObjectScale = selectedEditorObject->getScale(); + int axis = hitTestEditorGizmoAxis(x, y, effectiveScale); + if (axis != 0) { + editorActiveGizmoAxis = axis; + editorDragging = true; + editorDragStartX = x; + editorDragStartY = y; + editorDragStartScale = effectiveScale; + editorDragStartPosition = selectedEditorObject->getPosition(); + editorDragStartRotation = selectedEditorObject->getRotation(); + editorDragStartObjectScale = selectedEditorObject->getScale(); + return; + } } + + selectEditorObjectAt(x, y, effectiveScale); + editorActiveGizmoAxis = 0; return; } @@ -1738,9 +2304,29 @@ void Window::editorPointerEvent(int action, float x, float y, int button, if (action == 2) { editorDragging = false; + editorActiveGizmoAxis = 0; } } +void Window::editorScrollEvent(float delta, float scale) { + if (!editorControlsEnabled || camera == nullptr) { + return; + } + + float effectiveScale = scale > 0.0f ? scale : 1.0f; + float scrollAmount = std::clamp(delta / effectiveScale, -20.0f, 20.0f); + + scrollAmount *= EDITOR_ZOOM_SENSITIVITY; + + if (std::abs(scrollAmount) < 0.0001f) { + return; + } + + applyEditorZoomDelta(scrollAmount); + editorZoomVelocity += scrollAmount * 0.01f; + editorZoomVelocity = std::clamp(editorZoomVelocity, -80.0f, 80.0f); +} + void Window::editorKeyEvent(int key, bool pressed) { if (key < 0 || key >= static_cast(editorCameraKeys.size())) { return; @@ -1805,36 +2391,429 @@ void Window::selectEditorObjectAt(float x, float y, float scale) { selectedEditorObject = bestObject; } +int Window::hitTestEditorGizmoAxis(float x, float y, float scale) { + if (selectedEditorObject == nullptr || camera == nullptr || + editorControlMode == EditorControlMode::None) { + return 0; + } + + glm::vec3 boundsMin; + glm::vec3 boundsMax; + if (!editorSelectionBounds(selectedEditorObject, boundsMin, boundsMax)) { + return 0; + } + + float effectiveScale = scale > 0.0f ? scale : 1.0f; + float viewWidth = std::max(1.0f, static_cast(width)); + float viewHeight = std::max(1.0f, static_cast(height)); + glm::mat4 viewProjection = + calculateProjectionMatrix() * camera->calculateViewMatrix(); + glm::vec2 pointer(x, y); + glm::vec3 center = (boundsMin + boundsMax) * 0.5f; + float cameraDistance = glm::length(camera->position.toGlm() - center); + float gizmoScale = std::max(1.2f, cameraDistance * 0.16f); + float axisLength = gizmoScale * 1.35f; + float hitPadding = std::max(28.0f, 40.0f / effectiveScale); + int bestAxis = 0; + float bestDistance = hitPadding; + + if (editorControlMode == EditorControlMode::Move || + editorControlMode == EditorControlMode::Scale) { + for (int axis = 1; axis <= 3; ++axis) { + glm::vec3 axisVector = + editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, axis) + : editorAxisVector(axis); + glm::vec2 from; + glm::vec2 to; + float depth = 0.0f; + if (!projectPointToScreen(center, viewProjection, viewWidth, + viewHeight, from, depth) || + !projectPointToScreen(center + axisVector * axisLength, + viewProjection, viewWidth, viewHeight, to, + depth)) { + continue; + } + float distance = distanceToScreenSegment(pointer, from, to); + distance = std::min(distance, distanceToScreenPoint(pointer, to)); + if (distance < bestDistance) { + bestDistance = distance; + bestAxis = axis; + } + } + return bestAxis; + } + + int segments = 128; + float ringRadius = gizmoScale * 1.12f; + for (int axis = 1; axis <= 3; ++axis) { + for (int i = 0; i < segments; ++i) { + float a0 = + (static_cast(i) / segments) * glm::two_pi(); + float a1 = + (static_cast(i + 1) / segments) * glm::two_pi(); + glm::vec3 p0; + glm::vec3 p1; + if (axis == 1) { + p0 = center + + glm::vec3(0, std::cos(a0), std::sin(a0)) * ringRadius; + p1 = center + + glm::vec3(0, std::cos(a1), std::sin(a1)) * ringRadius; + } else if (axis == 2) { + p0 = center + + glm::vec3(std::cos(a0), 0, std::sin(a0)) * ringRadius; + p1 = center + + glm::vec3(std::cos(a1), 0, std::sin(a1)) * ringRadius; + } else { + p0 = center + + glm::vec3(std::cos(a0), std::sin(a0), 0) * ringRadius; + p1 = center + + glm::vec3(std::cos(a1), std::sin(a1), 0) * ringRadius; + } + if (editorLocalTransformSpace) { + const glm::quat rotation = + selectedEditorObject->getRotation().toGlmQuat(); + p0 = center + rotation * (p0 - center); + p1 = center + rotation * (p1 - center); + } + + glm::vec2 from; + glm::vec2 to; + float depth = 0.0f; + if (!projectPointToScreen(p0, viewProjection, viewWidth, viewHeight, + from, depth) || + !projectPointToScreen(p1, viewProjection, viewWidth, viewHeight, + to, depth)) { + continue; + } + float distance = distanceToScreenSegment(pointer, from, to); + if (distance < bestDistance) { + bestDistance = distance; + bestAxis = axis; + } + } + } + + return bestAxis; +} + +bool Window::editorSelectionBounds(GameObject *object, glm::vec3 &boundsMin, + glm::vec3 &boundsMax) { + if (object == nullptr) { + return false; + } + + bool any = false; + glm::vec3 objectMin; + glm::vec3 objectMax; + if (objectBounds(object, objectMin, objectMax)) { + boundsMin = objectMin; + boundsMax = objectMax; + any = true; + } + + auto childrenIt = editorObjectChildren.find(object); + if (childrenIt != editorObjectChildren.end()) { + for (GameObject *child : childrenIt->second) { + glm::vec3 childMin; + glm::vec3 childMax; + if (!editorSelectionBounds(child, childMin, childMax)) { + continue; + } + boundsMin = any ? glm::min(boundsMin, childMin) : childMin; + boundsMax = any ? glm::max(boundsMax, childMax) : childMax; + any = true; + } + } + + return any; +} + +void Window::moveEditorObjectChildren(GameObject *object, + const Position3d &deltaPosition) { + auto childrenIt = editorObjectChildren.find(object); + if (childrenIt == editorObjectChildren.end()) { + return; + } + + for (GameObject *child : childrenIt->second) { + if (child == nullptr) { + continue; + } + child->move(deltaPosition); + moveEditorObjectChildren(child, deltaPosition); + } +} + void Window::updateEditorDrag(float x, float y, float scale) { - if (selectedEditorObject == nullptr || camera == nullptr) { + if (selectedEditorObject == nullptr || camera == nullptr || + editorActiveGizmoAxis == 0) { return; } float effectiveScale = scale > 0.0f ? scale : editorDragStartScale; float dx = (x - editorDragStartX) / effectiveScale; float dy = (y - editorDragStartY) / effectiveScale; - float distance = glm::length(selectedEditorObject->getPosition().toGlm() - - camera->position.toGlm()); + glm::vec3 axis = + editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, editorActiveGizmoAxis) + : editorAxisVector(editorActiveGizmoAxis); + if (glm::length(axis) < 0.000001f) { + return; + } if (editorControlMode == EditorControlMode::Move) { - glm::mat4 inverseView = glm::inverse(camera->calculateViewMatrix()); - glm::vec3 right = glm::normalize(glm::vec3(inverseView[0])); - glm::vec3 up = glm::normalize(glm::vec3(inverseView[1])); - float factor = std::max(0.01f, distance * 0.0025f); - glm::vec3 delta = right * (dx * factor) + up * (dy * factor); - selectedEditorObject->setPosition( - Position3d::fromGlm(editorDragStartPosition.toGlm() + delta)); + float viewWidth = std::max(1.0f, static_cast(width)); + float viewHeight = std::max(1.0f, static_cast(height)); + glm::mat4 viewProjection = + calculateProjectionMatrix() * camera->calculateViewMatrix(); + glm::vec3 center = editorDragStartPosition.toGlm(); + glm::vec2 centerScreen; + glm::vec2 axisScreen; + float depth = 0.0f; + float worldDelta = 0.0f; + if (projectPointToScreen(center, viewProjection, viewWidth, viewHeight, + centerScreen, depth) && + projectPointToScreen(center + axis, viewProjection, viewWidth, + viewHeight, axisScreen, depth)) { + glm::vec2 projectedAxis = axisScreen - centerScreen; + float pixelsPerWorld = glm::length(projectedAxis); + if (pixelsPerWorld > 0.000001f) { + glm::vec2 direction = projectedAxis / pixelsPerWorld; + worldDelta = + glm::dot(glm::vec2(dx, dy), direction) / pixelsPerWorld; + } + } + if (std::abs(worldDelta) < 0.000001f) { + float distance = glm::length(editorDragStartPosition.toGlm() - + camera->position.toGlm()); + worldDelta = (dx + dy) * std::max(0.01f, distance * 0.0025f); + } + glm::vec3 delta = axis * worldDelta; + glm::vec3 nextVector = editorDragStartPosition.toGlm() + delta; + if (editorTransformSnapping) { + nextVector = glm::round(nextVector / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + } + Position3d nextPosition = Position3d::fromGlm(nextVector); + Position3d childDelta = + nextPosition - selectedEditorObject->getPosition(); + selectedEditorObject->setPosition(nextPosition); + moveEditorObjectChildren(selectedEditorObject, childDelta); } else if (editorControlMode == EditorControlMode::Rotate) { - selectedEditorObject->setRotation( - Rotation3d(editorDragStartRotation.pitch + dy * 0.25f, - editorDragStartRotation.yaw + dx * 0.25f, - editorDragStartRotation.roll)); + float viewWidth = std::max(1.0f, static_cast(width)); + float viewHeight = std::max(1.0f, static_cast(height)); + glm::mat4 viewProjection = + calculateProjectionMatrix() * camera->calculateViewMatrix(); + glm::vec2 centerScreen; + float depth = 0.0f; + float angle = 0.0f; + if (projectPointToScreen(editorDragStartPosition.toGlm(), + viewProjection, viewWidth, viewHeight, + centerScreen, depth)) { + glm::vec2 startVector = + glm::vec2(editorDragStartX, editorDragStartY) - centerScreen; + glm::vec2 currentVector = glm::vec2(x, y) - centerScreen; + if (glm::length(startVector) > 0.000001f && + glm::length(currentVector) > 0.000001f) { + startVector = glm::normalize(startVector); + currentVector = glm::normalize(currentVector); + float cross = startVector.x * currentVector.y - + startVector.y * currentVector.x; + float dot = glm::clamp(glm::dot(startVector, currentVector), + -1.0f, 1.0f); + angle = glm::degrees(std::atan2(cross, dot)); + } + } + if (std::abs(angle) < 0.000001f) { + angle = (dx - dy) * 0.25f; + } + if (editorTransformSnapping) + angle = std::round(angle / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + Rotation3d rotation = editorDragStartRotation; + if (editorActiveGizmoAxis == 1) { + rotation.pitch = editorDragStartRotation.pitch + angle; + } else if (editorActiveGizmoAxis == 2) { + rotation.yaw = editorDragStartRotation.yaw + angle; + } else if (editorActiveGizmoAxis == 3) { + rotation.roll = editorDragStartRotation.roll + angle; + } + selectedEditorObject->setRotation(rotation); } else if (editorControlMode == EditorControlMode::Scale) { - float factor = std::max(0.05f, 1.0f + (dx + dy) * 0.01f); - selectedEditorObject->setScale( - Position3d(editorDragStartObjectScale.x * factor, - editorDragStartObjectScale.y * factor, - editorDragStartObjectScale.z * factor)); + float viewWidth = std::max(1.0f, static_cast(width)); + float viewHeight = std::max(1.0f, static_cast(height)); + glm::mat4 viewProjection = + calculateProjectionMatrix() * camera->calculateViewMatrix(); + glm::vec3 center = editorDragStartPosition.toGlm(); + glm::vec2 centerScreen; + glm::vec2 axisScreen; + float depth = 0.0f; + float scaleDelta = 0.0f; + if (projectPointToScreen(center, viewProjection, viewWidth, viewHeight, + centerScreen, depth) && + projectPointToScreen(center + axis, viewProjection, viewWidth, + viewHeight, axisScreen, depth)) { + glm::vec2 projectedAxis = axisScreen - centerScreen; + float pixelsPerWorld = glm::length(projectedAxis); + if (pixelsPerWorld > 0.000001f) { + glm::vec2 direction = projectedAxis / pixelsPerWorld; + scaleDelta = + glm::dot(glm::vec2(dx, dy), direction) / pixelsPerWorld; + } + } + if (std::abs(scaleDelta) < 0.000001f) { + scaleDelta = (dx + dy) * 0.01f; + } + Scale3d nextScale = editorDragStartObjectScale; + if (editorActiveGizmoAxis == 1) { + nextScale.x = + std::max(0.05f, editorDragStartObjectScale.x + scaleDelta); + } else if (editorActiveGizmoAxis == 2) { + nextScale.y = + std::max(0.05f, editorDragStartObjectScale.y + scaleDelta); + } else if (editorActiveGizmoAxis == 3) { + nextScale.z = + std::max(0.05f, editorDragStartObjectScale.z + scaleDelta); + } + if (editorTransformSnapping) { + nextScale.x = std::round(nextScale.x / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + nextScale.y = std::round(nextScale.y / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + nextScale.z = std::round(nextScale.z / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + nextScale.x = std::max(0.001f, nextScale.x); + nextScale.y = std::max(0.001f, nextScale.y); + nextScale.z = std::max(0.001f, nextScale.z); + } + selectedEditorObject->setScale(nextScale); + } + shadowMapsDirty = true; + ssaoMapsDirty = true; +} + +void Window::updateEditorKeyboardTransform(float x, float y, float scale) { + if (!editorKeyboardTransform || selectedEditorObject == nullptr || + camera == nullptr) + return; + + const float effectiveScale = scale > 0.0f ? scale : editorDragStartScale; + float stepX = x - editorKeyboardLastX; + float stepY = y - editorKeyboardLastY; + const float wrapWidth = std::max(1.0f, static_cast(width)); + const float wrapHeight = std::max(1.0f, static_cast(height)); + if (stepX > wrapWidth * 0.5f) + stepX -= wrapWidth; + else if (stepX < -wrapWidth * 0.5f) + stepX += wrapWidth; + if (stepY > wrapHeight * 0.5f) + stepY -= wrapHeight; + else if (stepY < -wrapHeight * 0.5f) + stepY += wrapHeight; + editorKeyboardAccumulatedX += stepX; + editorKeyboardAccumulatedY += stepY; + editorKeyboardLastX = x; + editorKeyboardLastY = y; + const float dx = editorKeyboardAccumulatedX / effectiveScale; + const float dy = editorKeyboardAccumulatedY / effectiveScale; + const int axes = editorKeyboardTransformAxes; + const float distance = glm::length(editorDragStartPosition.toGlm() - + camera->position.toGlm()); + + if (editorControlMode == EditorControlMode::Move) { + glm::vec3 delta(0.0f); + if (axes == 7) { + glm::vec3 front = glm::normalize(camera->target.toGlm() - + camera->position.toGlm()); + glm::vec3 right = glm::cross(front, glm::vec3(0.0f, 1.0f, 0.0f)); + if (glm::length(right) < 0.000001f) + right = glm::vec3(1.0f, 0.0f, 0.0f); + else + right = glm::normalize(right); + const glm::vec3 up = glm::normalize(glm::cross(right, front)); + const float sensitivity = std::max(0.0025f, distance * 0.0025f); + delta = (right * dx + up * dy) * sensitivity; + } else { + const float viewWidth = + std::max(1.0f, static_cast(width)); + const float viewHeight = + std::max(1.0f, static_cast(height)); + const glm::mat4 viewProjection = + calculateProjectionMatrix() * camera->calculateViewMatrix(); + const glm::vec3 center = editorDragStartPosition.toGlm(); + for (int axisIndex = 0; axisIndex < 3; ++axisIndex) { + if ((axes & (1 << axisIndex)) == 0) + continue; + const glm::vec3 axis = + editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, + axisIndex + 1) + : editorAxisVector(axisIndex + 1); + glm::vec2 centerScreen; + glm::vec2 axisScreen; + float depth = 0.0f; + if (!projectPointToScreen(center, viewProjection, viewWidth, + viewHeight, centerScreen, depth) || + !projectPointToScreen(center + axis, viewProjection, + viewWidth, viewHeight, axisScreen, + depth)) { + continue; + } + const glm::vec2 projected = axisScreen - centerScreen; + const float pixels = glm::length(projected); + if (pixels > 0.000001f) { + const float amount = + glm::dot(glm::vec2(dx, dy), projected / pixels) / + pixels; + delta += axis * amount; + } + } + } + glm::vec3 nextVector = editorDragStartPosition.toGlm() + delta; + if (editorTransformSnapping) { + nextVector = glm::round(nextVector / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + } + const Position3d next = Position3d::fromGlm(nextVector); + const Position3d childDelta = + next - selectedEditorObject->getPosition(); + selectedEditorObject->setPosition(next); + moveEditorObjectChildren(selectedEditorObject, childDelta); + } else if (editorControlMode == EditorControlMode::Scale) { + const float amount = (dx - dy) * 0.01f; + Scale3d next = editorDragStartObjectScale; + if ((axes & 1) != 0) + next.x = std::max(0.05f, next.x + amount); + if ((axes & 2) != 0) + next.y = std::max(0.05f, next.y + amount); + if ((axes & 4) != 0) + next.z = std::max(0.05f, next.z + amount); + if (editorTransformSnapping) { + next.x = std::round(next.x / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + next.y = std::round(next.y / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + next.z = std::round(next.z / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + next.x = std::max(0.001f, next.x); + next.y = std::max(0.001f, next.y); + next.z = std::max(0.001f, next.z); + } + selectedEditorObject->setScale(next); + } else if (editorControlMode == EditorControlMode::Rotate) { + float angle = (dx - dy) * 0.25f; + if (editorTransformSnapping) + angle = std::round(angle / editorTransformSnapIncrement) * + editorTransformSnapIncrement; + Rotation3d next = editorDragStartRotation; + if ((axes & 1) != 0) + next.pitch += angle; + if ((axes & 2) != 0) + next.yaw += angle; + if ((axes & 4) != 0) + next.roll += angle; + selectedEditorObject->setRotation(next); } shadowMapsDirty = true; ssaoMapsDirty = true; @@ -1851,26 +2830,117 @@ void Window::updateEditorCameraDrag(float x, float y, float scale) { editorCameraLastX = x; editorCameraLastY = y; + float yawDelta = dx * 0.22f; + float pitchDelta = -dy * 0.22f; + applyEditorOrbitDelta(yawDelta, pitchDelta); + editorOrbitVelocityX = yawDelta * 45.0f; + editorOrbitVelocityY = pitchDelta * 45.0f; +} + +void Window::updateEditorCameraPan(float x, float y, float scale) { + if (camera == nullptr) + return; + const float effectiveScale = scale > 0.0f ? scale : 1.0f; + const float dx = (x - editorCameraLastX) / effectiveScale; + const float dy = (y - editorCameraLastY) / effectiveScale; + editorCameraLastX = x; + editorCameraLastY = y; glm::vec3 position = camera->position.toGlm(); glm::vec3 target = camera->target.toGlm(); glm::vec3 front = target - position; - if (glm::length(front) < 0.000001f) { + if (glm::length(front) < 0.000001f) front = camera->getFrontVector().toGlm(); - } front = glm::normalize(front); + glm::vec3 right = glm::cross(front, glm::vec3(0.0f, 1.0f, 0.0f)); + if (glm::length(right) < 0.000001f) + right = glm::vec3(1.0f, 0.0f, 0.0f); + else + right = glm::normalize(right); + const glm::vec3 up = glm::normalize(glm::cross(right, front)); + const float distance = std::max(0.1f, glm::length(target - position)); + const float sensitivity = distance * 0.0018f; + const glm::vec3 movement = + -right * dx * sensitivity - up * dy * sensitivity; + camera->position = Position3d::fromGlm(position + movement); + camera->target = Position3d::fromGlm(target + movement); + if (editorOrbitPivotInitialized) + editorOrbitPivot = + Position3d::fromGlm(editorOrbitPivot.toGlm() + movement); + shadowMapsDirty = true; + ssaoMapsDirty = true; +} + +void Window::applyEditorOrbitDelta(float yawDelta, float pitchDelta) { + if (camera == nullptr || + (std::abs(yawDelta) < 0.000001f && std::abs(pitchDelta) < 0.000001f)) { + return; + } - float yaw = glm::degrees(std::atan2(front.z, front.x)); - float pitch = glm::degrees(std::asin(glm::clamp(front.y, -1.0f, 1.0f))); - yaw += dx * 0.22f; - pitch += dy * 0.22f; + glm::vec3 position = camera->position.toGlm(); + glm::vec3 pivot = editorOrbitPivotInitialized ? editorOrbitPivot.toGlm() + : camera->target.toGlm(); + glm::vec3 offset = position - pivot; + float radius = glm::length(offset); + if (radius < 0.000001f) { + glm::vec3 front = camera->getFrontVector().toGlm(); + if (glm::length(front) < 0.000001f) { + front = glm::vec3(0.0f, 0.0f, -1.0f); + } + radius = std::max(editorOrbitDistance, 3.0f); + offset = -glm::normalize(front) * radius; + } + + glm::vec3 direction = glm::normalize(offset); + float yaw = glm::degrees(std::atan2(direction.z, direction.x)); + float pitch = glm::degrees(std::asin(glm::clamp(direction.y, -1.0f, 1.0f))); + yaw += yawDelta; + pitch += pitchDelta; pitch = std::clamp(pitch, -89.0f, 89.0f); - glm::vec3 nextFront; - nextFront.x = std::cos(glm::radians(yaw)) * std::cos(glm::radians(pitch)); - nextFront.y = std::sin(glm::radians(pitch)); - nextFront.z = std::sin(glm::radians(yaw)) * std::cos(glm::radians(pitch)); - nextFront = glm::normalize(nextFront); - camera->lookAt(Position3d::fromGlm(position + nextFront)); + glm::vec3 nextOffset; + nextOffset.x = std::cos(glm::radians(yaw)) * std::cos(glm::radians(pitch)); + nextOffset.y = std::sin(glm::radians(pitch)); + nextOffset.z = std::sin(glm::radians(yaw)) * std::cos(glm::radians(pitch)); + nextOffset = glm::normalize(nextOffset) * radius; + camera->position = Position3d::fromGlm(pivot + nextOffset); + camera->lookAt(Position3d::fromGlm(pivot)); + editorOrbitPivot = Position3d::fromGlm(pivot); + editorOrbitDistance = radius; + editorOrbitPivotInitialized = true; + shadowMapsDirty = true; + ssaoMapsDirty = true; +} + +void Window::applyEditorZoomDelta(float scrollAmount) { + if (camera == nullptr || std::abs(scrollAmount) < 0.000001f) { + return; + } + + glm::vec3 position = camera->position.toGlm(); + glm::vec3 target = camera->target.toGlm(); + glm::vec3 pivot = + editorOrbitPivotInitialized ? editorOrbitPivot.toGlm() : target; + glm::vec3 toPivot = pivot - position; + float distance = glm::length(toPivot); + if (distance < 0.000001f) { + glm::vec3 front = camera->getFrontVector().toGlm(); + if (glm::length(front) < 0.000001f) { + return; + } + toPivot = glm::normalize(front); + distance = std::max(editorOrbitDistance, 3.0f); + pivot = position + toPivot * distance; + } else { + toPivot = glm::normalize(toPivot); + } + + float zoomFactor = std::pow(0.9f, scrollAmount); + float nextDistance = std::clamp(distance * zoomFactor, 0.2f, 1000.0f); + camera->position = Position3d::fromGlm(pivot - toPivot * nextDistance); + camera->lookAt(Position3d::fromGlm(pivot)); + editorOrbitPivot = Position3d::fromGlm(pivot); + editorOrbitDistance = nextDistance; + editorOrbitPivotInitialized = true; shadowMapsDirty = true; ssaoMapsDirty = true; } @@ -1918,52 +2988,112 @@ void Window::updateEditorCameraMovement(float deltaTime) { std::max(deltaTime, 1.0f / 120.0f); camera->position = Position3d::fromGlm(position + movement); camera->target = Position3d::fromGlm(target + movement); + if (editorOrbitPivotInitialized) { + editorOrbitPivot = + Position3d::fromGlm(editorOrbitPivot.toGlm() + movement); + } shadowMapsDirty = true; ssaoMapsDirty = true; } +void Window::updateEditorCameraInertia(float deltaTime) { + if (camera == nullptr) { + return; + } + + float dt = std::clamp(deltaTime, 1.0f / 240.0f, 1.0f / 30.0f); + if (!editorCameraDragging) { + if (std::abs(editorOrbitVelocityX) > 0.0001f || + std::abs(editorOrbitVelocityY) > 0.0001f) { + applyEditorOrbitDelta(editorOrbitVelocityX * dt, + editorOrbitVelocityY * dt); + float damping = std::pow(0.04f, dt); + editorOrbitVelocityX *= damping; + editorOrbitVelocityY *= damping; + if (std::abs(editorOrbitVelocityX) < 0.001f) { + editorOrbitVelocityX = 0.0f; + } + if (std::abs(editorOrbitVelocityY) < 0.001f) { + editorOrbitVelocityY = 0.0f; + } + } + } + + if (std::abs(editorZoomVelocity) > 0.0001f) { + applyEditorZoomDelta(editorZoomVelocity * dt); + float damping = std::pow(0.025f, dt); + editorZoomVelocity *= damping; + if (std::abs(editorZoomVelocity) < 0.001f) { + editorZoomVelocity = 0.0f; + } + } +} + void Window::updateEditorControlGeometry() { if (!editorControlsEnabled || camera == nullptr) { return; } std::vector gridVertices; - float step = 1.0f; - int viewportPixelSpan = - std::max(1, std::max(viewportWidth, viewportHeight)); - float viewportScale = - std::max(1.0f, static_cast(viewportPixelSpan) / 720.0f); - float cameraScale = std::max(1.0f, std::abs(camera->position.y) * 0.35f); - int halfLines = - std::clamp(static_cast( - std::ceil(20.0f * std::max(viewportScale, cameraScale))), - 20, 220); - gridVertices.reserve(static_cast((halfLines * 2 + 1) * 4)); - float centerX = std::floor(camera->position.x / step) * step; - float centerZ = std::floor(camera->position.z / step) * step; + constexpr float step = 1.0f; + int fbWidth = 1; + int fbHeight = 1; + queryDrawableSizeInPixels(&fbWidth, &fbHeight); + fbWidth = std::max(1, fbWidth); + fbHeight = std::max(1, fbHeight); + + float aspect = static_cast(fbWidth) / static_cast(fbHeight); + float pitchFactor = 1.0f; + glm::vec3 cameraDirection = + camera->target.toGlm() - camera->position.toGlm(); + if (glm::length(cameraDirection) > 0.000001f) { + glm::vec3 front = glm::normalize(cameraDirection); + pitchFactor = std::clamp(1.0f - std::abs(front.y), 0.2f, 1.0f); + } + float baseDistance = std::max( + 6.0f, + glm::length(camera->target.toGlm() - camera->position.toGlm()) * 1.1f); + float frustumHalfHeight = + std::tan(glm::radians(camera->fov) * 0.5f) * baseDistance; + float frustumHalfWidth = frustumHalfHeight * aspect; + float extent = std::max(250.0f, (frustumHalfWidth + frustumHalfHeight) * + (10.0f + (1.0f - pitchFactor) * 4.0f)); + + float minX = std::floor((camera->position.x - extent) / step) * step; + float maxX = std::ceil((camera->position.x + extent) / step) * step; + float minZ = std::floor((camera->position.z - extent) / step) * step; + float maxZ = std::ceil((camera->position.z + extent) / step) * step; + + int xLineCount = std::clamp( + static_cast(std::round((maxX - minX) / step)) + 1, 300, 2400); + int zLineCount = std::clamp( + static_cast(std::round((maxZ - minZ) / step)) + 1, 300, 2400); + float clampedMaxX = minX + (xLineCount - 1) * step; + float clampedMaxZ = minZ + (zLineCount - 1) * step; + gridVertices.reserve( + static_cast((xLineCount + zLineCount) * 2)); + Color minor{0.16f, 0.22f, 0.28f, 0.42f}; Color major{0.28f, 0.38f, 0.48f, 0.62f}; Color axisX{0.95f, 0.2f, 0.18f, 0.7f}; Color axisZ{0.2f, 0.46f, 1.0f, 0.7f}; - float extent = halfLines * step; - for (int i = -halfLines; i <= halfLines; ++i) { - float offset = i * step; - float x = centerX + offset; - float z = centerZ + offset; - Color xColor = std::abs(std::round(x)) < 0.001f - ? axisZ - : (i % 5 == 0 ? major : minor); - Color zColor = std::abs(std::round(z)) < 0.001f - ? axisX - : (i % 5 == 0 ? major : minor); - gridVertices.push_back( - editorVertex(glm::vec3(x, 0.0f, centerZ - extent), xColor)); - gridVertices.push_back( - editorVertex(glm::vec3(x, 0.0f, centerZ + extent), xColor)); - gridVertices.push_back( - editorVertex(glm::vec3(centerX - extent, 0.0f, z), zColor)); - gridVertices.push_back( - editorVertex(glm::vec3(centerX + extent, 0.0f, z), zColor)); + + for (int i = 0; i < xLineCount; ++i) { + float x = minX + static_cast(i) * step; + int xIndex = static_cast(std::llround(x / step)); + Color xColor = + std::abs(x) < 0.001f ? axisZ : (xIndex % 5 == 0 ? major : minor); + appendEditorLine(gridVertices, glm::vec3(x, 0.0f, minZ), + glm::vec3(x, 0.0f, clampedMaxZ), xColor); + } + + for (int i = 0; i < zLineCount; ++i) { + float z = minZ + static_cast(i) * step; + int zIndex = static_cast(std::llround(z / step)); + Color zColor = + std::abs(z) < 0.001f ? axisX : (zIndex % 5 == 0 ? major : minor); + appendEditorLine(gridVertices, glm::vec3(minX, 0.0f, z), + glm::vec3(clampedMaxX, 0.0f, z), zColor); } ensureEditorLineObject(editorGridObject, editorGridInitialized, gridVertices); @@ -1974,35 +3104,61 @@ void Window::updateEditorControlGeometry() { glm::vec3 boundsMin; glm::vec3 boundsMax; - if (!objectBounds(selectedEditorObject, boundsMin, boundsMax)) { + if (!editorSelectionBounds(selectedEditorObject, boundsMin, boundsMax)) { selectedEditorObject = nullptr; editorDragging = false; + editorActiveGizmoAxis = 0; return; } + glm::vec3 cameraPosition = camera->position.toGlm(); Color outlineColor{0.0f, 0.95f, 1.0f, 1.0f}; - glm::vec3 p000(boundsMin.x, boundsMin.y, boundsMin.z); - glm::vec3 p001(boundsMin.x, boundsMin.y, boundsMax.z); - glm::vec3 p010(boundsMin.x, boundsMax.y, boundsMin.z); - glm::vec3 p011(boundsMin.x, boundsMax.y, boundsMax.z); - glm::vec3 p100(boundsMax.x, boundsMin.y, boundsMin.z); - glm::vec3 p101(boundsMax.x, boundsMin.y, boundsMax.z); - glm::vec3 p110(boundsMax.x, boundsMax.y, boundsMin.z); - glm::vec3 p111(boundsMax.x, boundsMax.y, boundsMax.z); - std::vector outlineVertices = { - editorVertex(p000, outlineColor), editorVertex(p100, outlineColor), - editorVertex(p100, outlineColor), editorVertex(p101, outlineColor), - editorVertex(p101, outlineColor), editorVertex(p001, outlineColor), - editorVertex(p001, outlineColor), editorVertex(p000, outlineColor), - editorVertex(p010, outlineColor), editorVertex(p110, outlineColor), - editorVertex(p110, outlineColor), editorVertex(p111, outlineColor), - editorVertex(p111, outlineColor), editorVertex(p011, outlineColor), - editorVertex(p011, outlineColor), editorVertex(p010, outlineColor), - editorVertex(p000, outlineColor), editorVertex(p010, outlineColor), - editorVertex(p100, outlineColor), editorVertex(p110, outlineColor), - editorVertex(p101, outlineColor), editorVertex(p111, outlineColor), - editorVertex(p001, outlineColor), editorVertex(p011, outlineColor), - }; + glm::vec3 boundsCenter = (boundsMin + boundsMax) * 0.5f; + float outlinePixelSize = worldUnitsPerScreenPixel( + camera, boundsCenter, static_cast(fbHeight)); + float outlinePadding = outlinePixelSize * 5.0f; + float outlineThickness = outlinePixelSize * 1.5f; + glm::vec3 outlinePaddingVector(outlinePadding); + std::array outlineCorners = boundsCorners( + boundsMin - outlinePaddingVector, boundsMax + outlinePaddingVector); + const glm::vec3 &p000 = outlineCorners[0]; + const glm::vec3 &p001 = outlineCorners[1]; + const glm::vec3 &p010 = outlineCorners[2]; + const glm::vec3 &p011 = outlineCorners[3]; + const glm::vec3 &p100 = outlineCorners[4]; + const glm::vec3 &p101 = outlineCorners[5]; + const glm::vec3 &p110 = outlineCorners[6]; + const glm::vec3 &p111 = outlineCorners[7]; + std::vector outlineVertices; + outlineVertices.reserve(360); + appendRibbonLine(outlineVertices, p000, p100, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p100, p101, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p101, p001, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p001, p000, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p010, p110, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p110, p111, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p111, p011, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p011, p010, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p000, p010, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p100, p110, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p101, p111, outlineThickness, + cameraPosition, outlineColor); + appendRibbonLine(outlineVertices, p001, p011, outlineThickness, + cameraPosition, outlineColor); + float cornerHalfSize = outlineThickness * 0.95f; + for (const auto &corner : outlineCorners) { + appendSolidCube(outlineVertices, corner, cornerHalfSize, outlineColor); + } ensureEditorLineObject(editorOutlineObject, editorOutlineInitialized, outlineVertices); @@ -2011,53 +3167,83 @@ void Window::updateEditorControlGeometry() { } std::vector gizmoVertices; - gizmoVertices.reserve(1024); + gizmoVertices.reserve(4096); glm::vec3 center = selectedEditorObject->getPosition().toGlm(); - float radius = std::max(0.75f, glm::length(boundsMax - boundsMin) * 0.45f); - float axisLength = radius * 1.15f; - float squareHalfSize = std::max(0.08f, radius * 0.12f); - float arrowSize = std::max(0.08f, radius * 0.12f); + float cameraDistance = glm::length(cameraPosition - center); + float gizmoScale = std::max(1.2f, cameraDistance * 0.16f); + float axisLength = gizmoScale * 1.35f; + float gizmoThickness = std::max(0.018f, gizmoScale * 0.018f); + float squareHalfSize = std::max(0.08f, gizmoScale * 0.07f); + float arrowSize = std::max(0.18f, gizmoScale * 0.17f); Color red{1.0f, 0.1f, 0.08f, 1.0f}; Color green{0.25f, 1.0f, 0.35f, 1.0f}; Color blue{0.2f, 0.48f, 1.0f, 1.0f}; + Color active{1.0f, 0.86f, 0.08f, 1.0f}; if (editorControlMode == EditorControlMode::Move) { - glm::vec3 xAxis(1.0f, 0.0f, 0.0f); - glm::vec3 yAxis(0.0f, 1.0f, 0.0f); - glm::vec3 zAxis(0.0f, 0.0f, 1.0f); + glm::vec3 xAxis = editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, 1) + : editorAxisVector(1); + glm::vec3 yAxis = editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, 2) + : editorAxisVector(2); + glm::vec3 zAxis = editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, 3) + : editorAxisVector(3); glm::vec3 xTip = center + xAxis * axisLength; glm::vec3 yTip = center + yAxis * axisLength; glm::vec3 zTip = center + zAxis * axisLength; + Color xColor = editorActiveGizmoAxis == 1 ? active : red; + Color yColor = editorActiveGizmoAxis == 2 ? active : green; + Color zColor = editorActiveGizmoAxis == 3 ? active : blue; - appendEditorLine(gizmoVertices, center, xTip, red); - appendSquareCap(gizmoVertices, xTip, xAxis, squareHalfSize, red); + appendRibbonLine(gizmoVertices, center, xTip, gizmoThickness, + cameraPosition, xColor); + appendArrowHead(gizmoVertices, xTip, xAxis, arrowSize, xColor); - appendEditorLine(gizmoVertices, center, yTip, green); - appendSquareCap(gizmoVertices, yTip, yAxis, squareHalfSize, green); + appendRibbonLine(gizmoVertices, center, yTip, gizmoThickness, + cameraPosition, yColor); + appendArrowHead(gizmoVertices, yTip, yAxis, arrowSize, yColor); - appendEditorLine(gizmoVertices, center, zTip, blue); - appendSquareCap(gizmoVertices, zTip, zAxis, squareHalfSize, blue); + appendRibbonLine(gizmoVertices, center, zTip, gizmoThickness, + cameraPosition, zColor); + appendArrowHead(gizmoVertices, zTip, zAxis, arrowSize, zColor); } else if (editorControlMode == EditorControlMode::Scale) { - glm::vec3 xAxis(1.0f, 0.0f, 0.0f); - glm::vec3 yAxis(0.0f, 1.0f, 0.0f); - glm::vec3 zAxis(0.0f, 0.0f, 1.0f); + glm::vec3 xAxis = editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, 1) + : editorAxisVector(1); + glm::vec3 yAxis = editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, 2) + : editorAxisVector(2); + glm::vec3 zAxis = editorLocalTransformSpace + ? editorScaleAxisVector(selectedEditorObject, 3) + : editorAxisVector(3); glm::vec3 xTip = center + xAxis * axisLength; glm::vec3 yTip = center + yAxis * axisLength; glm::vec3 zTip = center + zAxis * axisLength; + Color xColor = editorActiveGizmoAxis == 1 ? active : red; + Color yColor = editorActiveGizmoAxis == 2 ? active : green; + Color zColor = editorActiveGizmoAxis == 3 ? active : blue; - appendEditorLine(gizmoVertices, center, xTip, red); - appendArrowHead(gizmoVertices, xTip, xAxis, arrowSize, red); + appendRibbonLine(gizmoVertices, center, xTip, gizmoThickness, + cameraPosition, xColor); + appendSolidCubeCap(gizmoVertices, xTip, xAxis, squareHalfSize, xColor); - appendEditorLine(gizmoVertices, center, yTip, green); - appendArrowHead(gizmoVertices, yTip, yAxis, arrowSize, green); + appendRibbonLine(gizmoVertices, center, yTip, gizmoThickness, + cameraPosition, yColor); + appendSolidCubeCap(gizmoVertices, yTip, yAxis, squareHalfSize, yColor); - appendEditorLine(gizmoVertices, center, zTip, blue); - appendArrowHead(gizmoVertices, zTip, zAxis, arrowSize, blue); + appendRibbonLine(gizmoVertices, center, zTip, gizmoThickness, + cameraPosition, zColor); + appendSolidCubeCap(gizmoVertices, zTip, zAxis, squareHalfSize, zColor); } else if (editorControlMode == EditorControlMode::Rotate) { - int segments = 96; - float ringRadius = radius * 1.05f; + int segments = 128; + float ringRadius = gizmoScale * 1.12f; for (int ring = 0; ring < 3; ++ring) { Color color = ring == 0 ? red : (ring == 1 ? green : blue); + if (editorActiveGizmoAxis == ring + 1) { + color = active; + } for (int i = 0; i < segments; ++i) { float a0 = (static_cast(i) / segments) * glm::two_pi(); @@ -2081,7 +3267,14 @@ void Window::updateEditorControlGeometry() { p1 = center + glm::vec3(std::cos(a1), std::sin(a1), 0) * ringRadius; } - appendEditorLine(gizmoVertices, p0, p1, color); + if (editorLocalTransformSpace) { + const glm::quat rotation = + selectedEditorObject->getRotation().toGlmQuat(); + p0 = center + rotation * (p0 - center); + p1 = center + rotation * (p1 - center); + } + appendRibbonLine(gizmoVertices, p0, p1, gizmoThickness, + cameraPosition, color); } } } @@ -2090,7 +3283,7 @@ void Window::updateEditorControlGeometry() { gizmoVertices); } -void Window::renderEditorControls( +void Window::renderEditorGrid( const std::shared_ptr &commandBuffer) { if (!editorControlsEnabled || commandBuffer == nullptr || camera == nullptr) { @@ -2120,29 +3313,58 @@ void Window::renderEditorControls( updatePipelineStateField(this->lineWidth, 1.8f); updatePipelineStateField(this->useDepth, true); updatePipelineStateField(this->writeDepth, false); - updatePipelineStateField(this->depthCompareOp, opal::CompareOp::LessEqual); + updatePipelineStateField(this->depthCompareOp, opal::CompareOp::Less); renderEditorLineObject(editorGridObject.get(), view, projection, commandBuffer); - if (selectedEditorObject == nullptr) { - updatePipelineStateField(this->primitiveStyle, previousPrimitiveStyle); - updatePipelineStateField(this->cullMode, previousCullMode); - updatePipelineStateField(this->depthCompareOp, previousDepthCompare); - updatePipelineStateField(this->useDepth, previousDepth); - updatePipelineStateField(this->writeDepth, previousWriteDepth); - updatePipelineStateField(this->useBlending, previousBlending); - updatePipelineStateField(this->srcBlend, previousSrcBlend); - updatePipelineStateField(this->dstBlend, previousDstBlend); - updatePipelineStateField(this->lineWidth, previousLineWidth); + updatePipelineStateField(this->primitiveStyle, previousPrimitiveStyle); + updatePipelineStateField(this->cullMode, previousCullMode); + updatePipelineStateField(this->depthCompareOp, previousDepthCompare); + updatePipelineStateField(this->useDepth, previousDepth); + updatePipelineStateField(this->writeDepth, previousWriteDepth); + updatePipelineStateField(this->useBlending, previousBlending); + updatePipelineStateField(this->srcBlend, previousSrcBlend); + updatePipelineStateField(this->dstBlend, previousDstBlend); + updatePipelineStateField(this->lineWidth, previousLineWidth); +} + +void Window::renderEditorOverlays( + const std::shared_ptr &commandBuffer) { + if (!editorControlsEnabled || commandBuffer == nullptr || + camera == nullptr || selectedEditorObject == nullptr) { return; } - updatePipelineStateField(this->lineWidth, 3.0f); - updatePipelineStateField(this->useDepth, false); - updatePipelineStateField(this->depthCompareOp, opal::CompareOp::Always); + updateEditorControlGeometry(); + + opal::PrimitiveStyle previousPrimitiveStyle = primitiveStyle; + opal::CullMode previousCullMode = cullMode; + opal::CompareOp previousDepthCompare = depthCompareOp; + bool previousDepth = useDepth; + bool previousWriteDepth = writeDepth; + bool previousBlending = useBlending; + opal::BlendFunc previousSrcBlend = srcBlend; + opal::BlendFunc previousDstBlend = dstBlend; + float previousLineWidth = lineWidth; + + glm::mat4 view = camera->calculateViewMatrix(); + glm::mat4 projection = calculateProjectionMatrix(); + + updatePipelineStateField(this->cullMode, opal::CullMode::None); + updatePipelineStateField(this->useBlending, true); + updatePipelineStateField(this->srcBlend, opal::BlendFunc::SrcAlpha); + updatePipelineStateField(this->dstBlend, opal::BlendFunc::OneMinusSrcAlpha); + updatePipelineStateField(this->writeDepth, false); + + updatePipelineStateField(this->primitiveStyle, + opal::PrimitiveStyle::Triangles); + updatePipelineStateField(this->useDepth, true); + updatePipelineStateField(this->depthCompareOp, opal::CompareOp::Less); renderEditorLineObject(editorOutlineObject.get(), view, projection, commandBuffer); if (editorControlMode != EditorControlMode::None) { + updatePipelineStateField(this->useDepth, false); + updatePipelineStateField(this->depthCompareOp, opal::CompareOp::Always); renderEditorLineObject(editorGizmoObject.get(), view, projection, commandBuffer); } @@ -2158,12 +3380,29 @@ void Window::renderEditorControls( updatePipelineStateField(this->lineWidth, previousLineWidth); } +void Window::renderEditorControls( + const std::shared_ptr &commandBuffer) { + renderEditorGrid(commandBuffer); + renderEditorOverlays(commandBuffer); +} + void Window::endRunLoop() { if (!this->runLoopInitialized) { return; } this->activeCommandBuffer = nullptr; this->runLoopRenderPass = nullptr; + if (this->modeScreenTarget) { + auto *target = this->modeScreenTarget.get(); + if (this->currentRenderTarget == target) { + this->currentRenderTarget = nullptr; + } + this->preferenceRenderables.erase( + std::remove(this->preferenceRenderables.begin(), + this->preferenceRenderables.end(), target), + this->preferenceRenderables.end()); + this->modeScreenTarget.reset(); + } this->runLoopWindowID = 0; this->runLoopInitialized = false; } @@ -2205,37 +3444,60 @@ void Window::removeObject(Renderable *obj) { if (obj == nullptr) { return; } - if (selectedEditorObject == dynamic_cast(obj)) { + auto *gameObject = dynamic_cast(obj); + if (gameObject != nullptr) { + setEditorObjectParent(gameObject, nullptr); + auto childrenIt = editorObjectChildren.find(gameObject); + if (childrenIt != editorObjectChildren.end()) { + for (GameObject *child : childrenIt->second) { + editorObjectParents.erase(child); + } + editorObjectChildren.erase(childrenIt); + } + } + if (selectedEditorObject == gameObject) { selectedEditorObject = nullptr; editorDragging = false; - } - - if (this->physicsWorld != nullptr) { - this->pendingRemovals.push_back(obj); + editorActiveGizmoAxis = 0; + } + + const auto pendingObject = + std::find(this->pendingObjects.begin(), this->pendingObjects.end(), obj); + const bool wasPending = pendingObject != this->pendingObjects.end(); + if (wasPending) { + this->pendingObjects.erase(pendingObject); + } + + this->lateForwardRenderables.erase( + std::remove(this->lateForwardRenderables.begin(), + this->lateForwardRenderables.end(), obj), + this->lateForwardRenderables.end()); + this->preferenceRenderables.erase( + std::remove(this->preferenceRenderables.begin(), + this->preferenceRenderables.end(), obj), + this->preferenceRenderables.end()); + this->firstRenderables.erase(std::remove(this->firstRenderables.begin(), + this->firstRenderables.end(), obj), + this->firstRenderables.end()); + this->uiRenderables.erase(std::remove(this->uiRenderables.begin(), + this->uiRenderables.end(), obj), + this->uiRenderables.end()); + if (auto *fluid = dynamic_cast(obj)) { + this->lateFluids.erase(std::remove(this->lateFluids.begin(), + this->lateFluids.end(), fluid), + this->lateFluids.end()); + } + + if (this->physicsWorld != nullptr && !wasPending) { + if (std::find(this->pendingRemovals.begin(), + this->pendingRemovals.end(), obj) == + this->pendingRemovals.end()) { + this->pendingRemovals.push_back(obj); + } } else { this->renderables.erase(std::remove(this->renderables.begin(), this->renderables.end(), obj), this->renderables.end()); - this->lateForwardRenderables.erase( - std::remove(this->lateForwardRenderables.begin(), - this->lateForwardRenderables.end(), obj), - this->lateForwardRenderables.end()); - this->preferenceRenderables.erase( - std::remove(this->preferenceRenderables.begin(), - this->preferenceRenderables.end(), obj), - this->preferenceRenderables.end()); - this->firstRenderables.erase(std::remove(this->firstRenderables.begin(), - this->firstRenderables.end(), - obj), - this->firstRenderables.end()); - this->uiRenderables.erase(std::remove(this->uiRenderables.begin(), - this->uiRenderables.end(), obj), - this->uiRenderables.end()); - if (auto *fluid = dynamic_cast(obj)) { - this->lateFluids.erase(std::remove(this->lateFluids.begin(), - this->lateFluids.end(), fluid), - this->lateFluids.end()); - } } this->shadowMapsDirty = true; this->shadowUpdateCooldown = 0.0f; @@ -2360,6 +3622,8 @@ void Window::applyScene(Scene *scene) { this->renderables.clear(); this->pendingObjects.clear(); this->preferenceRenderables.clear(); + this->currentRenderTarget = nullptr; + this->modeScreenTarget.reset(); this->firstRenderables.clear(); this->uiRenderables.clear(); this->lateForwardRenderables.clear(); @@ -2521,6 +3785,10 @@ std::vector Window::enumerateMonitors() { } Window::~Window() { + if (audioEngine != nullptr) { + audioEngine->shutdown(); + audioEngine.reset(); + } this->pingpongFramebuffers.at(0) = nullptr; this->pingpongFramebuffers.at(1) = nullptr; this->pingpongTextures.at(1) = nullptr; @@ -2528,7 +3796,9 @@ Window::~Window() { this->pingpongWidth = 0; this->pingpongHeight = 0; closeAllInputDeviceHandles(); - Window::mainWindow = nullptr; + if (Window::mainWindow == this) { + Window::mainWindow = nullptr; + } } Monitor::Monitor(CoreMonitorReference ref, int id, bool isPrimary) @@ -2642,7 +3912,37 @@ void Window::captureMouse() { } void Window::addRenderTarget(RenderTarget *target) { - this->renderTargets.push_back(target); + if (target != nullptr && + std::find(this->renderTargets.begin(), this->renderTargets.end(), + target) == this->renderTargets.end()) { + this->renderTargets.push_back(target); + } +} + +void Window::removeRenderTarget(RenderTarget *target) { + this->renderTargets.erase( + std::remove(this->renderTargets.begin(), this->renderTargets.end(), + target), + this->renderTargets.end()); + if (this->currentRenderTarget == target) { + this->currentRenderTarget = nullptr; + } +} + +void Window::setHostWindowVisible(bool visible) { + this->showHostWindow = visible; + if (this->windowRef == nullptr) { + return; + } + if (visible) { + SDL_ShowWindow(this->windowRef); + } else { + SDL_HideWindow(this->windowRef); + } +} + +void Window::setDefaultFramebufferRenderingEnabled(bool enabled) { + this->renderDefaultFramebuffer = enabled; } void Window::renderLightsToShadowMaps( @@ -3498,6 +4798,7 @@ void Window::captureFluidReflection( if (dynamic_cast(obj) == &fluid) { continue; } + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(view); obj->setProjectionMatrix(projection); obj->render(getDeltaTime(), commandBuffer, @@ -3624,6 +4925,7 @@ void Window::captureFluidRefraction( if (dynamic_cast(obj) == &fluid) { continue; } + if (obj && obj->editorOnly && !this->areEditorControlsEnabled()) continue; obj->setViewMatrix(view); obj->setProjectionMatrix(projection); obj->render(getDeltaTime(), commandBuffer, diff --git a/atlas/camera.cpp b/atlas/camera.cpp index 63330290..0ad28d46 100644 --- a/atlas/camera.cpp +++ b/atlas/camera.cpp @@ -54,7 +54,11 @@ glm::vec2 sampleControllerAxisPair(Window &window, int axisIndexX, glm::mat4 Camera::calculateViewMatrix() const { glm::dvec3 camPos(position.x, position.y, position.z); glm::dvec3 camTarget(target.x, target.y, target.z); - glm::dvec3 upVector(0.0, 1.0, 0.0); // Assuming Y-up coordinate system + glm::dvec3 direction = glm::normalize(camTarget - camPos); + glm::dvec3 upVector = + std::abs(glm::dot(direction, glm::dvec3(0.0, 1.0, 0.0))) > 0.999 + ? glm::dvec3(0.0, 0.0, 1.0) + : glm::dvec3(0.0, 1.0, 0.0); return glm::mat4(glm::lookAt(camPos, camTarget, upVector)); } @@ -70,10 +74,14 @@ void Camera::setPosition(const Position3d &newPosition) { } void Camera::lookAt(const Point3d &newTarget) { + glm::vec3 delta(newTarget.x - position.x, newTarget.y - position.y, + newTarget.z - position.z); + if (!std::isfinite(delta.x) || !std::isfinite(delta.y) || + !std::isfinite(delta.z) || glm::length(delta) < 0.000001f) { + return; + } target = newTarget; - glm::vec3 dir = glm::normalize(glm::vec3(newTarget.x - position.x, - newTarget.y - position.y, - newTarget.z - position.z)); + glm::vec3 dir = glm::normalize(delta); pitch = glm::degrees(asin(dir.y)); yaw = glm::degrees(atan2(dir.z, dir.x)); targetPitch = pitch; diff --git a/atlas/graphics/deferred.cpp b/atlas/graphics/deferred.cpp index de451e7d..8fd8c89c 100644 --- a/atlas/graphics/deferred.cpp +++ b/atlas/graphics/deferred.cpp @@ -315,6 +315,7 @@ void Window::deferredRendering( std::shared_ptr pipeline; int width = 0; int height = 0; + opal::RasterizerMode rasterizerMode = opal::RasterizerMode::Fill; }; static std::unordered_map deferredPrograms; static std::unordered_map @@ -431,10 +432,12 @@ void Window::deferredRendering( const int gbufferHeight = this->gBuffer->getHeight(); if (pipelineEntry.pipeline == nullptr || pipelineEntry.width != gbufferWidth || - pipelineEntry.height != gbufferHeight) { + pipelineEntry.height != gbufferHeight || + pipelineEntry.rasterizerMode != this->rasterizerMode) { auto deferredPipeline = opal::Pipeline::create(); deferredPipeline->setViewport(0, 0, gbufferWidth, gbufferHeight); deferredPipeline->setCullMode(this->cullMode); + deferredPipeline->setRasterizerMode(this->rasterizerMode); deferredPipeline->setFrontFace(this->deferredFrontFace); deferredPipeline->enableDepthTest(true); deferredPipeline->setDepthCompareOp(opal::CompareOp::Less); @@ -443,6 +446,7 @@ void Window::deferredRendering( programIt->second.requestPipeline(deferredPipeline); pipelineEntry.width = gbufferWidth; pipelineEntry.height = gbufferHeight; + pipelineEntry.rasterizerMode = this->rasterizerMode; } obj->setViewMatrix(this->camera->calculateViewMatrix()); diff --git a/atlas/graphics/light.cpp b/atlas/graphics/light.cpp index fec53a9a..10408fb2 100644 --- a/atlas/graphics/light.cpp +++ b/atlas/graphics/light.cpp @@ -37,6 +37,7 @@ void Light::createDebugObject() { sphere.createAndAttachProgram(vShader, shader); this->debugObject = std::make_shared(sphere); this->debugObject->castsShadows = false; + this->debugObject->editorOnly = true; } void Light::setColor(Color newColor) { @@ -161,6 +162,7 @@ void Spotlight::createDebugObject() { pyramid.createAndAttachProgram(vShader, shader); this->debugObject = std::make_shared(pyramid); this->debugObject->castsShadows = false; + this->debugObject->editorOnly = true; } void Spotlight::setColor(Color newColor) { @@ -525,6 +527,7 @@ void AreaLight::createDebugObject() { this->debugObject = std::make_shared(plane); this->debugObject->castsShadows = false; + this->debugObject->editorOnly = true; } void AreaLight::addDebugObject(Window &window) { diff --git a/atlas/graphics/particle.cpp b/atlas/graphics/particle.cpp index 657ee6b4..6ffafbcb 100644 --- a/atlas/graphics/particle.cpp +++ b/atlas/graphics/particle.cpp @@ -34,7 +34,7 @@ ParticleEmitter::ParticleEmitter(unsigned int maxParticles) particles.reserve(maxParticles); this->direction = {0.0, 1.0, 0.0}; - for (auto& p : particles) { + for (auto &p : particles) { p.active = false; } } @@ -44,8 +44,7 @@ void ParticleEmitter::initialize() { {.x = -0.5f, .y = -0.5f, .z = 0.0f, .u = 0.0f, .v = 0.0f}, {.x = 0.5f, .y = -0.5f, .z = 0.0f, .u = 1.0f, .v = 0.0f}, {.x = 0.5f, .y = 0.5f, .z = 0.0f, .u = 1.0f, .v = 1.0f}, - {.x = -0.5f, .y = 0.5f, .z = 0.0f, .u = 0.0f, .v = 1.0f} - }; + {.x = -0.5f, .y = 0.5f, .z = 0.0f, .u = 0.0f, .v = 1.0f}}; static const unsigned int indices[] = {0, 1, 2, 2, 3, 0}; @@ -71,8 +70,7 @@ void ParticleEmitter::initialize() { .size = 3, .stride = static_cast(sizeof(QuadVertex)), .inputRate = opal::VertexBindingInputRate::Vertex, - .divisor = 0 - }; + .divisor = 0}; opal::VertexAttribute uvAttr{ .name = "particleUV", .type = opal::VertexAttributeType::Float, @@ -82,8 +80,7 @@ void ParticleEmitter::initialize() { .size = 2, .stride = static_cast(sizeof(QuadVertex)), .inputRate = opal::VertexBindingInputRate::Vertex, - .divisor = 0 - }; + .divisor = 0}; opal::VertexAttribute instancePos{ .name = "instancePosition", .type = opal::VertexAttributeType::Float, @@ -93,8 +90,7 @@ void ParticleEmitter::initialize() { .size = 3, .stride = static_cast(sizeof(ParticleInstanceData)), .inputRate = opal::VertexBindingInputRate::Instance, - .divisor = 1 - }; + .divisor = 1}; opal::VertexAttribute instanceColor{ .name = "instanceColor", .type = opal::VertexAttributeType::Float, @@ -104,8 +100,7 @@ void ParticleEmitter::initialize() { .size = 4, .stride = static_cast(sizeof(ParticleInstanceData)), .inputRate = opal::VertexBindingInputRate::Instance, - .divisor = 1 - }; + .divisor = 1}; opal::VertexAttribute instanceSize{ .name = "instanceSize", .type = opal::VertexAttributeType::Float, @@ -115,16 +110,14 @@ void ParticleEmitter::initialize() { .size = 1, .stride = static_cast(sizeof(ParticleInstanceData)), .inputRate = opal::VertexBindingInputRate::Instance, - .divisor = 1 - }; + .divisor = 1}; std::vector bindings = { {.attribute = positionAttr, .sourceBuffer = quadBuffer}, {.attribute = uvAttr, .sourceBuffer = quadBuffer}, {.attribute = instancePos, .sourceBuffer = instanceBuffer}, {.attribute = instanceColor, .sourceBuffer = instanceBuffer}, - {.attribute = instanceSize, .sourceBuffer = instanceBuffer} - }; + {.attribute = instanceSize, .sourceBuffer = instanceBuffer}}; vao->configureAttributes(bindings); program = ShaderProgram::fromDefaultShaders(AtlasVertexShader::Particle, @@ -138,7 +131,7 @@ void ParticleEmitter::spawnParticle() { } } -void ParticleEmitter::updateParticle(Particle& p, float deltaTime) { +void ParticleEmitter::updateParticle(Particle &p, float deltaTime) { if (!p.active) return; @@ -150,8 +143,7 @@ void ParticleEmitter::updateParticle(Particle& p, float deltaTime) { if (emissionType == ParticleEmissionType::Fountain) { p.velocity.y += settings.gravity * deltaTime; - } - else if (emissionType == ParticleEmissionType::Ambient) { + } else if (emissionType == ParticleEmissionType::Ambient) { p.velocity.y += settings.gravity * 0.1f * deltaTime; float time = atlasGetTimeSeconds(); @@ -187,14 +179,13 @@ void ParticleEmitter::updateParticle(Particle& p, float deltaTime) { } Position3d ParticleEmitter::generateSpawnPosition() { - static std::default_random_engine rng([] - { + static std::default_random_engine rng([] { std::random_device rd; return std::default_random_engine(rd()); }()); static std::uniform_real_distribution rand01(0.0f, 1.0f); - static std::uniform_real_distribution randAngle(0.0f, - 2.0f * std::numbers::pi_v); + static std::uniform_real_distribution randAngle( + 0.0f, 2.0f * std::numbers::pi_v); if (emissionType == ParticleEmissionType::Ambient) { Position3d spawnPos = position; @@ -221,8 +212,7 @@ Position3d ParticleEmitter::generateSpawnPosition() { } Magnitude3d ParticleEmitter::generateRandomVelocity() { - static std::default_random_engine rng([] - { + static std::default_random_engine rng([] { std::random_device rd; return std::default_random_engine(rd()); }()); @@ -235,8 +225,7 @@ Magnitude3d ParticleEmitter::generateRandomVelocity() { float spreadZ = (rand01(rng) - 0.5f) * settings.spread; vel.x += spreadX; vel.z += spreadZ; - } - else if (emissionType == ParticleEmissionType::Ambient) { + } else if (emissionType == ParticleEmissionType::Ambient) { vel.x = (rand01(rng) - 0.5f) * 0.5f; vel.y = -0.5f - (rand01(rng) * 1.0f); vel.z = (rand01(rng) - 0.5f) * 0.5f; @@ -263,14 +252,13 @@ void ParticleEmitter::activateParticle(int index) { if (index < 0 || std::cmp_greater_equal(index, particles.size())) return; - static std::default_random_engine rng([] - { + static std::default_random_engine rng([] { std::random_device rd; return std::default_random_engine(rd()); }()); static std::uniform_real_distribution rand01(0.0f, 1.0f); - Particle& p = particles.at(index); + Particle &p = particles.at(index); p.active = true; p.position = generateSpawnPosition(); p.velocity = generateRandomVelocity(); @@ -293,13 +281,13 @@ void ParticleEmitter::activateParticle(int index) { p.life = baseLifetime * heightMultiplier; p.maxLife = p.life; - p.size = - settings.minSize + ((settings.maxSize - settings.minSize) * rand01(rng)); + p.size = settings.minSize + + ((settings.maxSize - settings.minSize) * rand01(rng)); } -void ParticleEmitter::update(Window& window) { +void ParticleEmitter::update(Window &window) { float dt = window.getDeltaTime(); - Camera* cam = window.getCamera(); + Camera *cam = window.getCamera(); this->model = glm::translate( glm::mat4(1.0f), glm::vec3(cam->position.x, cam->position.y, cam->position.z)); @@ -326,14 +314,14 @@ void ParticleEmitter::update(Window& window) { } } - for (auto& p : particles) { + for (auto &p : particles) { updateParticle(p, dt); } std::vector instanceData; instanceData.reserve(maxParticles); - for (const auto& p : particles) { + for (const auto &p : particles) { if (p.active) { ParticleInstanceData data; data.posX = static_cast(p.position.x); @@ -363,7 +351,7 @@ void ParticleEmitter::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { (void)updatePipeline; - for (auto& component : components) { + for (auto &component : components) { component->update(dt); } if (activeParticleCount == 0) @@ -410,7 +398,10 @@ void ParticleEmitter::render(float dt, if (TracerServices::getInstance().isOk()) { DebugObjectPacket debugPacket; debugPacket.drawCallsForObject = 1; - debugPacket.frameCount = Window::mainWindow->device->frameCount; + debugPacket.frameCount = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; debugPacket.triangleCount = activeParticleCount * 2; debugPacket.vertexBufferSizeMb = static_cast(sizeof(QuadVertex) * 4) / (1024.0f * 1024.0f); @@ -424,29 +415,45 @@ void ParticleEmitter::render(float dt, } } -void ParticleEmitter::setProjectionMatrix(const glm::mat4& newProjection) { +void ParticleEmitter::setProjectionMatrix(const glm::mat4 &newProjection) { this->projection = newProjection; } -void ParticleEmitter::setViewMatrix(const glm::mat4& newView) { +void ParticleEmitter::setViewMatrix(const glm::mat4 &newView) { this->view = newView; } -void ParticleEmitter::attachTexture(const Texture& tex) { +void ParticleEmitter::attachTexture(const Texture &tex) { this->texture = tex; this->useTexture = true; } -void ParticleEmitter::setColor(const Color& newColor) { this->color = newColor; } +void ParticleEmitter::setColor(const Color &newColor) { + this->color = newColor; +} -void ParticleEmitter::setPosition(const Position3d& newPosition) { +void ParticleEmitter::setPosition(const Position3d &newPosition) { this->position = newPosition; } -void ParticleEmitter::move(const Position3d& deltaPosition) { +void ParticleEmitter::move(const Position3d &deltaPosition) { this->position = this->position + deltaPosition; } +void ParticleEmitter::setRotation(const Rotation3d &newRotation) { + this->rotation = newRotation; + glm::vec3 nextDirection = + newRotation.toGlmQuat() * glm::vec3(0.0f, 1.0f, 0.0f); + if (glm::length(nextDirection) < 0.000001f) { + nextDirection = glm::vec3(0.0f, 1.0f, 0.0f); + } + this->direction = Magnitude3d::fromGlm(glm::normalize(nextDirection)); +} + +void ParticleEmitter::rotate(const Rotation3d &deltaRotation) { + setRotation(this->rotation + deltaRotation); +} + void ParticleEmitter::setEmissionType(ParticleEmissionType type) { ParticleSettings particleSettings; particleSettings.gravity = -1.0f; @@ -458,7 +465,7 @@ void ParticleEmitter::setEmissionType(ParticleEmissionType type) { this->emissionType = type; } -void ParticleEmitter::setDirection(const Magnitude3d& dir) { +void ParticleEmitter::setDirection(const Magnitude3d &dir) { this->direction = dir; } @@ -470,7 +477,8 @@ void ParticleEmitter::setSpawnRate(float particlesPerSecond) { this->spawnRate = particlesPerSecond; } -void ParticleEmitter::setParticleSettings(const ParticleSettings& particleSettings) { +void ParticleEmitter::setParticleSettings( + const ParticleSettings &particleSettings) { this->settings = particleSettings; } diff --git a/atlas/graphics/render_target.cpp b/atlas/graphics/render_target.cpp index 554f750e..6e0a2f8e 100644 --- a/atlas/graphics/render_target.cpp +++ b/atlas/graphics/render_target.cpp @@ -27,8 +27,13 @@ RenderTarget::RenderTarget(Window &window, RenderTargetType type, int resolution) { atlas_log("Creating render target (type: " + std::to_string(static_cast(type)) + ")"); - int fbWidth, fbHeight; - atlasGetWindowSizeInPixels(window.windowRef, &fbWidth, &fbHeight); + Size2d drawableSize = window.getSize(); + int fbWidth = static_cast(drawableSize.width); + int fbHeight = static_cast(drawableSize.height); + if (fbWidth <= 1 || fbHeight <= 1) { + fbWidth = std::max(1, window.width); + fbHeight = std::max(1, window.height); + } float targetScale = window.getRenderScale(); if (type == RenderTargetType::SSAO || type == RenderTargetType::SSAOBlur) { @@ -456,7 +461,8 @@ RenderTarget::RenderTarget(Window &window, RenderTargetType type, 4.0f /* bytes per pixel */) / (1024.0f * 1024.0f); packet.kind = DebugResourceKind::RenderTarget; - packet.frameNumber = Window::mainWindow->device->frameCount; + packet.frameNumber = + window.device != nullptr ? window.device->frameCount : 0; packet.send(); } @@ -921,7 +927,11 @@ void RenderTarget::render(float dt, if (TracerServices::getInstance().isOk()) { DebugObjectPacket debugPacket; debugPacket.drawCallsForObject = 1; - debugPacket.frameCount = Window::mainWindow->device->frameCount; + debugPacket.frameCount = + Window::mainWindow != nullptr && + Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; debugPacket.triangleCount = 2; debugPacket.vertexBufferSizeMb = static_cast(sizeof(CoreVertex) * 4) / (1024.0f * 1024.0f); diff --git a/atlas/graphics/texture.cpp b/atlas/graphics/texture.cpp index 750104fe..9ef7c1e5 100644 --- a/atlas/graphics/texture.cpp +++ b/atlas/graphics/texture.cpp @@ -849,7 +849,10 @@ void Skybox::render(float, std::shared_ptr commandBuffer, if (TracerServices::getInstance().isOk()) { DebugObjectPacket debugPacket; debugPacket.drawCallsForObject = 1; - debugPacket.frameCount = Window::mainWindow->device->frameCount; + debugPacket.frameCount = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; debugPacket.triangleCount = static_cast(obj->indices.size()) / 3; debugPacket.vertexBufferSizeMb = diff --git a/atlas/object/compound.cpp b/atlas/object/compound.cpp index 6e53fd8d..e8a073fb 100644 --- a/atlas/object/compound.cpp +++ b/atlas/object/compound.cpp @@ -85,17 +85,6 @@ void CompoundObject::initialize() { void CompoundObject::render(float dt, std::shared_ptr commandBuffer, bool updatePipeline) { - if (originalPositions.empty()) { - for (const auto &obj : objects) { - originalPositions.push_back(obj->getPosition()); - } - } - if (changedPosition) { - for (size_t i = 0; i < objects.size(); ++i) { - objects[i]->setPosition(position + originalPositions[i]); - } - changedPosition = false; - } for (auto &component : components) { component->update(dt); } @@ -177,13 +166,7 @@ void CompoundObject::setPipeline(std::shared_ptr &pipeline) { } Position3d CompoundObject::getPosition() const { - if (objects.empty()) { - if (!lateForwardObjects.empty() && lateForwardObjects[0] != nullptr) { - return lateForwardObjects[0]->getPosition(); - } - return Position3d{0.0, 0.0, 0.0}; - } - return objects[0]->getPosition(); + return position; } Size3d CompoundObject::getScale() const { @@ -198,13 +181,7 @@ Size3d CompoundObject::getScale() const { void CompoundObject::update(Window &window) { updateObjects(window); - for (auto &obj : objects) { - if (changedPosition) { - std::cout << "Updating position of compound object child\n"; - obj->setPosition(position + this->position); - changedPosition = false; - } - } + changedPosition = false; } bool CompoundObject::canCastShadows() const { @@ -213,13 +190,24 @@ bool CompoundObject::canCastShadows() const { } void CompoundObject::setPosition(const Position3d &newPosition) { + Position3d delta = newPosition - position; this->position = newPosition; - changedPosition = true; + for (auto &obj : objects) { + if (obj != nullptr) { + obj->move(delta); + } + } + changedPosition = false; } void CompoundObject::move(const Position3d &deltaPosition) { this->position += deltaPosition; - changedPosition = true; + for (auto &obj : objects) { + if (obj != nullptr) { + obj->move(deltaPosition); + } + } + changedPosition = false; } void CompoundObject::setRotation(const Rotation3d &newRotation) { diff --git a/atlas/object/core_object.cpp b/atlas/object/core_object.cpp index 7a0899b6..a6039973 100644 --- a/atlas/object/core_object.cpp +++ b/atlas/object/core_object.cpp @@ -534,7 +534,10 @@ void CoreObject::render(float dt, if (TracerServices::getInstance().isOk()) { DebugObjectPacket debugPacket{}; debugPacket.drawCallsForObject = 1; - debugPacket.frameCount = Window::mainWindow->device->frameCount; + debugPacket.frameCount = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; debugPacket.triangleCount = static_cast( indices.empty() ? vertices.size() / 3 : indices.size() / 3); debugPacket.vertexBufferSizeMb = @@ -1020,7 +1023,10 @@ void CoreObject::update(Window &) { physicsEvent.name = "Physics Update"; physicsEvent.durationMs = static_cast(physicsTime) / 1'000'000.0f; physicsEvent.subsystem = TimingEventSubsystem::Physics; - physicsEvent.frameNumber = Window::mainWindow->device->frameCount; + physicsEvent.frameNumber = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; physicsEvent.send(); } diff --git a/atlas/object/model.cpp b/atlas/object/model.cpp index 809c5c9a..9971574f 100644 --- a/atlas/object/model.cpp +++ b/atlas/object/model.cpp @@ -434,7 +434,10 @@ Model::processMesh(aiMesh *mesh, const aiScene *scene, info.resourceType = DebugResourceType::Mesh; info.callerObject = std::to_string(object.getId()); info.operation = DebugResourceOperation::Created; - info.frameNumber = Window::mainWindow->device->frameCount; + info.frameNumber = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; info.sizeMb = static_cast((vertices.size() * sizeof(CoreVertex)) + (indices.size() * sizeof(unsigned int))) / (1024.0f * 1024.0f); 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/cli/src/lib.rs b/cli/src/lib.rs index 076c05eb..1fadced1 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -81,6 +81,7 @@ pub struct PackConfig { #[derive(serde::Deserialize)] pub struct Config { + #[serde(flatten)] pub project: ProjectConfig, pub pack: PackConfig, } diff --git a/cli/src/pack.rs b/cli/src/pack.rs index 9d6118fc..a4ce5b8f 100644 --- a/cli/src/pack.rs +++ b/cli/src/pack.rs @@ -1,5 +1,6 @@ use crate::{Commands, Config}; use colored::Colorize; +use serde_json::Value; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -14,7 +15,7 @@ const INFO_PLIST: &str = r#" CFBundleDisplayName ((APPNAME)) CFBundleIdentifier - com.((IDENTIFIER)).((APPNAMELC)) + ((IDENTIFIER)) CFBundleVersion ((VERSION)) CFBundleExecutable @@ -42,9 +43,143 @@ fn host_platform() -> &'static str { } fn parse_config() -> Result { - let config_str = - fs::read_to_string("atlas.toml").map_err(|e| format!("Failed to read atlas.toml: {e}"))?; - toml::from_str(&config_str).map_err(|e| format!("Failed to parse atlas.toml: {e}")) + let config_str = fs::read_to_string("project.atlas") + .map_err(|e| format!("Failed to read project.atlas: {e}"))?; + toml::from_str(&config_str).map_err(|e| format!("Failed to parse project.atlas: {e}")) +} + +struct RuntimePaths { + atlas: PathBuf, + library: PathBuf, +} + +fn home_dir() -> Result { + std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| String::from("HOME is not set")) +} + +fn expand_user_path(path: &str) -> Result { + if path == "~" { + return home_dir(); + } + if let Some(relative) = path.strip_prefix("~/") { + return Ok(home_dir()?.join(relative)); + } + Ok(PathBuf::from(path)) +} + +fn runtime_paths(version: &str) -> Result { + let config_path = home_dir()?.join(".atlas/config.json"); + let contents = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?; + let root: Value = serde_json::from_str(&contents) + .map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?; + let versions = root + .as_object() + .ok_or_else(|| String::from("Atlas runtime configuration must be an object"))?; + let entry = versions + .get(version) + .or_else(|| { + versions + .iter() + .find(|(key, _)| { + key.eq_ignore_ascii_case(version) + || key.starts_with(version) + || version.starts_with(key.as_str()) + }) + .map(|(_, value)| value) + }) + .or_else(|| { + if versions.len() == 1 { + versions.values().next() + } else { + None + } + }) + .ok_or_else(|| format!("No installed Atlas runtime matches '{version}'"))?; + let onboarding = entry + .get("onboardingData") + .and_then(Value::as_object) + .ok_or_else(|| String::from("The installed runtime has no onboarding data"))?; + let atlas = onboarding + .get("atlasExecutablePath") + .and_then(Value::as_str) + .ok_or_else(|| String::from("The installed runtime has no Atlas executable"))?; + let library = onboarding + .get("runtimeLib") + .and_then(Value::as_str) + .ok_or_else(|| String::from("The installed runtime has no runtime library"))?; + let paths = RuntimePaths { + atlas: expand_user_path(atlas)?, + library: expand_user_path(library)?, + }; + if !paths.atlas.is_file() { + return Err(format!( + "Atlas executable not found at {}", + paths.atlas.display() + )); + } + if !paths.library.is_file() { + return Err(format!( + "Runtime library not found at {}", + paths.library.display() + )); + } + Ok(paths) +} + +fn copy_project(source: &Path, destination: &Path) -> Result<(), String> { + fs::create_dir_all(destination) + .map_err(|e| format!("Failed to create {}: {e}", destination.display()))?; + for entry in + fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))? + { + let entry = entry.map_err(|e| format!("Failed to inspect project file: {e}"))?; + let path = entry.path(); + let name = entry.file_name(); + if matches!( + name.to_str(), + Some(".git") + | Some(".atlas") + | Some("build") + | Some("dist") + | Some("Exports") + | Some("node_modules") + | Some("target") + ) { + continue; + } + let target = destination.join(&name); + if path.is_dir() { + copy_project(&path, &target)?; + } else { + fs::copy(&path, &target).map_err(|e| { + format!( + "Failed to copy {} to {}: {e}", + path.display(), + target.display() + ) + })?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path) + .map_err(|e| format!("Failed to inspect {}: {e}", path.display()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) + .map_err(|e| format!("Failed to make {} executable: {e}", path.display())) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<(), String> { + Ok(()) } fn resolve_backend(config: &Config, override_backend: Option) -> String { @@ -171,7 +306,7 @@ fn ensure_supported_platform(config: &Config) -> Result<(), String> { } Err(format!( - "Current platform '{current}' is not supported by atlas.toml" + "Current platform '{current}' is not supported by project.atlas" )) } @@ -246,16 +381,35 @@ pub fn clangd(cmd: Commands) { } pub fn pack(cmd: Commands) { - let (release, backend_override) = match cmd { - Commands::Pack { release, backend } => (release != 0, backend), - _ => (false, None), + let backend_override = match cmd { + Commands::Pack { backend, .. } => backend, + _ => None, }; - - let (config, backend, executable) = match build_internal(release, backend_override, false) { - Ok(data) => data, - Err(e) => { - eprintln!("{}\n{e}", "atlas pack failed".red().bold()); - return; + let config = match parse_config() { + Ok(config) => config, + Err(error) => { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + }; + if let Err(error) = ensure_supported_platform(&config) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + let backend = resolve_backend(&config, backend_override); + let version = config.project.atlas_version.as_deref().unwrap_or("stable"); + let runtime = match runtime_paths(version) { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + }; + let project_root = match std::env::current_dir() { + Ok(path) => path, + Err(error) => { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); } }; @@ -267,55 +421,97 @@ pub fn pack(cmd: Commands) { .unwrap_or_else(|| config.project.name.clone()); let app_dir = Path::new("dist"); + println!("{}", "Preparing project package...".cyan()); if app_dir.exists() { - let _ = fs::remove_dir_all(app_dir); + if let Err(error) = fs::remove_dir_all(app_dir) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } } - if fs::create_dir_all(app_dir).is_err() { - eprintln!("{}", "Failed to create dist directory".red().bold()); - return; + if let Err(error) = fs::create_dir_all(app_dir) { + eprintln!( + "{}\n{error}", + "Failed to create dist directory".red().bold() + ); + std::process::exit(1); } if host == "macos" { let bundle_dir = app_dir.join(format!("{app_name}.app")); let contents_dir = bundle_dir.join("Contents"); let macos_dir = contents_dir.join("MacOS"); + let frameworks_dir = contents_dir.join("Frameworks"); let resources_dir = contents_dir.join("Resources"); - if fs::create_dir_all(&macos_dir).is_err() || fs::create_dir_all(&resources_dir).is_err() { - eprintln!("{}", "Failed to create app bundle directories".red().bold()); - return; + for directory in [&macos_dir, &frameworks_dir, &resources_dir] { + if let Err(error) = fs::create_dir_all(directory) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } } - if fs::copy(&executable, macos_dir.join(&app_name)).is_err() { - eprintln!( - "{}", - "Failed to copy executable into app bundle".red().bold() - ); - return; + let atlas_binary = macos_dir.join("atlas"); + println!("{}", "Bundling the Atlas runtime...".cyan()); + if let Err(error) = fs::copy(&runtime.atlas, &atlas_binary) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + let runtime_name = runtime + .library + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("runtime.dylib")); + let runtime_target = frameworks_dir.join(runtime_name); + if let Err(error) = fs::copy(&runtime.library, &runtime_target) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + println!("{}", "Copying project resources...".cyan()); + if let Err(error) = copy_project(&project_root, &resources_dir.join("Project")) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + let launcher = macos_dir.join("AtlasLauncher"); + let launcher_contents = format!( + "#!/bin/sh\nCONTENTS=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nexport ATLAS_RUNTIME_LIB=\"$CONTENTS/Frameworks/{}\"\nexec \"$CONTENTS/MacOS/atlas\" run \"$CONTENTS/Resources/Project/project.atlas\"\n", + runtime_name.to_string_lossy() + ); + if let Err(error) = fs::write(&launcher, launcher_contents) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + if let Err(error) = make_executable(&launcher) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); } if config.pack.icon != "none" { - let icon_source = Path::new("assets").join(&config.pack.icon); + let icon_source = project_root.join("assets").join(&config.pack.icon); if icon_source.exists() { let _ = fs::copy(icon_source, resources_dir.join("AppIcon.icns")); } } + let default_identifier = format!( + "org.atlasengine.{}", + app_name.to_lowercase().replace(' ', "-") + ); + let identifier = config + .pack + .identifier + .as_deref() + .unwrap_or(&default_identifier); let plist = INFO_PLIST .replace("((APPNAME))", &app_name) .replace("((APPNAMELC))", &app_name.to_lowercase().replace(' ', "_")) - .replace( - "((IDENTIFIER))", - config.pack.identifier.as_deref().unwrap_or("example"), - ) + .replace("((IDENTIFIER))", identifier) .replace( "((VERSION))", config.pack.version.as_deref().unwrap_or("1.0"), ) - .replace("((EXECUTABLE))", &app_name); - if fs::write(contents_dir.join("Info.plist"), plist).is_err() { - eprintln!("{}", "Failed to write Info.plist".red().bold()); - return; + .replace("((EXECUTABLE))", "AtlasLauncher"); + if let Err(error) = fs::write(contents_dir.join("Info.plist"), plist) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); } println!("{} {}", "Pack backend:".cyan(), backend.bold().green()); @@ -327,21 +523,37 @@ pub fn pack(cmd: Commands) { return; } - let output_name = if host == "windows" { - format!("{app_name}.exe") + let package_dir = app_dir.join(&app_name); + if let Err(error) = fs::create_dir_all(&package_dir) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + let atlas_name = if host == "windows" { + "atlas.exe" } else { - app_name.clone() + "atlas" }; - let output_path = app_dir.join(output_name); - if fs::copy(executable, &output_path).is_err() { - eprintln!("{}", "Failed to create packaged executable".red().bold()); - return; + if let Err(error) = fs::copy(&runtime.atlas, package_dir.join(atlas_name)) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + let runtime_name = runtime + .library + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("runtime")); + if let Err(error) = fs::copy(&runtime.library, package_dir.join(runtime_name)) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); + } + if let Err(error) = copy_project(&project_root, &package_dir.join("Project")) { + eprintln!("{}\n{error}", "atlas pack failed".red().bold()); + std::process::exit(1); } println!("{} {}", "Pack backend:".cyan(), backend.bold().green()); println!( "{} {}", "Created package:".cyan(), - output_path.display().to_string().bold().green() + package_dir.display().to_string().bold().green() ); } diff --git a/docs/pages/editor.md b/docs/pages/editor.md new file mode 100644 index 00000000..5b00545d --- /dev/null +++ b/docs/pages/editor.md @@ -0,0 +1,84 @@ +# Atlas Editor workflow + +## Scene and project commands + +| Command | Shortcut | +| --- | --- | +| New scene | Command N | +| Open scene | Command O | +| Save scene | Command S | +| Save scene as | Command Shift S | +| Close scene tab | Command W | +| Quit Atlas | Command Q | +| Project settings | Command , | +| Export project | File > Export Project | +| Build project | Command B | +| Run project | Command Shift B | + +Scenes open as tabs above the viewport. Creating a scene uses the Atlas scene dialog and writes a new `.ascene` file. Project settings are stored inside the project at `.atlas/project-settings.ini`. + +## Editing commands + +| Command | Shortcut | +| --- | --- | +| Undo | Command Z | +| Redo | Command Shift Z | +| Cut, copy, paste | Command X, Command C, Command V | +| Duplicate selection | Command D | +| Delete selection | Backspace | +| Select all | Command A | +| Move, rotate, scale | G, R, S | +| Reset position | Command Option G | +| Reset rotation | Command Option R | +| Reset scale | Command Option S | +| Toggle local or world space | Shift T | +| Focus or frame selection | Tab | +| Pan viewport | Right-click and drag | +| Orbit viewport | Middle-click and drag | + +G, R, and S start a modal transform. X, Y, and Z constrain axes; multiple axis keys combine constraints; Shift plus an axis excludes it. Enter or left click confirms. Escape or right click cancels. The pointer wraps around the viewport during modal transforms so the operation can continue without reaching a screen edge. + +## Object and asset commands + +| Command | Shortcut | +| --- | --- | +| Create empty | Shift N | +| Create camera | Shift C | +| Create point light | Shift L | +| Open Add Object | Shift A | +| Reparent | Shift R | +| Rename hierarchy object or asset | Enter | +| Search hierarchy | Command Shift F | +| Search assets | Command Option F | +| Refresh asset database | Command Shift R | + +Shift-click selects multiple hierarchy objects. Dropping OBJ, FBX, glTF, GLB, or DAE files from the Content Browser onto the viewport imports a model. + +## Runtime and discovery + +| Command | Shortcut | +| --- | --- | +| Play or pause | Command P | +| Stop | Command Shift L | +| Step one frame | Command Shift K | +| Global project search | Command F | +| Command palette | Command Shift P | + +The command palette lists available commands and their shortcuts and supports keyboard filtering, arrow navigation, and Enter. Scripts are watched for changes and the editor reloads the runtime automatically when JavaScript or TypeScript files change. + +## Packaging Atlas Engine + +Run the macOS packer from the repository root: + +```shell +./scripts/package_app.py --debug --macOS +./scripts/package_app.py --release --macOS +``` + +The equivalent `just` recipes are `just package-debug-macos` and `just package-release-macos`. Products are written below `dist/macOS/` and intermediate files are written below `build/package`; both directories are ignored by version control. + +The package is self-contained and includes Qt, the Atlas CLI, and `runtime.dylib`. On first launch, Atlas Engine offers to install the bundled CLI and runtime into `~/Library/Application Support/Atlas Engine/toolchains/alpha9` and registers them in `~/.atlas/config.json`. Installation does not require administrator access and preserves other configured Atlas versions. + +Debug packages use an ad-hoc signature. A release intended for GitHub requires `ATLAS_SIGNING_IDENTITY` to name a Developer ID Application certificate and `ATLAS_NOTARY_PROFILE` to name a `notarytool` keychain profile. The packer creates, signs, notarizes, staples, mounts, and validates a drag-to-Applications DMG. It refuses to create an accidentally unnotarized release unless `ATLAS_ALLOW_UNNOTARIZED_RELEASE=1` is explicitly set; that local-only artifact is named `UNNOTARIZED`. + +The packer builds the host architecture by default. Set `ATLAS_MACOS_ARCHITECTURES='arm64;x86_64'` when the selected Qt installation contains both architectures to create a universal archive. `ATLAS_MACOS_DEPLOYMENT_TARGET` changes the default macOS 14.0 deployment target, and `ATLAS_MACDEPLOYQT` can select a specific `macdeployqt` executable. diff --git a/editor/.gitignore b/editor/.gitignore deleted file mode 100644 index f5c26c89..00000000 --- a/editor/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -dist-electron - -public -release -release-dev - -.clangd diff --git a/editor/.prettierrc b/editor/.prettierrc deleted file mode 100644 index 0c317dc3..00000000 --- a/editor/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "trailingComma": "all", - "tabWidth": 4 -} diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt new file mode 100644 index 00000000..64dbf688 --- /dev/null +++ b/editor/CMakeLists.txt @@ -0,0 +1,205 @@ + +# Finding all Editor Files +file(GLOB_RECURSE EDITOR_FILES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/editor/*" + "${CMAKE_SOURCE_DIR}/include/editor/*.h" +) + +file(GLOB_RECURSE ATLAS_QSS_FILES + CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/editor/styling/*.qss" +) + +set(ATLAS_GENERATED_THEME_HEADER + "${CMAKE_SOURCE_DIR}/include/editor/core/themes.h" +) + +# Find packages + +find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +find_program(ATLAS_CARGO_EXECUTABLE cargo REQUIRED) + +file(GLOB_RECURSE ATLAS_CLI_SOURCES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/cli/src/*.rs" +) + +if (CMAKE_BUILD_TYPE STREQUAL "Release") + set(ATLAS_EDITOR_CARGO_ARGS --release) + set(ATLAS_EDITOR_CLI "${CMAKE_SOURCE_DIR}/target/release/atlas") +else () + set(ATLAS_EDITOR_CARGO_ARGS) + set(ATLAS_EDITOR_CLI "${CMAKE_SOURCE_DIR}/target/debug/atlas") +endif () + +add_custom_command( + OUTPUT "${ATLAS_EDITOR_CLI}" + COMMAND + "${CMAKE_COMMAND}" -E env CARGO_TERM_COLOR=always + "${ATLAS_CARGO_EXECUTABLE}" build + ${ATLAS_EDITOR_CARGO_ARGS} + --manifest-path "${CMAKE_SOURCE_DIR}/cli/Cargo.toml" + DEPENDS + "${CMAKE_SOURCE_DIR}/Cargo.toml" + "${CMAKE_SOURCE_DIR}/Cargo.lock" + "${CMAKE_SOURCE_DIR}/cli/Cargo.toml" + ${ATLAS_CLI_SOURCES} + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "Building Atlas CLI for editor project export" + VERBATIM +) + +add_custom_target(atlas_editor_cli DEPENDS "${ATLAS_EDITOR_CLI}") + +# Compile all Qt themes into one header file + +add_custom_command( + OUTPUT "${ATLAS_GENERATED_THEME_HEADER}" + COMMAND + "${Python3_EXECUTABLE}" + "${CMAKE_SOURCE_DIR}/scripts/pack_qt_themes.py" + "${ATLAS_GENERATED_THEME_HEADER}" + ${ATLAS_QSS_FILES} + DEPENDS + "${CMAKE_SOURCE_DIR}/scripts/pack_qt_themes.py" + ${ATLAS_QSS_FILES} + COMMENT "Generating embedded QSS theme header" + VERBATIM +) + +add_custom_target(generate_atlas_themes + DEPENDS "${ATLAS_GENERATED_THEME_HEADER}" +) + +# Setting up the project + +qt_standard_project_setup() + +set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + +add_subdirectory( + "${CMAKE_SOURCE_DIR}/extern/QtDockingSystem" + "${CMAKE_BINARY_DIR}/extern/QtDockingSystem" +) + +if (APPLE) + set(ATLAS_APP_ICON + "${CMAKE_SOURCE_DIR}/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png" + CACHE FILEPATH "Atlas Engine macOS bundle icon" + ) + get_filename_component(ATLAS_APP_ICON_NAME "${ATLAS_APP_ICON}" NAME) + set_source_files_properties("${ATLAS_APP_ICON}" PROPERTIES + MACOSX_PACKAGE_LOCATION "Resources" + ) + list(APPEND EDITOR_FILES "${ATLAS_APP_ICON}") +endif () + +qt_add_executable(AtlasEditor ${EDITOR_FILES}) +add_dependencies(AtlasEditor generate_atlas_themes atlas_editor_cli) + +if (APPLE) + set_target_properties(AtlasEditor PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "Atlas Engine" + MACOSX_BUNDLE_GUI_IDENTIFIER "neutralsoftware.atlas" + MACOSX_BUNDLE_ICON_FILE "${ATLAS_APP_ICON_NAME}" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/editor/macos/Info.plist.in" + MACOSX_BUNDLE_SHORT_VERSION_STRING "0.9.0" + MACOSX_BUNDLE_BUNDLE_VERSION "10" + OUTPUT_NAME "Atlas Engine" + BUILD_RPATH "@executable_path/../Frameworks" + INSTALL_RPATH "@executable_path/../Frameworks" + ) + add_custom_command(TARGET AtlasEditor POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory + "$/Contents/Helpers" + "$/Contents/Frameworks" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${ATLAS_EDITOR_CLI}" + "$/Contents/Helpers/atlas" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/Contents/Frameworks/runtime.dylib" + VERBATIM + ) +else () + add_custom_command(TARGET AtlasEditor POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${ATLAS_EDITOR_CLI}" + "$/atlas" + VERBATIM + ) +endif () + +qt_add_resources(AtlasEditor "atlas_editor_assets" + PREFIX "/editor" + BASE "${CMAKE_SOURCE_DIR}/editor" + FILES + "${CMAKE_SOURCE_DIR}/editor/assets/Icon-iOS-Default-1024x1024@1x.png" + "${CMAKE_SOURCE_DIR}/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png" + "${CMAKE_SOURCE_DIR}/editor/assets/Manrope-VariableFont_wght.ttf" + "${CMAKE_SOURCE_DIR}/editor/assets/Phosphor.ttf" + "${CMAKE_SOURCE_DIR}/editor/assets/PHOSPHOR_LICENSE.txt" +) + +target_include_directories(AtlasEditor + PRIVATE + ${CMAKE_SOURCE_DIR}/extern/QtDockingSystem/src + ${ATLAS_DEP_INCLUDE_DIRS} +) + +string(TIMESTAMP ATLAS_EDITOR_BUILD_DATE "%Y%m%d") + +target_compile_definitions(AtlasEditor PRIVATE + ATLAS_TOOLCHAIN_VERSION="alpha9" + "$<$:ATLAS_DEBUG_BUILD>" + "$<$:ATLAS_BUILD_STRING=\"${ATLAS_EDITOR_BUILD_DATE}\">" +) + +# Linking libraries + +if (NOT BEZEL_NATIVE) + target_compile_definitions(AtlasEditor PRIVATE + JPH_PROFILE_ENABLED=1 + JPH_DEBUG_RENDERER=1 + JPH_OBJECT_STREAM=1 + ) +endif () + +target_link_libraries(AtlasEditor + PRIVATE + runtime_lib + atlas + opal + bezel + finewave + photon + aurora + hydra + graphite + quickjs + fmt::fmt + ${ATLAS_FREETYPE_TARGET} + ${ATLAS_ASSIMP_TARGET} + ${ATLAS_OPENAL_TARGET} + ${ATLAS_GLM_TARGET} + Qt6::Widgets + ads::qtadvanceddocking-qt6 +) + +if (NOT BEZEL_NATIVE) + target_link_libraries(AtlasEditor PRIVATE Jolt::Jolt) +endif () + +if (APPLE) + target_link_libraries(AtlasEditor + PUBLIC + "-framework Metal" + "-framework MetalKit" + "-framework MetalFX" + "-framework QuartzCore" + "-framework Foundation" + "-framework Cocoa" + "-framework IOKit" + ) +endif () diff --git a/editor/README.md b/editor/README.md deleted file mode 100644 index 30ec0164..00000000 --- a/editor/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Atlas Editor - -Electron-based editor for Atlas Engine. It provides a user-friendly interface for managing projects, configuring settings, and running the engine. - -## Building -To build the editor for development, run: - -```bash -bun run dist:debug -``` - -To build the editor for release, run: - -```bash -bun run release -``` \ No newline at end of file diff --git a/editor/application/toolchainInstaller.cpp b/editor/application/toolchainInstaller.cpp new file mode 100644 index 00000000..78b84ea1 --- /dev/null +++ b/editor/application/toolchainInstaller.cpp @@ -0,0 +1,235 @@ +#include "editor/application/toolchainInstaller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct ToolchainPaths { + QString bundledCli; + QString bundledRuntime; + QString installedCli; + QString installedRuntime; + QString config; +}; + +QByteArray digest(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!hash.addData(&file)) + return {}; + return hash.result(); +} + +bool filesMatch(const QString& left, const QString& right) { + const QFileInfo leftInfo(left); + const QFileInfo rightInfo(right); + return leftInfo.isFile() && rightInfo.isFile() && + leftInfo.size() == rightInfo.size() && digest(left) == digest(right); +} + +ToolchainPaths paths() { + const QDir contents(QCoreApplication::applicationDirPath() + "/.."); + const QString installRoot = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + "/Atlas Engine/toolchains/" + ATLAS_TOOLCHAIN_VERSION; + const QString home = QDir::homePath(); + return { + contents.filePath("Helpers/atlas"), + contents.filePath("Frameworks/runtime.dylib"), + installRoot + "/bin/atlas", + installRoot + "/lib/runtime.dylib", + home + "/.atlas/config.json", + }; +} + +QJsonObject readConfig(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return {}; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + return document.isObject() ? document.object() : QJsonObject(); +} + +bool configMatches(const ToolchainPaths& toolchain) { + const QJsonObject root = readConfig(toolchain.config); + const QJsonObject entry = root.value(ATLAS_TOOLCHAIN_VERSION).toObject(); + const QJsonObject onboarding = entry.value("onboardingData").toObject(); + return onboarding.value("atlasExecutablePath").toString() == + toolchain.installedCli && + onboarding.value("runtimeLib").toString() == + toolchain.installedRuntime; +} + +bool copyFile(const QString& source, const QString& destination, + QString* error) { + QFile input(source); + if (!input.open(QIODevice::ReadOnly)) { + *error = input.errorString(); + return false; + } + QSaveFile output(destination); + if (!output.open(QIODevice::WriteOnly)) { + *error = output.errorString(); + return false; + } + while (!input.atEnd()) { + const QByteArray data = input.read(1024 * 1024); + if (data.isEmpty() && input.error() != QFileDevice::NoError) { + *error = input.errorString(); + return false; + } + if (output.write(data) != data.size()) { + *error = output.errorString(); + return false; + } + } + if (!output.commit()) { + *error = output.errorString(); + return false; + } + if (!QFile::setPermissions(destination, QFile::permissions(source))) { + *error = QStringLiteral("Could not apply permissions to %1") + .arg(destination); + return false; + } + return true; +} + +bool writeConfig(const ToolchainPaths& toolchain, QString* error) { + QJsonObject root = readConfig(toolchain.config); + QJsonObject entry = root.value(ATLAS_TOOLCHAIN_VERSION).toObject(); + QJsonObject onboarding = entry.value("onboardingData").toObject(); + onboarding.insert("atlasExecutablePath", toolchain.installedCli); + onboarding.insert("runtimeLib", toolchain.installedRuntime); + entry.insert("onboardingData", onboarding); + root.insert(ATLAS_TOOLCHAIN_VERSION, entry); + + const QFileInfo configInfo(toolchain.config); + if (!QDir().mkpath(configInfo.absolutePath())) { + *error = QStringLiteral("Could not create %1") + .arg(configInfo.absolutePath()); + return false; + } + QSaveFile output(toolchain.config); + if (!output.open(QIODevice::WriteOnly)) { + *error = output.errorString(); + return false; + } + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Indented); + if (output.write(data) != data.size() || !output.commit()) { + *error = output.errorString(); + return false; + } + return true; +} + +bool isInstalled(const ToolchainPaths& toolchain) { + return filesMatch(toolchain.bundledCli, toolchain.installedCli) && + filesMatch(toolchain.bundledRuntime, toolchain.installedRuntime) && + configMatches(toolchain); +} + +bool promptDismissed() { + QSettings settings("Neutral Software", "Atlas Engine"); + return settings.value("toolchain/installationPromptDismissed", false) + .toBool(); +} + +void setPromptDismissed(bool dismissed) { + QSettings settings("Neutral Software", "Atlas Engine"); + settings.setValue("toolchain/installationPromptDismissed", dismissed); + settings.sync(); +} + +bool installToolchain(const ToolchainPaths& toolchain, QWidget* parent, + bool reportExistingInstallation) { + if (isInstalled(toolchain)) { + setPromptDismissed(false); + if (reportExistingInstallation) { + QMessageBox::information( + parent, "Atlas Toolchain Ready", + "The bundled Atlas toolchain is already installed for this user."); + } + return true; + } + + const QFileInfo cliInfo(toolchain.installedCli); + const QFileInfo runtimeInfo(toolchain.installedRuntime); + QString error; + if (!QDir().mkpath(cliInfo.absolutePath()) || + !QDir().mkpath(runtimeInfo.absolutePath())) { + error = "Could not create the Atlas toolchain directory."; + } else if (!copyFile(toolchain.bundledCli, toolchain.installedCli, + &error) || + !copyFile(toolchain.bundledRuntime, + toolchain.installedRuntime, &error) || + !writeConfig(toolchain, &error)) { + } + if (!error.isEmpty()) { + QMessageBox::critical(parent, "Toolchain Installation Failed", error); + return false; + } + + setPromptDismissed(false); + QMessageBox::information( + parent, "Atlas Toolchain Installed", + "The Atlas toolchain is ready. Projects can now be created, run, and exported from Atlas Engine."); + return true; +} +} + +bool ToolchainInstaller::ensureInstalled(QWidget* parent) { + const ToolchainPaths toolchain = paths(); + if (!QFileInfo::exists(toolchain.bundledCli) || + !QFileInfo::exists(toolchain.bundledRuntime)) + return true; + + if (isInstalled(toolchain)) { + setPromptDismissed(false); + return true; + } + if (promptDismissed()) + return false; + + QMessageBox prompt(parent); + prompt.setWindowTitle("Welcome to Atlas Engine"); + prompt.setIcon(QMessageBox::Information); + prompt.setText("Install the Atlas toolchain for this user?"); + prompt.setInformativeText( + "Atlas Engine includes the command-line tools and runtime required to create, run, and export projects. They will be installed in your user Library and do not require administrator access."); + auto* installButton = prompt.addButton("Install Toolchain", + QMessageBox::AcceptRole); + prompt.addButton("Not Now", QMessageBox::RejectRole); + prompt.setDefaultButton(installButton); + prompt.exec(); + if (prompt.clickedButton() != installButton) { + setPromptDismissed(true); + return false; + } + + return installToolchain(toolchain, parent, false); +} + +bool ToolchainInstaller::install(QWidget* parent) { + const ToolchainPaths toolchain = paths(); + if (!QFileInfo::exists(toolchain.bundledCli) || + !QFileInfo::exists(toolchain.bundledRuntime)) { + QMessageBox::warning( + parent, "Atlas Toolchain Unavailable", + "This copy of Atlas Engine does not include the packaged toolchain."); + return false; + } + return installToolchain(toolchain, parent, true); +} diff --git a/editor/assets/AtlasEngine.icon.json b/editor/assets/AtlasEngine.icon.json new file mode 100644 index 00000000..6dbc047d --- /dev/null +++ b/editor/assets/AtlasEngine.icon.json @@ -0,0 +1,43 @@ +{ + "fill": { + "linear-gradient": [ + "srgb:1.00000,0.95276,0.98370,1.00000", + "srgb:0.69138,0.67074,0.76447,1.00000" + ], + "orientation": { + "start": { + "x": 0.5, + "y": 0 + }, + "stop": { + "x": 0.5, + "y": 0.7 + } + } + }, + "groups": [ + { + "layers": [ + { + "glass": true, + "image-name": "atlas_ball_bright.png", + "name": "atlas_ball_bright" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/editor/assets/AtlasEngineDev.icon.json b/editor/assets/AtlasEngineDev.icon.json new file mode 100644 index 00000000..a2c2a8c8 --- /dev/null +++ b/editor/assets/AtlasEngineDev.icon.json @@ -0,0 +1,29 @@ +{ + "fill": { + "automatic-gradient": "extended-srgb:0.00000,0.53333,1.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "image-name": "atlas_ball_bright.png", + "name": "atlas_ball_bright" + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/editor/src/renderer/assets/iconDebug.png b/editor/assets/Icon-iOS-Default-1024x1024@1x.png similarity index 100% rename from editor/src/renderer/assets/iconDebug.png rename to editor/assets/Icon-iOS-Default-1024x1024@1x.png diff --git a/editor/assets/Manrope-VariableFont_wght.ttf b/editor/assets/Manrope-VariableFont_wght.ttf new file mode 100644 index 00000000..765c1b1f Binary files /dev/null and b/editor/assets/Manrope-VariableFont_wght.ttf differ diff --git a/editor/assets/PHOSPHOR_LICENSE.txt b/editor/assets/PHOSPHOR_LICENSE.txt new file mode 100644 index 00000000..c04a19d1 --- /dev/null +++ b/editor/assets/PHOSPHOR_LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-2021 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/editor/assets/Phosphor.ttf b/editor/assets/Phosphor.ttf new file mode 100644 index 00000000..7c1b8a7a Binary files /dev/null and b/editor/assets/Phosphor.ttf differ diff --git a/editor/assets/atlas_ball_bright.png b/editor/assets/atlas_ball_bright.png new file mode 100644 index 00000000..18276831 Binary files /dev/null and b/editor/assets/atlas_ball_bright.png differ diff --git a/editor/src/renderer/assets/iconRelease.png b/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png similarity index 100% rename from editor/src/renderer/assets/iconRelease.png rename to editor/assets/iconFile-iOS-Dark-1024x1024@1x.png diff --git a/editor/assets/iconFile-iOS-Default-1024x1024@1x.png b/editor/assets/iconFile-iOS-Default-1024x1024@1x.png new file mode 100644 index 00000000..90730d9b Binary files /dev/null and b/editor/assets/iconFile-iOS-Default-1024x1024@1x.png differ diff --git a/editor/assets/landing.png b/editor/assets/landing.png new file mode 100644 index 00000000..48f4cf39 Binary files /dev/null and b/editor/assets/landing.png differ diff --git a/editor/binding.gyp b/editor/binding.gyp deleted file mode 100644 index 98f854a2..00000000 --- a/editor/binding.gyp +++ /dev/null @@ -1,26 +0,0 @@ -{ - "targets": [ - { - "target_name": "engine_bridge", - "sources": [ "native/engine_bridge.mm" ], - "include_dirs": [ - "=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], - - "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - - "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - - "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], - - "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], - - "@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="], - - "@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="], - - "@hapi/hoek": ["@hapi/hoek@11.0.7", "", {}, "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ=="], - - "@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="], - - "@hapi/tlds": ["@hapi/tlds@1.1.6", "", {}, "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw=="], - - "@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="], - - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], - - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="], - - "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], - - "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], - - "@npmcli/fs": ["@npmcli/fs@2.1.2", "", { "dependencies": { "@gar/promisify": "^1.1.3", "semver": "^7.3.5" } }, "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ=="], - - "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], - - "@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="], - - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="], - - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], - - "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], - - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], - - "@tootallnate/once": ["@tootallnate/once@2.0.0", "", {}, "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - - "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], - - "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - - "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], - - "@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], - - "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], - - "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], - - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/type-utils": "8.58.1", "@typescript-eslint/utils": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.1", "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1" } }, "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.1", "@typescript-eslint/tsconfig-utils": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.1", "", { "dependencies": { "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], - - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="], - - "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], - - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - - "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - - "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], - - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], - - "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], - - "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], - - "are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], - - "astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="], - - "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], - - "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], - - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - - "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], - - "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], - - "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], - - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - - "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - - "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - - "builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], - - "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - - "cacache": ["cacache@16.1.3", "", { "dependencies": { "@npmcli/fs": "^2.1.0", "@npmcli/move-file": "^2.0.0", "chownr": "^2.0.0", "fs-minipass": "^2.1.0", "glob": "^8.0.1", "infer-owner": "^1.0.4", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^9.0.0", "tar": "^6.1.11", "unique-filename": "^2.0.0" } }, "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ=="], - - "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], - - "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], - - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], - - "chromium-pickle-js": ["chromium-pickle-js@0.2.0", "", {}, "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw=="], - - "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], - - "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], - - "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - - "cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], - - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - - "clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], - - "clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], - - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - - "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], - - "compare-version": ["compare-version@0.1.2", "", {}, "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], - - "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], - - "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], - - "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], - - "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], - - "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - - "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], - - "dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="], - - "dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="], - - "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], - - "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - - "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - - "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - - "electron": ["electron@41.2.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g=="], - - "electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], - - "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], - - "electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], - - "electron-rebuild": ["electron-rebuild@3.2.9", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "fs-extra": "^10.0.0", "got": "^11.7.0", "lzma-native": "^8.0.5", "node-abi": "^3.0.0", "node-api-version": "^0.1.4", "node-gyp": "^9.0.0", "ora": "^5.1.0", "semver": "^7.3.5", "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/src/cli.js" } }, "sha512-FkEZNFViUem3P0RLYbZkUjC8LUFIK+wKq09GHoOITSJjfDAVQv964hwaNseTTWt58sITQX3/5fHNYcTefqaCWw=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], - - "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], - - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - - "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], - - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], - - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], - - "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - - "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - - "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - - "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - - "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], - - "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "got": ["got@11.8.6", "", { "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", "@types/cacheable-request": "^6.0.1", "@types/responselike": "^1.0.0", "cacheable-lookup": "^5.0.3", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", "http2-wrapper": "^1.0.0-beta.5.2", "lowercase-keys": "^2.0.0", "p-cancelable": "^2.0.0", "responselike": "^2.0.0" } }, "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - - "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], - - "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], - - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - - "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], - - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], - - "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], - - "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="], - - "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], - - "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], - - "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], - - "isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], - - "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], - - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - - "joi": ["joi@18.1.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], - - "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], - - "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], - - "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], - - "lzma-native": ["lzma-native@8.0.6", "", { "dependencies": { "node-addon-api": "^3.1.0", "node-gyp-build": "^4.2.1", "readable-stream": "^3.6.0" }, "bin": { "lzmajs": "bin/lzmajs" } }, "sha512-09xfg67mkL2Lz20PrrDeNYZxzeW7ADtpYFbwSQh9U8+76RIzx5QsJBMy8qikv3hbUPfpy6hqwxt6FcGK81g9AA=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "make-fetch-happen": ["make-fetch-happen@10.2.1", "", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], - - "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], - - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], - - "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], - - "minipass-fetch": ["minipass-fetch@2.1.2", "", { "dependencies": { "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA=="], - - "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], - - "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], - - "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], - - "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], - - "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], - - "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], - - "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], - - "node-api-version": ["node-api-version@0.1.4", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g=="], - - "node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="], - - "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], - - "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], - - "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], - - "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], - - "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], - - "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], - - "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - - "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], - - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], - - "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], - - "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="], - - "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], - - "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - - "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], - - "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], - - "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - - "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], - - "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], - - "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], - - "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], - - "react-router-dom": ["react-router-dom@7.14.0", "", { "dependencies": { "react-router": "7.14.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ=="], - - "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], - - "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], - - "responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="], - - "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], - - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], - - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - - "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], - - "rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="], - - "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], - - "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - - "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], - - "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], - - "slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], - - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], - - "socks-proxy-agent": ["socks-proxy-agent@7.0.0", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - - "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], - - "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], - - "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - - "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - - "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - - "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], - - "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], - - "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], - - "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], - - "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], - - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], - - "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], - - "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], - - "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], - - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], - - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], - - "typescript-eslint": ["typescript-eslint@8.58.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.1", "@typescript-eslint/parser": "8.58.1", "@typescript-eslint/typescript-estree": "8.58.1", "@typescript-eslint/utils": "8.58.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg=="], - - "undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], - - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - - "unique-filename": ["unique-filename@2.0.1", "", { "dependencies": { "unique-slug": "^3.0.0" } }, "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A=="], - - "unique-slug": ["unique-slug@3.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w=="], - - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], - - "vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="], - - "wait-on": ["wait-on@9.0.5", "", { "dependencies": { "axios": "^1.15.0", "joi": "^18.1.2", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA=="], - - "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - - "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - - "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], - - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - - "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - - "@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="], - - "@electron/rebuild/node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="], - - "@electron/rebuild/node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="], - - "@electron/rebuild/node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], - - "@electron/rebuild/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], - - "@electron/universal/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], - - "@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - - "@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - - "@isaacs/fs-minipass/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], - - "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "@npmcli/agent/socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@types/cacheable-request/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "@types/fs-extra/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "@types/keyv/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "@types/plist/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "@types/responselike/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "@types/yauzl/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], - - "app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="], - - "app-builder-lib/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], - - "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], - - "cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], - - "cacache/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - - "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], - - "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "electron/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - - "electron-rebuild/node-gyp": ["node-gyp@9.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.0.3", "nopt": "^6.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ=="], - - "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], - - "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - - "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], - - "lzma-native/node-addon-api": ["node-addon-api@3.2.1", "", {}, "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="], - - "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], - - "make-fetch-happen/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], - - "make-fetch-happen/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - - "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "node-gyp/tar": ["tar@7.5.13", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng=="], - - "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], - - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="], - - "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - - "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - - "temp/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], - - "temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], - - "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "@electron/rebuild/node-gyp/make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], - - "@electron/rebuild/node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], - - "@electron/rebuild/node-gyp/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], - - "@electron/rebuild/node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], - - "@electron/rebuild/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - - "@electron/rebuild/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "@electron/rebuild/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "@electron/rebuild/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - - "@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "@types/cacheable-request/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/fs-extra/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/keyv/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/plist/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/responselike/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - - "app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "app-builder-lib/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - - "app-builder-lib/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "app-builder-lib/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "app-builder-lib/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - - "app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - - "cacache/glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - - "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "electron-rebuild/node-gyp/nopt": ["nopt@6.0.0", "", { "dependencies": { "abbrev": "^1.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g=="], - - "electron-rebuild/node-gyp/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - - "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - - "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - - "node-gyp/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], - - "node-gyp/tar/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "node-gyp/tar/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "node-gyp/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - - "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], - - "@electron/rebuild/node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], - - "@electron/rebuild/node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - - "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], - - "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], - - "cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "electron-rebuild/node-gyp/nopt/abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], - - "electron-rebuild/node-gyp/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/minipass-fetch/minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], - - "cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/unique-filename/unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], - - "@electron/rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - } -} diff --git a/editor/core/dockManager.cpp b/editor/core/dockManager.cpp new file mode 100644 index 00000000..71b2faaf --- /dev/null +++ b/editor/core/dockManager.cpp @@ -0,0 +1,62 @@ +/* +* dockManager.cpp +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Dock management functions +* Copyright (c) 2026 Max Van den Eynde +*/ + +#include +#include "DockAreaWidget.h" +#include "DockManager.h" + + +EditorDockManager::EditorDockManager(ads::CDockManager* dockManager) + : dockManager(dockManager) { +} + +ads::DockWidgetArea EditorDockManager::toAdsArea(EditorDockArea area) const { + switch (area) { + case EditorDockArea::Left: + return ads::LeftDockWidgetArea; + case EditorDockArea::Right: + return ads::RightDockWidgetArea; + case EditorDockArea::Bottom: + return ads::BottomDockWidgetArea; + case EditorDockArea::Center: + return ads::CenterDockWidgetArea; + } + + return ads::LeftDockWidgetArea; +} + +ads::CDockWidget* EditorDockManager::addPanel(const EditorDockPanelDesc& desc) { + auto* dock = new ads::CDockWidget(desc.title); + dock->setObjectName(desc.id); + dock->setWidget(desc.widget); + dock->setFeature(ads::CDockWidget::DockWidgetMovable, true); + dock->setFeature(ads::CDockWidget::DockWidgetFloatable, true); + + if (!desc.icon.isNull()) { + dock->setIcon(desc.icon); + } + + if (desc.area == EditorDockArea::Center) { + centerArea = dockManager->setCentralWidget(dock); + if (centerArea != nullptr) { + centerArea->setAllowedAreas(ads::AllDockAreas); + } + } else if (centerArea) { + dockManager->addDockWidget(toAdsArea(desc.area), dock, centerArea); + } else { + dockManager->addDockWidget(toAdsArea(desc.area), dock); + } + + panels.insert(desc.id, dock); + return dock; +} + +ads::CDockWidget* EditorDockManager::panel(const QString& id) const { + return panels.value(id, nullptr); +} diff --git a/editor/debug.cpp b/editor/debug.cpp new file mode 100644 index 00000000..40de2a8d --- /dev/null +++ b/editor/debug.cpp @@ -0,0 +1,421 @@ +/* +* debug.cpp +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Debug view for theming and more +* 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 + +DebugComponentsView::DebugComponentsView(QWidget* parent) + : QWidget(parent) { + auto* rootLayout = new QVBoxLayout(this); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(0); + + auto* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + + auto* content = new QWidget(scrollArea); + auto* contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(12, 12, 12, 12); + contentLayout->setSpacing(12); + + auto* title = new QLabel("Debug Components View", content); + auto* subtitle = new QLabel( + "A test view containing common Qt widgets for checking layout, behavior, focus, interaction, and future theme changes.", + content + ); + subtitle->setWordWrap(true); + + contentLayout->addWidget(title); + contentLayout->addWidget(subtitle); + + contentLayout->addWidget(createBasicControlsSection()); + contentLayout->addWidget(createInputSection()); + contentLayout->addWidget(createSelectionSection()); + contentLayout->addWidget(createRangeSection()); + contentLayout->addWidget(createTextSection()); + contentLayout->addWidget(createItemViewsSection()); + contentLayout->addWidget(createTabsSection()); + contentLayout->addWidget(createCollapsibleSection()); + contentLayout->addWidget(createStatusSection()); + + contentLayout->addStretch(); + + scrollArea->setWidget(content); + rootLayout->addWidget(scrollArea); +} + +QWidget* DebugComponentsView::createSection(const QString& title, QWidget* content) { + auto* groupBox = new QGroupBox(title, this); + + auto* layout = new QVBoxLayout(groupBox); + layout->setContentsMargins(12, 16, 12, 12); + layout->setSpacing(8); + layout->addWidget(content); + + return groupBox; +} + +QWidget* DebugComponentsView::createBasicControlsSection() { + auto* container = new QWidget(this); + auto* layout = new QHBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + auto* normalButton = new QPushButton("Normal", container); + auto* defaultButton = new QPushButton("Default", container); + defaultButton->setDefault(true); + + auto* disabledButton = new QPushButton("Disabled", container); + disabledButton->setEnabled(false); + + auto* checkableButton = new QPushButton("Checkable", container); + checkableButton->setCheckable(true); + checkableButton->setChecked(true); + + auto* toolButton = new QToolButton(container); + toolButton->setText("Tool"); + + layout->addWidget(normalButton); + layout->addWidget(defaultButton); + layout->addWidget(disabledButton); + layout->addWidget(checkableButton); + layout->addWidget(toolButton); + layout->addStretch(); + + return createSection("Basic Controls", container); +} + +QWidget* DebugComponentsView::createInputSection() { + auto* container = new QWidget(this); + auto* layout = new QFormLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + auto* lineEdit = new QLineEdit(container); + lineEdit->setPlaceholderText("Type something..."); + + auto* disabledLineEdit = new QLineEdit(container); + disabledLineEdit->setText("Disabled input"); + disabledLineEdit->setEnabled(false); + + auto* comboBox = new QComboBox(container); + comboBox->addItems({ + "Perspective", + "Orthographic", + "Wireframe", + "Rendered" + }); + + auto* spinBox = new ScrubbableSpinBox(container); + spinBox->setRange(0, 4096); + spinBox->setValue(128); + + auto* doubleSpinBox = new ScrubbableDoubleSpinBox(container); + doubleSpinBox->setRange(0.0, 1.0); + doubleSpinBox->setSingleStep(0.01); + doubleSpinBox->setValue(0.42); + + auto* dateEdit = new QDateEdit(container); + dateEdit->setCalendarPopup(true); + dateEdit->setDate(QDate::currentDate()); + + auto* timeEdit = new QTimeEdit(container); + timeEdit->setTime(QTime::currentTime()); + + layout->addRow("Line edit", lineEdit); + layout->addRow("Disabled line edit", disabledLineEdit); + layout->addRow("Combo box", comboBox); + layout->addRow("Spin box", spinBox); + layout->addRow("Double spin box", doubleSpinBox); + layout->addRow("Date edit", dateEdit); + layout->addRow("Time edit", timeEdit); + + return createSection("Inputs", container); +} + +QWidget* DebugComponentsView::createSelectionSection() { + auto* container = new QWidget(this); + auto* layout = new QHBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(16); + + auto* checkA = new QCheckBox("Visible", container); + checkA->setChecked(true); + + auto* checkB = new QCheckBox("Locked", container); + + auto* checkC = new QCheckBox("Disabled", container); + checkC->setEnabled(false); + + auto* radioA = new QRadioButton("Local", container); + auto* radioB = new QRadioButton("Remote", container); + auto* radioC = new QRadioButton("Cloud", container); + + radioA->setChecked(true); + + layout->addWidget(checkA); + layout->addWidget(checkB); + layout->addWidget(checkC); + layout->addSpacing(20); + layout->addWidget(radioA); + layout->addWidget(radioB); + layout->addWidget(radioC); + layout->addStretch(); + + return createSection("Selection Controls", container); +} + +QWidget* DebugComponentsView::createRangeSection() { + auto* container = new QWidget(this); + auto* layout = new QFormLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + auto* horizontalSlider = new QSlider(Qt::Horizontal, container); + horizontalSlider->setRange(0, 100); + horizontalSlider->setValue(64); + + auto* progressBar = new QProgressBar(container); + progressBar->setRange(0, 100); + progressBar->setValue(72); + + auto* busyProgressBar = new QProgressBar(container); + busyProgressBar->setRange(0, 0); + + auto* dial = new QDial(container); + dial->setRange(0, 100); + dial->setValue(35); + + layout->addRow("Slider", horizontalSlider); + layout->addRow("Progress bar", progressBar); + layout->addRow("Busy progress", busyProgressBar); + layout->addRow("Dial", dial); + + return createSection("Ranges and Progress", container); +} + +QWidget* DebugComponentsView::createTextSection() { + auto* splitter = new QSplitter(Qt::Horizontal, this); + + auto* plainTextEdit = new QPlainTextEdit(splitter); + plainTextEdit->setPlainText( + "void renderFrame(Scene& scene) {\n" + " renderer.beginFrame();\n" + " renderer.draw(scene);\n" + " renderer.endFrame();\n" + "}\n" + ); + + auto* textEdit = new QTextEdit(splitter); + textEdit->setHtml( + "

Rich Text

" + "

This checks rich text rendering, selection, scrolling, focus, and editor behavior.

" + "
    " + "
  • Scene
  • " + "
  • Assets
  • " + "
  • Inspector
  • " + "
" + ); + + splitter->addWidget(plainTextEdit); + splitter->addWidget(textEdit); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 1); + + return createSection("Text Editors", splitter); +} + +QWidget* DebugComponentsView::createItemViewsSection() { + auto* splitter = new QSplitter(Qt::Horizontal, this); + + auto* listWidget = new QListWidget(splitter); + listWidget->addItems({ + "main.scene", + "player.mesh", + "terrain.mesh", + "skybox.material", + "postprocess.shader" + }); + + auto* treeWidget = new QTreeWidget(splitter); + treeWidget->setHeaderLabels({"Object", "Type"}); + + auto* sceneRoot = new QTreeWidgetItem({"Scene", "Root"}); + sceneRoot->addChild(new QTreeWidgetItem({"Camera", "Entity"})); + sceneRoot->addChild(new QTreeWidgetItem({"Directional Light", "Light"})); + sceneRoot->addChild(new QTreeWidgetItem({"Player", "Entity"})); + sceneRoot->addChild(new QTreeWidgetItem({"Terrain", "Mesh"})); + + treeWidget->addTopLevelItem(sceneRoot); + treeWidget->expandAll(); + + auto* tableWidget = new QTableWidget(5, 3, splitter); + tableWidget->setHorizontalHeaderLabels({"Name", "Type", "Size"}); + + tableWidget->setItem(0, 0, new QTableWidgetItem("albedo.png")); + tableWidget->setItem(0, 1, new QTableWidgetItem("Texture")); + tableWidget->setItem(0, 2, new QTableWidgetItem("2.4 MB")); + + tableWidget->setItem(1, 0, new QTableWidgetItem("player.mesh")); + tableWidget->setItem(1, 1, new QTableWidgetItem("Mesh")); + tableWidget->setItem(1, 2, new QTableWidgetItem("8.1 MB")); + + tableWidget->setItem(2, 0, new QTableWidgetItem("main.scene")); + tableWidget->setItem(2, 1, new QTableWidgetItem("Scene")); + tableWidget->setItem(2, 2, new QTableWidgetItem("12 KB")); + + tableWidget->setItem(3, 0, new QTableWidgetItem("pbr.shader")); + tableWidget->setItem(3, 1, new QTableWidgetItem("Shader")); + tableWidget->setItem(3, 2, new QTableWidgetItem("5 KB")); + + tableWidget->setItem(4, 0, new QTableWidgetItem("ambient.wav")); + tableWidget->setItem(4, 1, new QTableWidgetItem("Audio")); + tableWidget->setItem(4, 2, new QTableWidgetItem("3.2 MB")); + + tableWidget->horizontalHeader()->setStretchLastSection(true); + + splitter->addWidget(listWidget); + splitter->addWidget(treeWidget); + splitter->addWidget(tableWidget); + + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 1); + splitter->setStretchFactor(2, 2); + + return createSection("Item Views", splitter); +} + +QWidget* DebugComponentsView::createTabsSection() { + auto* tabs = new QTabWidget(this); + + auto* viewportLabel = new QLabel("Viewport Preview", tabs); + viewportLabel->setAlignment(Qt::AlignCenter); + + auto* inspectorLabel = new QLabel("Inspector", tabs); + inspectorLabel->setAlignment(Qt::AlignCenter); + + auto* console = new QPlainTextEdit(tabs); + console->setPlainText( + "[Info] Atlas Engine started\n" + "[Info] Loaded debug components view\n" + "[Warning] This is a test warning\n" + "[Error] This is a test error\n" + ); + + tabs->addTab(viewportLabel, "Viewport"); + tabs->addTab(inspectorLabel, "Inspector"); + tabs->addTab(console, "Console"); + + return createSection("Tabs", tabs); +} + +QWidget* DebugComponentsView::createCollapsibleSection() { + auto* section = new QWidget(this); + section->setObjectName("collapsibleSection"); + + auto* layout = new QVBoxLayout(section); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + + auto* header = new QToolButton(section); + header->setObjectName("collapsibleHeader"); + header->setText("Transform"); + header->setCheckable(true); + header->setChecked(true); + header->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + header->setArrowType(Qt::DownArrow); + + auto* body = new QWidget(section); + body->setObjectName("collapsibleBody"); + + auto* form = new QFormLayout(body); + form->setContentsMargins(18, 4, 0, 0); + form->setSpacing(8); + + auto* positionX = new ScrubbableDoubleSpinBox(body); + positionX->setRange(-10000.0, 10000.0); + positionX->setValue(12.5); + + auto* positionY = new ScrubbableDoubleSpinBox(body); + positionY->setRange(-10000.0, 10000.0); + positionY->setValue(4.0); + + auto* positionZ = new ScrubbableDoubleSpinBox(body); + positionZ->setRange(-10000.0, 10000.0); + positionZ->setValue(-2.25); + + auto* visible = new QCheckBox("Visible in scene", body); + visible->setChecked(true); + + form->addRow("Position X", positionX); + form->addRow("Position Y", positionY); + form->addRow("Position Z", positionZ); + form->addRow("Visibility", visible); + + QObject::connect(header, &QToolButton::toggled, section, [header, body, section](bool checked) { + header->setArrowType(checked ? Qt::DownArrow : Qt::RightArrow); + body->setVisible(checked); + body->updateGeometry(); + section->updateGeometry(); + if (auto* parent = section->parentWidget()) { + parent->updateGeometry(); + } + }); + + layout->addWidget(header); + layout->addWidget(body); + + return section; +} + +QWidget* DebugComponentsView::createStatusSection() { + auto* container = new QWidget(this); + auto* layout = new QHBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(12); + + layout->addWidget(new QLabel("Ready", container)); + layout->addStretch(); + layout->addWidget(new QLabel("60 FPS", container)); + layout->addWidget(new QLabel("Renderer: Metal", container)); + layout->addWidget(new QLabel("Memory: 421 MB", container)); + + return createSection("Status Row", container); +} diff --git a/editor/electron-builder.config.cjs b/editor/electron-builder.config.cjs deleted file mode 100644 index f98bced8..00000000 --- a/editor/electron-builder.config.cjs +++ /dev/null @@ -1,55 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef -const path = require("node:path"); - -// eslint-disable-next-line no-undef -const mode = process.env.APP_MODE ?? "development"; -const isRelease = mode === "release"; - -// eslint-disable-next-line no-undef -module.exports = { - appId: isRelease ? "org.atlas.editor" : "org.atlas.editor.dev", - productName: isRelease - ? "Atlas Engine" - : "Atlas Engine (Development Build)", - - directories: { - output: isRelease ? "release" : "release-dev", - }, - - files: ["dist/**/*", "dist-electron/**/*", "package.json"], - - mac: { - icon: path.resolve( - isRelease - ? "build/icons/release/icon.icns" - : "build/icons/dev/icon.icns", - ), - identity: "-", - }, - - dmg: { - icon: path.resolve( - isRelease - ? "build/icons/release/icon.icns" - : "build/icons/dev/icon.icns", - ), - }, - - win: { - target: "nsis", - icon: path.resolve( - isRelease - ? "build/icons/release/icon.ico" - : "build/icons/dev/icon.ico", - ), - }, - - linux: { - target: ["AppImage"], - icon: path.resolve( - isRelease - ? "build/icons/release/icon.png" - : "build/icons/dev/icon.png", - ), - }, -}; diff --git a/editor/eslint.config.mjs b/editor/eslint.config.mjs deleted file mode 100644 index ec83bc6f..00000000 --- a/editor/eslint.config.mjs +++ /dev/null @@ -1,43 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import tseslint from "typescript-eslint"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import prettier from "eslint-config-prettier"; - -export default tseslint.config( - { - ignores: ["dist", "dist-electron", "release", "release-dev", "main.js"], - }, - - js.configs.recommended, - ...tseslint.configs.recommended, - - { - files: ["src/renderer/src/**/*.{ts,tsx}"], - languageOptions: { - globals: { - ...globals.browser, - }, - }, - plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": "warn", - }, - }, - - { - files: ["src/**/*.{ts,tsx,js,jsx}"], - languageOptions: { - globals: { - ...globals.node, - }, - }, - }, - - prettier, -); diff --git a/editor/index.html b/editor/index.html deleted file mode 100644 index 7604e76c..00000000 --- a/editor/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - app - - -
- - - diff --git a/editor/macos/Info.plist.in b/editor/macos/Info.plist.in new file mode 100644 index 00000000..fff7b0b4 --- /dev/null +++ b/editor/macos/Info.plist.in @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIconFile + ${MACOSX_BUNDLE_ICON_FILE} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + ${CMAKE_OSX_DEPLOYMENT_TARGET} + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/editor/main.cpp b/editor/main.cpp new file mode 100644 index 00000000..23f52a26 --- /dev/null +++ b/editor/main.cpp @@ -0,0 +1,111 @@ +/* + * main.cpp + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Main entry point for the editor + * Copyright (c) 2026 Max Van den Eynde + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DockManager.h" +#include "DockWidget.h" +#include "../include/editor/application/styling.h" +#include "editor/debug.h" +#include "editor/application/toolchainInstaller.h" +#include "editor/styling/icons.h" +#include "editor/views/editorWindow.h" +#include "editor/views/projectBrowser.h" +#include "editor/views/splashScreen.h" + +int main(int argc, char **argv) { + QApplication app(argc, argv); + app.setApplicationName("Atlas Engine"); + app.setApplicationDisplayName("Atlas Engine"); + app.setOrganizationName("Neutral Software"); + app.setQuitOnLastWindowClosed(true); + + const int manropeFont = QFontDatabase::addApplicationFont( + ":/editor/assets/Manrope-VariableFont_wght.ttf"); + if (manropeFont < 0) { + qWarning() << "Failed to load Manrope"; + } + + const QStringList manropeFamilies = + QFontDatabase::applicationFontFamilies(manropeFont); + QFont applicationFont = manropeFamilies.isEmpty() + ? QFontDatabase::systemFont( + QFontDatabase::GeneralFont) + : QFont(manropeFamilies.first()); + applicationFont.setPointSizeF(11.0); + app.setFont(applicationFont); + styling::loadIconFont(); + +#ifndef Q_OS_MACOS +#ifdef ATLAS_DEBUG_BUILD + app.setWindowIcon( + QIcon(":/editor/assets/Icon-iOS-Default-1024x1024@1x.png")); +#else + app.setWindowIcon( + QIcon(":/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png")); +#endif +#endif + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + app.styleHints()->setColorScheme(Qt::ColorScheme::Dark); +#endif + + app.setStyle("Fusion"); + styling::applyTheme(app); + ToolchainInstaller::ensureInstalled(); + + auto *startupSplash = new SplashScreen(); + startupSplash->start("Preparing the project browser..."); + QTimer::singleShot(0, &app, [&app, startupSplash] { + auto *projectBrowser = new ProjectBrowser(); + QObject::connect( + projectBrowser, &ProjectBrowser::openProjectRequested, &app, + [projectBrowser](const QString &projectFile) { + projectBrowser->setEnabled(false); + projectBrowser->hide(); + auto *splash = new SplashScreen(); + splash->start("Restoring editor workspace..."); + QTimer::singleShot( + 0, splash, [projectBrowser, projectFile, splash] { + auto *editor = new EditorWindow(projectFile); + editor->setAttribute(Qt::WA_DeleteOnClose); + QObject::connect( + editor, &EditorWindow::startupStatusChanged, + splash, &SplashScreen::setStatus); + QObject::connect( + editor, &EditorWindow::startupReady, splash, + [projectBrowser, editor, splash](bool, + const QString &) { + splash->finish(); + splash->deleteLater(); + projectBrowser->deleteLater(); + editor->raise(); + editor->activateWindow(); + }); + editor->show(); + }); + }); + projectBrowser->show(); + projectBrowser->raise(); + projectBrowser->activateWindow(); + startupSplash->finish(); + startupSplash->deleteLater(); + }); + return app.exec(); +} diff --git a/editor/main.js b/editor/main.js deleted file mode 100644 index 290242c4..00000000 --- a/editor/main.js +++ /dev/null @@ -1,109 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const electron_1 = require("electron"); -const node_fs_1 = require("node:fs"); -const node_path_1 = __importDefault(require("node:path")); -const build_1 = require("../shared/generated/build"); -const ipc_1 = require("./ipc"); -let mainWindow = null; -function getWindowIcon() { - const packagedExt = process.platform === "win32" ? "ico" : "png"; - if (electron_1.app.isPackaged) { - const packagedIconPath = node_path_1.default.join(process.resourcesPath, `icon.${packagedExt}`); - return (0, node_fs_1.existsSync)(packagedIconPath) ? packagedIconPath : undefined; - } - const iconPath = process.platform === "win32" - ? node_path_1.default.join(process.cwd(), build_1.DEBUG - ? "build/icons/dev/icon.ico" - : "build/icons/release/icon.ico") - : node_path_1.default.join(process.cwd(), build_1.DEBUG - ? "build/icons/dev/icon.png" - : "build/icons/release/icon.png"); - return (0, node_fs_1.existsSync)(iconPath) ? iconPath : undefined; -} -function getDockIconPath() { - if (process.platform !== "darwin") { - return undefined; - } - if (electron_1.app.isPackaged) { - const packagedDockIconPath = node_path_1.default.join(process.resourcesPath, "icon.icns"); - return (0, node_fs_1.existsSync)(packagedDockIconPath) - ? packagedDockIconPath - : undefined; - } - const devDockIconPath = node_path_1.default.join(process.cwd(), build_1.DEBUG ? "build/icons/dev/icon.png" : "build/icons/release/icon.png"); - return (0, node_fs_1.existsSync)(devDockIconPath) ? devDockIconPath : undefined; -} -function getRendererIndexPath() { - return node_path_1.default.join(electron_1.app.getAppPath(), "dist", "renderer", "index.html"); -} -function getPreloadPath() { - return node_path_1.default.join(electron_1.app.getAppPath(), "dist-electron", "preload", "preload.js"); -} -async function createMainWindow() { - const windowIcon = getWindowIcon(); - const win = new electron_1.BrowserWindow({ - width: 1280, - height: 800, - minWidth: 960, - minHeight: 640, - show: true, - ...(windowIcon ? { icon: windowIcon } : {}), - titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default", - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - mainWindow = win; - win.once("ready-to-show", () => { - win.show(); - }); - win.on("closed", () => { - if (mainWindow === win) { - mainWindow = null; - } - }); - const devServerUrl = "http://localhost:5173/#/test"; - if (!electron_1.app.isPackaged && build_1.DEBUG) { - try { - await win.loadURL(devServerUrl); - return win; - } - catch { - // Fallback to built renderer when the dev server is unavailable. - } - } - await win.loadFile(getRendererIndexPath(), { hash: "/test" }); - return win; -} -electron_1.app.whenReady().then(async () => { - if (process.platform === "darwin") { - const dockIconPath = getDockIconPath(); - if (dockIconPath) { - try { - electron_1.app.dock?.setIcon(dockIconPath); - } - catch { - // Keep startup resilient when icon setup fails. - } - } - } - (0, ipc_1.registerIpcHandlers)(); - await createMainWindow(); - electron_1.app.on("activate", async () => { - if (electron_1.BrowserWindow.getAllWindows().length === 0) { - await createMainWindow(); - } - }); -}); -electron_1.app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - electron_1.app.quit(); - } -}); diff --git a/editor/native/engine_bridge.mm b/editor/native/engine_bridge.mm deleted file mode 100644 index c7ae4adc..00000000 --- a/editor/native/engine_bridge.mm +++ /dev/null @@ -1,550 +0,0 @@ -#import -#import -#include -#include -#include -#include -#include - -using RuntimeCreateFn = void *(*)(const char *projectFile, void *metalView, - void *sdlInputWindow); -using RuntimeEndFn = void (*)(void *runtimeContext); -using RuntimeDestroyFn = void (*)(void *runtimeContext); -using RuntimeResizeFn = bool (*)(void *runtimeContext, int width, int height, - float scale); -using RuntimeSetEditorControlsEnabledFn = bool (*)(void *runtimeContext, - bool enabled); -using RuntimeSetEditorSimulationEnabledFn = bool (*)(void *runtimeContext, - bool enabled); -using RuntimeSetEditorControlModeFn = bool (*)(void *runtimeContext, int mode); -using RuntimeEditorPointerEventFn = bool (*)(void *runtimeContext, int action, - float x, float y, int button, - float scale); -using RuntimeEditorKeyEventFn = bool (*)(void *runtimeContext, int key, - bool pressed); -using RuntimeGetSelectedObjectIdFn = int (*)(void *runtimeContext); -using RuntimeGetSelectedObjectNameFn = const char *(*)(void *runtimeContext); -using RuntimeStepFn = bool (*)(void *runtimeContext); - -struct BridgeState { - void *dylibHandle = nullptr; - - RuntimeCreateFn createFn = nullptr; - RuntimeEndFn endFn = nullptr; - RuntimeDestroyFn destroyFn = nullptr; - RuntimeResizeFn resizeFn = nullptr; - RuntimeSetEditorControlsEnabledFn setEditorControlsEnabledFn = nullptr; - RuntimeSetEditorSimulationEnabledFn setEditorSimulationEnabledFn = nullptr; - RuntimeSetEditorControlModeFn setEditorControlModeFn = nullptr; - RuntimeEditorPointerEventFn editorPointerEventFn = nullptr; - RuntimeEditorKeyEventFn editorKeyEventFn = nullptr; - RuntimeGetSelectedObjectIdFn getSelectedObjectIdFn = nullptr; - RuntimeGetSelectedObjectNameFn getSelectedObjectNameFn = nullptr; - RuntimeStepFn stepFn = nullptr; - - void *runtimeContext = nullptr; - - NSView *hostView = nil; - NSView *childView = nil; -}; - -struct BridgeState bridgeState; - -static void sendEditorPointerEvent(NSEvent *event, NSView *view, int action) { - if (!bridgeState.runtimeContext || !bridgeState.editorPointerEventFn || - !view) { - return; - } - - NSPoint point = [view convertPoint:[event locationInWindow] fromView:nil]; - NSRect bounds = [view bounds]; - CGFloat y = bounds.size.height - point.y; - NSInteger buttonNumber = [event buttonNumber]; - int button = buttonNumber <= 0 ? 1 : static_cast(buttonNumber + 1); - CGFloat scale = 1.0; - if ([view window]) { - CGFloat backingScale = [[view window] backingScaleFactor]; - if (backingScale > 0.0) { - scale = backingScale; - } - } - bridgeState.editorPointerEventFn(bridgeState.runtimeContext, action, - static_cast(point.x), - static_cast(y), button, - static_cast(scale)); -} - -static int editorKeyFromEvent(NSEvent *event) { - switch ([event keyCode]) { - case 126: - return 0; - case 125: - return 1; - case 123: - return 2; - case 124: - return 3; - default: - return -1; - } -} - -static bool sendEditorKeyEvent(NSEvent *event, bool pressed) { - if (!bridgeState.runtimeContext || !bridgeState.editorKeyEventFn) { - return false; - } - int key = editorKeyFromEvent(event); - if (key < 0) { - return false; - } - bridgeState.editorKeyEventFn(bridgeState.runtimeContext, key, pressed); - return true; -} - -@interface AtlasRuntimeView : NSView -@end - -@implementation AtlasRuntimeView -- (BOOL)acceptsFirstResponder { - return YES; -} - -- (BOOL)acceptsFirstMouse:(NSEvent *)event { - (void)event; - return YES; -} - -- (void)viewDidMoveToWindow { - [super viewDidMoveToWindow]; - if ([self window]) { - [[self window] makeFirstResponder:self]; - } -} - -- (BOOL)isFlipped { - return YES; -} - -- (void)mouseDown:(NSEvent *)event { - [[self window] makeFirstResponder:self]; - sendEditorPointerEvent(event, self, 0); -} - -- (void)mouseDragged:(NSEvent *)event { - sendEditorPointerEvent(event, self, 1); -} - -- (void)mouseUp:(NSEvent *)event { - sendEditorPointerEvent(event, self, 2); -} - -- (void)rightMouseDown:(NSEvent *)event { - [[self window] makeFirstResponder:self]; - sendEditorPointerEvent(event, self, 0); -} - -- (void)rightMouseDragged:(NSEvent *)event { - sendEditorPointerEvent(event, self, 1); -} - -- (void)rightMouseUp:(NSEvent *)event { - sendEditorPointerEvent(event, self, 2); -} - -- (void)keyDown:(NSEvent *)event { - if (!bridgeState.runtimeContext || !bridgeState.setEditorControlModeFn) { - if (!sendEditorKeyEvent(event, true)) { - [super keyDown:event]; - } - return; - } - - NSString *characters = [event charactersIgnoringModifiers]; - if ([characters length] == 0) { - if (!sendEditorKeyEvent(event, true)) { - [super keyDown:event]; - } - return; - } - - unichar key = [[characters lowercaseString] characterAtIndex:0]; - int mode = -1; - if (key == 'q') { - mode = 0; - } else if (key == 'w') { - mode = 1; - } else if (key == 'e') { - mode = 2; - } else if (key == 'r') { - mode = 3; - } - - if (mode >= 0) { - bridgeState.setEditorControlModeFn(bridgeState.runtimeContext, mode); - } else if (sendEditorKeyEvent(event, true)) { - return; - } else { - [super keyDown:event]; - } -} - -- (void)keyUp:(NSEvent *)event { - if (!sendEditorKeyEvent(event, false)) { - [super keyUp:event]; - } -} -@end - -static void unloadEditorIfNeeded() { - if (bridgeState.runtimeContext && bridgeState.endFn) { - bridgeState.endFn(bridgeState.runtimeContext); - } - - if (bridgeState.runtimeContext && bridgeState.destroyFn) { - bridgeState.destroyFn(bridgeState.runtimeContext); - bridgeState.runtimeContext = nullptr; - } - - if (bridgeState.childView) { - [bridgeState.childView removeFromSuperview]; - bridgeState.childView = nil; - } - - if (bridgeState.dylibHandle) { - dlclose(bridgeState.dylibHandle); - bridgeState.dylibHandle = nullptr; - } - - bridgeState.createFn = nullptr; - bridgeState.endFn = nullptr; - bridgeState.destroyFn = nullptr; - bridgeState.resizeFn = nullptr; - bridgeState.setEditorControlsEnabledFn = nullptr; - bridgeState.setEditorSimulationEnabledFn = nullptr; - bridgeState.setEditorControlModeFn = nullptr; - bridgeState.editorPointerEventFn = nullptr; - bridgeState.editorKeyEventFn = nullptr; - bridgeState.getSelectedObjectIdFn = nullptr; - bridgeState.getSelectedObjectNameFn = nullptr; - bridgeState.stepFn = nullptr; - bridgeState.hostView = nil; -} - -static void *requireSymbol(void *handle, const char *name) { - void *sym = dlsym(handle, name); - if (!sym) { - throw std::runtime_error(std::string("Failed to load symbol: ") + name); - } - return sym; -} - -Napi::Value LoadLibrary(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (info.Length() < 1 || !info[0].IsString()) { - throw Napi::TypeError::New(env, - "loadLibrary(path) requires a string path"); - } - - unloadEditorIfNeeded(); - - std::string path = info[0].As().Utf8Value(); - - void *handle = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); - if (!handle) { - throw Napi::Error::New(env, dlerror() ? dlerror() : "dlopen failed"); - } - - bridgeState.dylibHandle = handle; - bridgeState.createFn = reinterpret_cast( - requireSymbol(handle, "atlas_runtime_create_metal_view_context")); - bridgeState.endFn = reinterpret_cast( - requireSymbol(handle, "atlas_runtime_end_context")); - bridgeState.destroyFn = reinterpret_cast( - requireSymbol(handle, "atlas_runtime_destroy_context")); - bridgeState.resizeFn = reinterpret_cast( - requireSymbol(handle, "atlas_runtime_resize_context")); - bridgeState.setEditorControlsEnabledFn = - reinterpret_cast(requireSymbol( - handle, "atlas_runtime_set_editor_controls_enabled")); - bridgeState.setEditorSimulationEnabledFn = - reinterpret_cast(requireSymbol( - handle, "atlas_runtime_set_editor_simulation_enabled")); - bridgeState.setEditorControlModeFn = - reinterpret_cast( - requireSymbol(handle, "atlas_runtime_set_editor_control_mode")); - bridgeState.editorPointerEventFn = - reinterpret_cast( - requireSymbol(handle, "atlas_runtime_editor_pointer_event")); - bridgeState.editorKeyEventFn = reinterpret_cast( - requireSymbol(handle, "atlas_runtime_editor_key_event")); - bridgeState.getSelectedObjectIdFn = - reinterpret_cast( - requireSymbol(handle, "atlas_runtime_get_selected_object_id")); - bridgeState.getSelectedObjectNameFn = - reinterpret_cast( - requireSymbol(handle, "atlas_runtime_get_selected_object_name")); - bridgeState.stepFn = reinterpret_cast( - requireSymbol(handle, "atlas_runtime_step_frame")); - - return env.Undefined(); -} - -Napi::Value AttachToNativeWindow(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.createFn) { - throw Napi::Error::New(env, "Library not loaded"); - } - - if (info.Length() < 2 || !info[0].IsString() || !info[1].IsBuffer()) { - throw Napi::TypeError::New( - env, - "attachToNativeWindow(projectFile, handleBuffer[, sdlInputWindow]) " - "requires a string project path and a Buffer"); - } - - std::string projectFile = info[0].As().Utf8Value(); - if (projectFile.empty()) { - throw Napi::Error::New(env, "Project file path cannot be empty"); - } - - auto buf = info[1].As>(); - if (buf.Length() < sizeof(void *)) { - throw Napi::Error::New(env, "Native handle buffer too small"); - } - - void *rawPtr = *reinterpret_cast(buf.Data()); - NSView *hostView = (__bridge NSView *)rawPtr; - if (!hostView) { - throw Napi::Error::New(env, "Host NSView is null"); - } - - bridgeState.hostView = hostView; - - NSRect bounds = [hostView bounds]; - NSView *child = [[AtlasRuntimeView alloc] initWithFrame:bounds]; - [child setAutoresizingMask:(NSViewWidthSizable | NSViewHeightSizable)]; - [hostView addSubview:child positioned:NSWindowAbove relativeTo:nil]; - bridgeState.childView = child; - if ([child window]) { - [[child window] makeFirstResponder:child]; - } - - NSLog(@"[runtime] create_metal_view_context called"); - NSLog(@"[runtime] parentView=%p", child); - NSLog(@"[runtime] isMainThread=%@", - [NSThread isMainThread] ? @"YES" : @"NO"); - NSLog(@"[runtime] parentView.window=%p", child ? [child window] : nil); - NSLog(@"[runtime] bounds=%@", - child ? NSStringFromRect([child bounds]) : @""); - - void *sdlInputWindow = nullptr; - if (info.Length() >= 3 && info[2].IsBuffer()) { - auto sdlWindowBuf = info[2].As>(); - if (sdlWindowBuf.Length() < sizeof(void *)) { - throw Napi::Error::New(env, "SDL input window buffer too small"); - } - sdlInputWindow = *reinterpret_cast(sdlWindowBuf.Data()); - } - - bridgeState.runtimeContext = - bridgeState.createFn(projectFile.c_str(), child, sdlInputWindow); - if (!bridgeState.runtimeContext) { - throw Napi::Error::New( - env, - "atlas_runtime_create_metal_view_context returned null"); - } - - if (bridgeState.setEditorControlsEnabledFn) { - bridgeState.setEditorControlsEnabledFn(bridgeState.runtimeContext, true); - } - if (bridgeState.setEditorSimulationEnabledFn) { - bridgeState.setEditorSimulationEnabledFn(bridgeState.runtimeContext, - false); - } - if (bridgeState.setEditorControlModeFn) { - bridgeState.setEditorControlModeFn(bridgeState.runtimeContext, 1); - } - - if (bridgeState.stepFn) { - bridgeState.stepFn(bridgeState.runtimeContext); - } - - return env.Undefined(); -} - -static void resizeChildView(NSView *childView, int width, int height) { - if (!childView) { - return; - } - - NSRect frame = NSMakeRect(0, 0, width, height); - if ([NSThread isMainThread]) { - [childView setFrame:frame]; - return; - } - - dispatch_sync(dispatch_get_main_queue(), ^{ - [childView setFrame:frame]; - }); -} - -Napi::Value Resize(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.runtimeContext || !bridgeState.resizeFn) { - return env.Undefined(); - } - - if (info.Length() < 3) { - throw Napi::TypeError::New(env, "resize(width, height, scale)"); - } - - int width = info[0].As().Int32Value(); - int height = info[1].As().Int32Value(); - float scale = info[2].As().FloatValue(); - float effectiveScale = scale > 0.0f ? scale : 1.0f; - - if (bridgeState.hostView && [bridgeState.hostView window]) { - CGFloat backingScale = [[bridgeState.hostView window] backingScaleFactor]; - if (backingScale > 0.0) { - effectiveScale = static_cast(backingScale); - } - } - - resizeChildView(bridgeState.childView, width, height); - - if (!bridgeState.resizeFn(bridgeState.runtimeContext, width, height, - effectiveScale)) { - throw Napi::Error::New(env, "atlas_runtime_resize_context failed"); - } - - return env.Undefined(); -} - -Napi::Value SetEditorControlMode(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.runtimeContext || !bridgeState.setEditorControlModeFn) { - return env.Undefined(); - } - - if (info.Length() < 1 || !info[0].IsNumber()) { - throw Napi::TypeError::New(env, "setEditorControlMode(mode)"); - } - - int mode = info[0].As().Int32Value(); - if (!bridgeState.setEditorControlModeFn(bridgeState.runtimeContext, mode)) { - throw Napi::Error::New(env, "atlas_runtime_set_editor_control_mode failed"); - } - - return env.Undefined(); -} - -Napi::Value SetEditorControlsEnabled(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.runtimeContext || - !bridgeState.setEditorControlsEnabledFn) { - return env.Undefined(); - } - - if (info.Length() < 1 || !info[0].IsBoolean()) { - throw Napi::TypeError::New(env, "setEditorControlsEnabled(enabled)"); - } - - bool enabled = info[0].As().Value(); - if (!bridgeState.setEditorControlsEnabledFn(bridgeState.runtimeContext, - enabled)) { - throw Napi::Error::New( - env, "atlas_runtime_set_editor_controls_enabled failed"); - } - - return env.Undefined(); -} - -Napi::Value SetEditorSimulationEnabled(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.runtimeContext || - !bridgeState.setEditorSimulationEnabledFn) { - return env.Undefined(); - } - - if (info.Length() < 1 || !info[0].IsBoolean()) { - throw Napi::TypeError::New(env, - "setEditorSimulationEnabled(enabled)"); - } - - bool enabled = info[0].As().Value(); - if (!bridgeState.setEditorSimulationEnabledFn(bridgeState.runtimeContext, - enabled)) { - throw Napi::Error::New( - env, "atlas_runtime_set_editor_simulation_enabled failed"); - } - - return env.Undefined(); -} - -Napi::Value GetSelectedObjectId(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.runtimeContext || !bridgeState.getSelectedObjectIdFn) { - return Napi::Number::New(env, -1); - } - - return Napi::Number::New( - env, bridgeState.getSelectedObjectIdFn(bridgeState.runtimeContext)); -} - -Napi::Value GetSelectedObjectName(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (!bridgeState.runtimeContext || !bridgeState.getSelectedObjectNameFn) { - return Napi::String::New(env, ""); - } - - const char *name = - bridgeState.getSelectedObjectNameFn(bridgeState.runtimeContext); - return Napi::String::New(env, name ? name : ""); -} - -Napi::Value Step(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - - if (bridgeState.runtimeContext && bridgeState.stepFn) { - bridgeState.stepFn(bridgeState.runtimeContext); - } - - return env.Undefined(); -} - -Napi::Value Shutdown(const Napi::CallbackInfo &info) { - Napi::Env env = info.Env(); - unloadEditorIfNeeded(); - return env.Undefined(); -} - -Napi::Object Init(Napi::Env env, Napi::Object exports) { - exports.Set("loadLibrary", Napi::Function::New(env, LoadLibrary)); - exports.Set("attachToNativeWindow", - Napi::Function::New(env, AttachToNativeWindow)); - exports.Set("resize", Napi::Function::New(env, Resize)); - exports.Set("resizeEditor", Napi::Function::New(env, Resize)); - exports.Set("setEditorControlsEnabled", - Napi::Function::New(env, SetEditorControlsEnabled)); - exports.Set("setEditorSimulationEnabled", - Napi::Function::New(env, SetEditorSimulationEnabled)); - exports.Set("setEditorControlMode", - Napi::Function::New(env, SetEditorControlMode)); - exports.Set("getSelectedObjectId", - Napi::Function::New(env, GetSelectedObjectId)); - exports.Set("getSelectedObjectName", - Napi::Function::New(env, GetSelectedObjectName)); - exports.Set("step", Napi::Function::New(env, Step)); - exports.Set("shutdown", Napi::Function::New(env, Shutdown)); - return exports; -} - -NODE_API_MODULE(engine_bridge, Init) diff --git a/editor/package.json b/editor/package.json deleted file mode 100644 index 1b9342dc..00000000 --- a/editor/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "atlas-engine", - "version": "0.0.0-alpha9", - "private": true, - "main": "dist-electron/main/main.js", - "scripts": { - "generate:build:dev": "cross-env APP_MODE=development bun run scripts/generate-build.ts", - "generate:build:release": "cross-env APP_MODE=release bun run scripts/generate-build.ts", - "dev:vite": "vite", - "build:renderer": "vite build", - "build:main": "tsc -p tsconfig.main.json", - "build:preload": "tsc -p tsconfig.preload.json", - "build:electron": "bun run build:main && bun run build:preload", - "build:native": "node-gyp rebuild && electron-rebuild -f -w engine_bridge", - "watch:main": "tsc -p tsconfig.main.json --watch", - "watch:preload": "tsc -p tsconfig.preload.json --watch", - "dev:prepare": "bun run generate:build:dev && bun run build:electron", - "dev:electron": "wait-on http://localhost:5173 && electron .", - "dev": "bun run dev:prepare && concurrently \"bun run dev:vite\" \"bun run watch:main\" \"bun run watch:preload\" \"bun run dev:electron\"", - "release": "cross-env APP_MODE=release bun run generate:build:release && bun run build:renderer && bun run build:electron && cross-env APP_MODE=release electron-builder --config electron-builder.config.cjs", - "dist:debug": "cross-env APP_MODE=development bun run generate:build:dev && bun run build:renderer && bun run build:electron && cross-env APP_MODE=development electron-builder --config electron-builder.config.cjs", - "lint": "eslint ." - }, - "dependencies": { - "clsx": "^2.1.1", - "lucide-react": "^1.8.0", - "node-addon-api": "^8.7.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0", - "tailwind-merge": "^3.5.0" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@tailwindcss/vite": "^4.2.2", - "@types/node": "^25.6.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "concurrently": "^9.2.1", - "cross-env": "^10.1.0", - "electron": "^41.2.0", - "electron-builder": "^26.8.1", - "electron-rebuild": "^3.2.9", - "eslint": "^10.2.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", - "node-gyp": "^12.3.0", - "prettier": "^3.8.2", - "tailwindcss": "^4.2.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.1", - "vite": "^8.0.4", - "wait-on": "^9.0.5" - } -} diff --git a/editor/project/projectStore.cpp b/editor/project/projectStore.cpp new file mode 100644 index 00000000..c3b4c3eb --- /dev/null +++ b/editor/project/projectStore.cpp @@ -0,0 +1,306 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QString normalizedProjectPath(const QString& projectFile) { + QFileInfo info(projectFile); + const QString canonical = info.canonicalFilePath(); + return canonical.isEmpty() ? info.absoluteFilePath() : canonical; +} + +QString tomlString(QString value) { + value.replace('\\', "\\\\"); + value.replace('"', "\\\""); + value.replace('\n', "\\n"); + return value; +} + +bool writeFile(const QString& path, const QByteArray& contents, + QString* errorMessage) { + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + if (errorMessage != nullptr) { + *errorMessage = file.errorString(); + } + return false; + } + if (file.write(contents) != contents.size() || !file.commit()) { + if (errorMessage != nullptr) { + *errorMessage = file.errorString(); + } + return false; + } + return true; +} + +QString projectConfig(const QString& name, + AtlasProjectTemplate projectTemplate) { + QString renderer = "deferred"; + const bool globalIllumination = + projectTemplate == AtlasProjectTemplate::PbrDdgi; + if (projectTemplate == AtlasProjectTemplate::PathTracing) { + renderer = "pathtracing"; + } + + QString config; + QTextStream stream(&config); + stream << "app_name = \"" << tomlString(name) << "\"\n"; + stream << "atlas_version = \"alpha9\"\n"; + stream << "backend = \"AUTO\"\n"; + stream << "name = \"" << tomlString(name) << "\"\n"; + stream << "platform = \"DESKTOP\"\n\n"; + stream << "[game]\n"; + stream << "assets = [\"assets/\"]\n"; + stream << "main_scene = \"main.ascene\"\n\n"; + stream << "[pack]\n"; + stream << "icon = \"none\"\n"; + stream << "supported_platforms = \"all\"\n\n"; + stream << "[renderer]\n"; + stream << "default = \"" << renderer << "\"\n"; + stream << "global_illumination = " + << (globalIllumination ? "true" : "false") << "\n\n"; + stream << "[window]\n"; + stream << "dimensions = [1280, 720]\n"; + stream << "mouse_capture = false\n"; + stream << "multisampling = " + << (projectTemplate == AtlasProjectTemplate::PathTracing ? "false" + : "true") + << "\n"; + stream << "ssaoScale = 0.5\n"; + return config; +} + +QByteArray starterScene(AtlasProjectTemplate projectTemplate) { + QByteArray scene = QByteArrayLiteral(R"({ + "name": "Main Scene", + "id": "main_scene", + "objects": [ + { + "name": "Cube", + "type": "solid", + "solid_type": "cube", + "position": [0.0, 0.0, 0.0], + "rotation": [0.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0], + "material": "", + "components": [] + } + ], + "lights": [ + { + "type": "ambient", + "intensity": 0.25 + } + ], + "camera": { + "position": [0.0, 1.5, -5.0], + "target": [0.0, 0.0, 0.0], + "fov": 60.0 + }, + "targets": [ + { + "name": "Main Target", + "type": "%RENDER_TARGET_TYPE%", + "render": true, + "display": true + } + ], + "environment": { + "automaticAmbient": true, + "atmosphereSky": true + } +} +)"); + scene.replace("%RENDER_TARGET_TYPE%", + projectTemplate == AtlasProjectTemplate::PathTracing + ? "scene" + : "multisampled"); + return scene; +} + +QString capture(const QString& contents, const QString& pattern) { + const QRegularExpression expression( + pattern, QRegularExpression::MultilineOption); + const QRegularExpressionMatch match = expression.match(contents); + return match.hasMatch() ? match.captured(1) : QString(); +} +} + +QList ProjectStore::recentProjects() { + QSettings settings("Neutral Software", "Atlas Engine"); + const QStringList recent = + settings.value("projects/recentFiles").toStringList(); + QList projects; + for (const QString& path : recent) { + const auto info = projectInfo(path); + if (info.has_value()) { + projects.append(*info); + } + } + return projects; +} + +std::optional ProjectStore::projectInfo( + const QString& projectFile) { + if (projectFile.trimmed().isEmpty()) { + return std::nullopt; + } + + const QFileInfo fileInfo(projectFile); + AtlasProjectInfo result; + result.projectFile = normalizedProjectPath(projectFile); + result.directory = QFileInfo(result.projectFile).absolutePath(); + result.name = QFileInfo(result.directory).fileName(); + result.renderer = "Unknown"; + result.available = isProjectFile(result.projectFile); + result.lastModified = fileInfo.lastModified(); + + if (!result.available) { + return result; + } + + QFile file(result.projectFile); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return result; + } + const QString contents = QString::fromUtf8(file.readAll()); + const QString configuredName = + capture(contents, QStringLiteral("^name\\s*=\\s*\"([^\"]+)\"")); + if (!configuredName.isEmpty()) { + result.name = configuredName; + } + + const QString renderer = capture( + contents, QStringLiteral("^default\\s*=\\s*\"([^\"]+)\"")); + const bool ddgi = QRegularExpression( + QStringLiteral( + "^global_illumination\\s*=\\s*true\\s*$"), + QRegularExpression::MultilineOption | + QRegularExpression::CaseInsensitiveOption) + .match(contents) + .hasMatch(); + if (renderer.compare("pathtracing", Qt::CaseInsensitive) == 0) { + result.renderer = "Path Tracing"; + } else if (ddgi) { + result.renderer = "PBR + DDGI"; + } else { + result.renderer = "PBR"; + } + return result; +} + +QString ProjectStore::createProject(const QString& name, + const QString& parentDirectory, + AtlasProjectTemplate projectTemplate, + QString* errorMessage) { + const QString trimmedName = name.trimmed(); + if (trimmedName.isEmpty()) { + if (errorMessage != nullptr) { + *errorMessage = "Enter a project name."; + } + return QString(); + } + if (trimmedName.contains(QRegularExpression(QStringLiteral( + R"([/\\:*?"<>|]))")))) { + if (errorMessage != nullptr) { + *errorMessage = "The project name contains unsupported characters."; + } + return QString(); + } + + QDir parent(parentDirectory); + if (!parent.exists()) { + if (errorMessage != nullptr) { + *errorMessage = "Choose an existing project location."; + } + return QString(); + } + + const QString projectDirectory = parent.filePath(trimmedName); + if (QFileInfo::exists(projectDirectory)) { + if (errorMessage != nullptr) { + *errorMessage = "A folder with this project name already exists."; + } + return QString(); + } + + QDir root; + if (!root.mkpath(projectDirectory + "/assets/scripts")) { + if (errorMessage != nullptr) { + *errorMessage = "Atlas could not create the project folder."; + } + return QString(); + } + + const QString projectFile = projectDirectory + "/project.atlas"; + QString writeError; + const bool wroteProject = + writeFile(projectFile, projectConfig(trimmedName, projectTemplate).toUtf8(), + &writeError); + const bool wroteScene = + wroteProject && writeFile(projectDirectory + "/main.ascene", + starterScene(projectTemplate), &writeError); + if (!wroteProject || !wroteScene) { + QDir(projectDirectory).removeRecursively(); + if (errorMessage != nullptr) { + *errorMessage = writeError.isEmpty() + ? "Atlas could not write the project files." + : writeError; + } + return QString(); + } + + addRecentProject(projectFile); + return normalizedProjectPath(projectFile); +} + +bool ProjectStore::isProjectFile(const QString& projectFile) { + const QFileInfo info(projectFile); + return info.exists() && info.isFile() && info.isReadable() && + info.suffix().compare("atlas", Qt::CaseInsensitive) == 0; +} + +void ProjectStore::addRecentProject(const QString& projectFile) { + const QString normalized = normalizedProjectPath(projectFile); + if (normalized.isEmpty()) { + return; + } + QSettings settings("Neutral Software", "Atlas Engine"); + QStringList recent = settings.value("projects/recentFiles").toStringList(); + recent.removeAll(normalized); + recent.prepend(normalized); + while (recent.size() > 20) { + recent.removeLast(); + } + settings.setValue("projects/recentFiles", recent); +} + +void ProjectStore::removeRecentProject(const QString& projectFile) { + const QString normalized = normalizedProjectPath(projectFile); + QSettings settings("Neutral Software", "Atlas Engine"); + QStringList recent = settings.value("projects/recentFiles").toStringList(); + recent.removeAll(normalized); + settings.setValue("projects/recentFiles", recent); +} + +QString ProjectStore::templateName(AtlasProjectTemplate projectTemplate) { + switch (projectTemplate) { + case AtlasProjectTemplate::Pbr: + return "PBR"; + case AtlasProjectTemplate::PbrDdgi: + return "PBR + DDGI"; + case AtlasProjectTemplate::PathTracing: + return "Path Tracing"; + } + return "PBR"; +} diff --git a/editor/scripts/generate-build.ts b/editor/scripts/generate-build.ts deleted file mode 100644 index 26f21f13..00000000 --- a/editor/scripts/generate-build.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -const mode = process.env.APP_MODE ?? "development"; -const debug = mode !== "release"; - -const now = new Date(); -const yyyy = String(now.getFullYear()); -const mm = String(now.getMonth() + 1).padStart(2, "0"); -const dd = String(now.getDate()).padStart(2, "0"); -const buildId = `${yyyy}${mm}${dd}`; - -const content = `export const DEBUG = ${debug} as const; -export const BUILDID = "${buildId}" as const; -export const APP_MODE = "${mode}" as const; -`; - -const outDir = path.resolve("src/shared/generated"); -await mkdir(outDir, { recursive: true }); -await writeFile(path.join(outDir, "build.ts"), content, "utf8"); - -console.log("Generated build constants:", { mode, debug, buildId }); diff --git a/editor/src/main/ipc.ts b/editor/src/main/ipc.ts deleted file mode 100644 index 4b239110..00000000 --- a/editor/src/main/ipc.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { ipcMain, BrowserWindow } from "electron"; -import { BUILDID, DEBUG } from "../shared/generated/build"; -import { tasks } from "./tasks/register"; -import { allWindows, engineBridge } from "./main"; -import { makerRegistry } from "./windows"; -import { EditorControlMode, WindowMaker } from "src/shared/types/ipc"; -import { createProject } from "./tasks/create-project"; -import { getProjects } from "./tasks/startup"; - -type OnboardingDataPayload = { - runtimePath: string | null; - executablePath: string | null; -}; - -export let currentProjectPath: string | null = null; - -const editorControlModes: Record = { - none: 0, - move: 1, - rotate: 2, - scale: 3, -}; - -export function registerIpcHandlers() { - ipcMain.handle("app:get-info", () => { - return { - debug: DEBUG, - buildId: BUILDID, - platform: process.platform, - }; - }); - - ipcMain.handle("window:set-title", (event, title: string) => { - const win = BrowserWindow.fromWebContents(event.sender); - win?.setTitle(title); - }); - - ipcMain.handle("startup-task:start", () => { - return tasks.start("startup-task"); - }); - - ipcMain.handle( - "store-onboarding-data", - (_event, payload: OnboardingDataPayload) => { - return tasks.start( - "store-onboarding-data", - payload.runtimePath, - payload.executablePath, - ); - }, - ); - - ipcMain.on("window:show", (event, eventId: string) => { - for (const { id, window } of allWindows) { - if (eventId === id && !window.isDestroyed()) { - window.show(); - return; - } - } - - if (eventId in makerRegistry) { - (makerRegistry[eventId] as WindowMaker)(); - } - }); - - ipcMain.on("window:hide", (event, eventId: string) => { - for (const { id, window } of allWindows) { - if (eventId === id && !window.isDestroyed()) { - window.hide(); - return; - } - } - }); - - ipcMain.on("window:destroy", (event, eventId: string) => { - for (const { id, window } of allWindows) { - if (eventId === id && !window.isDestroyed()) { - window.close(); - return; - } - } - }); - - ipcMain.handle("file-dialog", async (event, options) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) { - throw new Error("No window found for file dialog"); - } - - const { dialog } = await import("electron"); - const result = await dialog.showOpenDialog(win, options); - return result.canceled ? undefined : result.filePaths; - }); - - ipcMain.handle("general:get-projects", async () => { - return getProjects(); - }); - - ipcMain.handle("general:create-project", async (_event, payload) => { - return createProject(payload); - }); - - ipcMain.handle("general:open-project", async (_event, payload) => { - currentProjectPath = payload.path; - }); - - ipcMain.handle("editor-controls:set-enabled", async (_event, enabled) => { - engineBridge.setEditorControlsEnabled(Boolean(enabled)); - }); - - ipcMain.handle("editor-controls:set-playing", async (_event, playing) => { - engineBridge.setEditorSimulationEnabled(Boolean(playing)); - }); - - ipcMain.handle("editor-controls:set-mode", async (_event, mode) => { - const numericMode = - editorControlModes[mode as EditorControlMode] ?? - editorControlModes.none; - engineBridge.setEditorControlMode(numericMode); - }); - - ipcMain.handle("editor-controls:get-selection", async () => { - return { - id: engineBridge.getSelectedObjectId(), - name: engineBridge.getSelectedObjectName(), - }; - }); -} diff --git a/editor/src/main/main.ts b/editor/src/main/main.ts deleted file mode 100644 index 5b8e3de4..00000000 --- a/editor/src/main/main.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { app, BrowserWindow } from "electron"; -import { existsSync } from "node:fs"; -import path from "node:path"; -import { DEBUG } from "../shared/generated/build"; -import { registerIpcHandlers } from "./ipc"; -import { registerAllTasks } from "./tasks/register"; -import { WindowHandle } from "src/shared/types/ipc"; - -export let mainWindow: BrowserWindow | null = null; -export const allWindows: WindowHandle[] = []; - -export const VERSION_ID = "alpha9dev"; - -export function setMainWindow(win: BrowserWindow | null) { - mainWindow = win; -} - -// eslint-disable-next-line @typescript-eslint/no-require-imports -export const engineBridge = require( - path.join(__dirname, "../../build/Release/engine_bridge.node"), -); - -export function getWindowIcon() { - const packagedExt = process.platform === "win32" ? "ico" : "png"; - - if (app.isPackaged) { - const packagedIconPath = path.join( - process.resourcesPath, - `icon.${packagedExt}`, - ); - return existsSync(packagedIconPath) ? packagedIconPath : undefined; - } - - const iconPath = - process.platform === "win32" - ? path.join( - process.cwd(), - DEBUG - ? "build/icons/dev/icon.ico" - : "build/icons/release/icon.ico", - ) - : path.join( - process.cwd(), - DEBUG - ? "build/icons/dev/icon.png" - : "build/icons/release/icon.png", - ); - - return existsSync(iconPath) ? iconPath : undefined; -} - -function getDockIconPath() { - if (process.platform !== "darwin") { - return undefined; - } - - if (app.isPackaged) { - const packagedDockIconPath = path.join( - process.resourcesPath, - "icon.icns", - ); - return existsSync(packagedDockIconPath) - ? packagedDockIconPath - : undefined; - } - - const devDockIconPath = path.join( - process.cwd(), - DEBUG ? "build/icons/dev/icon.png" : "build/icons/release/icon.png", - ); - - return existsSync(devDockIconPath) ? devDockIconPath : undefined; -} - -export function getRendererIndexPath() { - return path.join(app.getAppPath(), "dist", "renderer", "index.html"); -} - -export function getPreloadPath() { - return path.join( - app.getAppPath(), - "dist-electron", - "preload", - "preload.js", - ); -} - -async function createMainWindow() { - const windowIcon = getWindowIcon(); - - const win = new BrowserWindow({ - width: 1080, - height: 720, - - resizable: false, - minimizable: false, - maximizable: false, - fullscreenable: false, - - frame: false, - hasShadow: true, - transparent: true, - - alwaysOnTop: true, - - center: true, - skipTaskbar: true, - - show: true, - ...(windowIcon ? { icon: windowIcon } : {}), - - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - mainWindow = win; - allWindows.push({ - id: "splash", - window: win, - }); - - win.once("ready-to-show", () => { - win.show(); - }); - - win.on("closed", () => { - if (mainWindow === win) { - mainWindow = null; - } - }); - - const devServerUrl = "http://localhost:5173/#/splash"; - - if (!app.isPackaged && DEBUG) { - try { - await win.loadURL(devServerUrl); - return win; - } catch { - // Fallback to built renderer when the dev server is unavailable. - } - } - - await win.loadFile(getRendererIndexPath(), { hash: "/splash" }); - - return win; -} - -app.whenReady().then(async () => { - if (process.platform === "darwin") { - const dockIconPath = getDockIconPath(); - if (dockIconPath) { - try { - app.dock?.setIcon(dockIconPath); - } catch { - // Keep startup resilient when icon setup fails. - } - } - } - - registerIpcHandlers(); - registerAllTasks(); - - await createMainWindow(); - - app.on("activate", async () => { - if (BrowserWindow.getAllWindows().length === 0) { - await createMainWindow(); - } - }); -}); - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } -}); diff --git a/editor/src/main/scheduler.ts b/editor/src/main/scheduler.ts deleted file mode 100644 index 6ab35f47..00000000 --- a/editor/src/main/scheduler.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { TaskDefinition } from "src/shared/types/ipc"; -import { BrowserWindow } from "electron"; - -type RunningTask = { - promise: Promise; - controller: AbortController; - runId: string; -}; - -type QueuedJob = { - args: TArgs; - resolve: (value: TResult | PromiseLike) => void; - reject: (reason?: unknown) => void; -}; - -type StoredTaskDefinition = TaskDefinition; -type StoredRunningTask = RunningTask; -type StoredQueuedJob = QueuedJob; - -export class TaskManager { - private definitions = new Map(); - private running = new Map(); - private queues = new Map(); - - register( - definition: TaskDefinition, - ) { - if (this.definitions.has(definition.name)) { - throw new Error(`Task "${definition.name}" is already registered`); - } - this.definitions.set( - definition.name, - definition as StoredTaskDefinition, - ); - } - - start( - name: string, - ...args: TArgs - ): Promise { - const definition = this.definitions.get(name); - if (!definition) { - throw new Error(`Unknown task "${name}"`); - } - - const current = this.running.get(name); - - switch (definition.concurrency) { - case "join": { - if (current) { - return current.promise as Promise; - } - return this.startFresh( - name, - definition as TaskDefinition, - args, - ); - } - - case "skip": { - if (current) { - return Promise.resolve(undefined); - } - return this.startFresh( - name, - definition as TaskDefinition, - args, - ); - } - - case "parallel": { - return this.startDetached( - name, - definition as TaskDefinition, - args, - ); - } - - case "queue": { - if (!current) { - return this.startFresh( - name, - definition as TaskDefinition< - unknown, - TResult, - unknown[] - >, - args, - ); - } - - return new Promise((resolve, reject) => { - const queue = this.queues.get(name) ?? []; - const queuedJob: StoredQueuedJob = { - args, - resolve: (value) => { - resolve(value as TResult); - }, - reject, - }; - queue.push(queuedJob); - this.queues.set(name, queue); - }); - } - } - } - - isRunning(name: string): boolean { - return this.running.has(name); - } - - cancel(name: string): boolean { - const current = this.running.get(name); - if (!current) return false; - current.controller.abort(); - return true; - } - - private startFresh( - name: string, - definition: TaskDefinition, - args: unknown[], - ): Promise { - const controller = new AbortController(); - const runId = randomUUID(); - - const promise = (async () => { - try { - return await definition.run( - { - runId, - signal: controller.signal, - emit: (update) => { - this.emit(name, runId, update); - }, - }, - ...args, - ); - } finally { - const stillCurrent = this.running.get(name); - if (stillCurrent?.runId === runId) { - this.running.delete(name); - void this.drainQueue(name, definition); - } - } - })(); - - this.running.set(name, { - promise, - controller, - runId, - }); - - return promise; - } - - private async drainQueue( - name: string, - definition: TaskDefinition, - ) { - const queue = this.queues.get(name); - if (!queue?.length) return; - - const next = queue.shift(); - if (!next) return; - - if (queue.length === 0) { - this.queues.delete(name); - } - - try { - const result = await this.startFresh(name, definition, next.args); - next.resolve(result); - } catch (err) { - next.reject(err); - } - } - - private async startDetached( - name: string, - definition: TaskDefinition, - args: unknown[], - ): Promise { - const controller = new AbortController(); - const runId = randomUUID(); - - return definition.run( - { - runId, - signal: controller.signal, - emit: (update) => { - this.emit(name, runId, update); - }, - }, - ...args, - ); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected emit(_taskName: string, _runId: string, _update: unknown): void {} -} - -export class ElectronTaskManager extends TaskManager { - constructor(private readonly getWindow: () => BrowserWindow | null) { - super(); - } - - protected override emit(taskName: string, runId: string, update: unknown) { - const win = this.getWindow(); - if (!win) return; - - console.log( - `Emitting update for task "${taskName}" (runId: ${runId}):`, - update, - ); - - win.webContents.send(`${taskName}:update`, { - runId, - update, - }); - } -} diff --git a/editor/src/main/tasks/create-project.ts b/editor/src/main/tasks/create-project.ts deleted file mode 100644 index 2b363619..00000000 --- a/editor/src/main/tasks/create-project.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { app } from "electron"; -import { randomUUID } from "node:crypto"; -import { execFile } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { promisify } from "node:util"; -import type { Project } from "src/shared/types/atlas"; -import type { CreateProjectStyle } from "src/shared/types/ipc"; -import { VERSION_ID } from "../main"; -import { atlasExecutablePath } from "./startup"; - -const execFileAsync = promisify(execFile); - -type CreateProjectPayload = { - name: string; - location: string; - style: CreateProjectStyle; -}; - -function getConfigFilePath() { - return path.join(app.getPath("home"), ".atlas", "config.json"); -} - -function getVersionEntry( - config: Record, -): Record { - const entry = config[VERSION_ID]; - - if (entry && typeof entry === "object" && !Array.isArray(entry)) { - return entry as Record; - } - - return {}; -} - -function getConfiguredExecutablePath(versionEntry: Record) { - if (atlasExecutablePath) { - return atlasExecutablePath; - } - - const onboardingData = versionEntry.onboardingData; - if ( - onboardingData && - typeof onboardingData === "object" && - !Array.isArray(onboardingData) - ) { - const executablePath = ( - onboardingData as Record - ).atlasExecutablePath; - - if (typeof executablePath === "string" && executablePath.trim()) { - return executablePath; - } - } - - return null; -} - -function toStoredProjects(versionEntry: Record) { - return Array.isArray(versionEntry.projects) ? versionEntry.projects : []; -} - -function toProject(project: { - id: string; - name: string; - path: string; - starred: boolean; - modified: string; -}): Project { - return { - ...project, - modified: new Date(project.modified), - }; -} - -function getCreateArgs( - projectPath: string, - name: string, - style: CreateProjectStyle, -) { - const args = [ - "create", - name, - "--path", - projectPath, - "--version", - VERSION_ID, - ]; - - if (style === "pathtracing") { - args.push("--renderer", "pathtracing"); - return args; - } - - args.push("--renderer", "deferred"); - - if (style === "pbr-gi") { - args.push("--global-illumination"); - } - - return args; -} - -function getFailureMessage(error: unknown) { - if (error && typeof error === "object") { - const stderr = "stderr" in error ? error.stderr : undefined; - if (typeof stderr === "string" && stderr.trim()) { - return stderr.trim(); - } - - const message = "message" in error ? error.message : undefined; - if (typeof message === "string" && message.trim()) { - return message.trim(); - } - } - - return "Failed to create project."; -} - -export async function createProject( - payload: CreateProjectPayload, -): Promise { - const name = payload.name.trim(); - const location = payload.location.trim(); - - if (!name) { - throw new Error("Project name cannot be empty."); - } - - if (!location) { - throw new Error("Project location cannot be empty."); - } - - const configFilePath = getConfigFilePath(); - const configDir = path.dirname(configFilePath); - - await mkdir(configDir, { recursive: true }); - - let config: Record = {}; - - try { - const configContent = await readFile(configFilePath, "utf-8"); - config = JSON.parse(configContent) as Record; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code !== "ENOENT") { - throw error; - } - } - - const versionEntry = getVersionEntry(config); - const executablePath = getConfiguredExecutablePath(versionEntry); - - if (!executablePath) { - throw new Error("Atlas executable is not configured."); - } - - const projectPath = path.join(location, name); - - try { - await execFileAsync( - executablePath, - getCreateArgs(projectPath, name, payload.style), - { - windowsHide: true, - }, - ); - } catch (error) { - throw new Error(getFailureMessage(error), { cause: error }); - } - - const storedProject = { - id: randomUUID(), - name, - path: projectPath, - starred: false, - modified: new Date().toISOString(), - }; - - const existingProjects = toStoredProjects(versionEntry).filter((project) => { - if (!project || typeof project !== "object" || Array.isArray(project)) { - return true; - } - - return (project as Record).path !== projectPath; - }); - - config[VERSION_ID] = { - ...versionEntry, - projects: [storedProject, ...existingProjects], - }; - - await writeFile(configFilePath, JSON.stringify(config, null, 2), "utf-8"); - - return toProject(storedProject); -} diff --git a/editor/src/main/tasks/register.ts b/editor/src/main/tasks/register.ts deleted file mode 100644 index d92aee2e..00000000 --- a/editor/src/main/tasks/register.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { StartupTaskUpdate, TaskDefinition } from "src/shared/types/ipc"; -import { startupTask } from "./startup"; -import { ElectronTaskManager } from "../scheduler"; -import { mainWindow } from "../main"; -import { storeOnboarding } from "./store-onboarding"; - -const startupTaskDefinition: TaskDefinition = { - name: "startup-task", - concurrency: "join", - run: startupTask, -}; - -const storeOnboardingTaskDefinition: TaskDefinition< - StartupTaskUpdate, - void, - [string | null, string | null] -> = { - name: "store-onboarding-data", - concurrency: "join", - run: storeOnboarding, -}; - -export const taskDefinitions = [ - startupTaskDefinition, - storeOnboardingTaskDefinition, -]; - -export function registerTasks(taskManager: { - register( - definition: TaskDefinition, - ): void; -}) { - taskManager.register(startupTaskDefinition); - taskManager.register(storeOnboardingTaskDefinition); -} - -export const tasks = new ElectronTaskManager(() => mainWindow); - -export function registerAllTasks() { - registerTasks(tasks); -} diff --git a/editor/src/main/tasks/startup.ts b/editor/src/main/tasks/startup.ts deleted file mode 100644 index 4b6e6680..00000000 --- a/editor/src/main/tasks/startup.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { app } from "electron"; -import path from "node:path"; -import { readFile, stat } from "node:fs/promises"; -import type { StartupTaskUpdate, TaskContext } from "../../shared/types/ipc"; -import { VERSION_ID } from "../main"; -import { Project } from "src/shared/types/atlas"; - -export let runtimeLib: string | null = null; -export let atlasExecutablePath: string | null = null; - -export async function startupTask( - ctx: TaskContext, -): Promise { - ctx.emit("starting"); - ctx.emit("locating-config-file"); - - const configFilePath = path.join( - app.getPath("home"), - ".atlas", - "config.json", - ); - - let configContent: string; - try { - configContent = await readFile(configFilePath, "utf-8"); - } catch { - ctx.emit("needs-config"); - return; - } - - ctx.emit("config-file-found"); - - const config = JSON.parse(configContent)[VERSION_ID].onboardingData; - - if (!config.atlasExecutablePath) { - ctx.emit("needs-config"); - return; - } - - if (!config.runtimeLib) { - ctx.emit("needs-config"); - return; - } - - atlasExecutablePath = config.atlasExecutablePath; - runtimeLib = config.runtimeLib; - - ctx.emit("checking-executable"); - - try { - await stat(atlasExecutablePath as string); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.emit({ - type: "error", - error: `Failed to locate atlas executable at path "${atlasExecutablePath}": ${message}`, - }); - return; - } - - if (ctx.signal.aborted) return; - - ctx.emit("loading-runtimelib"); - - try { - await stat(runtimeLib as string); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.emit({ - type: "error", - error: `Failed to locate runtimelib at path "${runtimeLib}": ${message}`, - }); - return; - } - - if (ctx.signal.aborted) return; - - ctx.emit("done"); -} - -export async function getProjects(): Promise { - const configFile = path.join(app.getPath("home"), ".atlas", "config.json"); - - try { - const configContent = await readFile(configFile, "utf-8"); - const config = JSON.parse(configContent)[VERSION_ID]; - const projects: unknown[] = Array.isArray(config?.projects) - ? config.projects - : []; - - return projects - .filter( - (project): project is Project & { modified: string | Date } => - Boolean(project) && - typeof project === "object" && - !Array.isArray(project), - ) - .map((project) => ({ - ...project, - modified: new Date(project.modified), - })); - } catch { - return []; - } -} diff --git a/editor/src/main/tasks/store-onboarding.ts b/editor/src/main/tasks/store-onboarding.ts deleted file mode 100644 index 727761c9..00000000 --- a/editor/src/main/tasks/store-onboarding.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { app } from "electron"; -import { readFile, mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { StartupTaskUpdate, TaskContext } from "src/shared/types/ipc"; -import { VERSION_ID } from "../main"; - -export async function storeOnboarding( - ctx: TaskContext, - runtimePath: string | null, - executablePath: string | null, -): Promise { - const configFilePath = path.join( - app.getPath("home"), - ".atlas", - "config.json", - ); - - const configDir = path.dirname(configFilePath); - - const onboardingData = { - atlasExecutablePath: executablePath, - runtimeLib: runtimePath, - }; - - try { - await mkdir(configDir, { recursive: true }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.emit({ - type: "error", - error: `Failed to create config directory: ${message}`, - }); - return; - } - - let jsonContent: Record = {}; - - try { - const configContent = await readFile(configFilePath, "utf-8"); - jsonContent = JSON.parse(configContent) as Record; - } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code !== "ENOENT") { - const message = err instanceof Error ? err.message : String(err); - ctx.emit({ - type: "error", - error: `Failed to read config file: ${message}`, - }); - return; - } - } - - jsonContent[VERSION_ID] = { - onboardingData, - }; - - try { - await writeFile( - configFilePath, - JSON.stringify(jsonContent, null, 2), - "utf-8", - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ctx.emit({ - type: "error", - error: `Failed to write onboarding data: ${message}`, - }); - } -} diff --git a/editor/src/main/windows.ts b/editor/src/main/windows.ts deleted file mode 100644 index 431f8760..00000000 --- a/editor/src/main/windows.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { app, BrowserWindow } from "electron"; -import { WindowMaker } from "src/shared/types/ipc"; -import { - allWindows, - engineBridge, - getPreloadPath, - getRendererIndexPath, - getWindowIcon, - mainWindow, - setMainWindow, -} from "./main"; -import { DEBUG } from "../shared/generated/build"; -import { runtimeLib } from "./tasks/startup"; -import { currentProjectPath } from "./ipc"; - -export const createOnboardingWindow: WindowMaker = async () => { - const windowIcon = getWindowIcon(); - - const win = new BrowserWindow({ - width: 557, - height: 557, - - resizable: false, - minimizable: false, - maximizable: false, - fullscreenable: false, - - frame: false, - hasShadow: true, - - alwaysOnTop: true, - - center: true, - skipTaskbar: true, - - show: true, - ...(windowIcon ? { icon: windowIcon } : {}), - - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - setMainWindow(win); - allWindows.push({ - id: "onboarding", - window: win, - }); - - win.once("ready-to-show", () => { - win.show(); - }); - - win.on("closed", () => { - if (mainWindow === win) { - setMainWindow(null); - } - }); - - const devServerUrl = "http://localhost:5173/#/onboarding"; - - if (!app.isPackaged && DEBUG) { - try { - await win.loadURL(devServerUrl); - return { id: "onboarding", window: win }; - } catch { - // Fallback to built renderer when the dev server is unavailable. - } - } - - await win.loadFile(getRendererIndexPath(), { hash: "/onboarding" }); - - return { id: "onboarding", window: win }; -}; - -export const createProjectsWindow: WindowMaker = async () => { - const windowIcon = getWindowIcon(); - - const win = new BrowserWindow({ - width: 1000, - height: 557, - - resizable: true, - minimizable: true, - maximizable: true, - fullscreenable: false, - - frame: true, - titleBarStyle: "hiddenInset", - - hasShadow: true, - - center: true, - - show: true, - ...(windowIcon ? { icon: windowIcon } : {}), - - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - setMainWindow(win); - allWindows.push({ - id: "projects", - window: win, - }); - - win.once("ready-to-show", () => { - win.show(); - }); - - win.on("closed", () => { - if (mainWindow === win) { - setMainWindow(null); - } - }); - - const devServerUrl = "http://localhost:5173/#/projects"; - - if (!app.isPackaged && DEBUG) { - try { - await win.loadURL(devServerUrl); - return { id: "projects", window: win }; - } catch { - // Fallback to built renderer when the dev server is unavailable. - } - } - - await win.loadFile(getRendererIndexPath(), { hash: "/projects" }); - - return { id: "projects", window: win }; -}; - -export const createNewProjectModal: WindowMaker = async () => { - const windowIcon = getWindowIcon(); - - const win = new BrowserWindow({ - width: 560, - height: 680, - parent: mainWindow!, - modal: true, - - frame: true, - titleBarStyle: "default", - - hasShadow: true, - - center: true, - - show: true, - ...(windowIcon ? { icon: windowIcon } : {}), - - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - setMainWindow(win); - allWindows.push({ - id: "createProject", - window: win, - }); - - win.once("ready-to-show", () => { - win.show(); - }); - - win.on("closed", () => { - if (mainWindow === win) { - setMainWindow(null); - } - }); - - const devServerUrl = "http://localhost:5173/#/createProject"; - - if (!app.isPackaged && DEBUG) { - try { - await win.loadURL(devServerUrl); - return { id: "createProject", window: win }; - } catch { - // Fallback to built renderer when the dev server is unavailable. - } - } - - await win.loadFile(getRendererIndexPath(), { hash: "/createProject" }); - - return { id: "createProject", window: win }; -}; - -export let frameTimer: NodeJS.Timeout | null = null; - -export const viewport: WindowMaker = async () => { - const windowIcon = getWindowIcon(); - - const win = new BrowserWindow({ - width: 1200, - height: 800, - backgroundColor: "#000000", - - frame: true, - titleBarStyle: "hiddenInset", - - show: true, - ...(windowIcon ? { icon: windowIcon } : {}), - - webPreferences: { - preload: getPreloadPath(), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - }); - - const dylibPath = runtimeLib as string; - try { - engineBridge.loadLibrary(dylibPath); - } catch (err) { - console.error("Failed to load engine library:", err); - throw err; - } - - const nativeHandle: Buffer = win.getNativeWindowHandle(); - try { - engineBridge.attachToNativeWindow( - currentProjectPath + "/project.atlas", - nativeHandle, - ); - } catch (err) { - console.error("Failed to attach to native window:", err); - throw err; - } - - function resizeEditorToWindow(window: BrowserWindow) { - const [width, height] = window.getContentSize(); - const scale = window.webContents.getZoomFactor(); - engineBridge.resizeEditor(width, height, scale); - } - - resizeEditorToWindow(win); - - setMainWindow(win); - allWindows.push({ - id: "editor", - window: win, - }); - - const targetEditorFps = 60; - frameTimer = setInterval(() => { - try { - engineBridge.step(); - } catch (err) { - console.error("Failed to step engine frame:", err); - } - }, 1000 / targetEditorFps); - - win.once("ready-to-show", () => { - win.show(); - }); - - win.on("resize", () => { - resizeEditorToWindow(win); - try { - engineBridge.step(); - } catch (err) { - console.error("Failed to step engine frame after resize:", err); - } - }); - - win.on("closed", () => { - if (frameTimer) { - clearInterval(frameTimer); - frameTimer = null; - } - try { - engineBridge.shutdown(); - } catch (err) { - console.error("Error during engine shutdown:", err); - } - if (mainWindow === win) { - setMainWindow(null); - } - }); - - const devServerUrl = "http://localhost:5173/#/editor"; - - if (!app.isPackaged && DEBUG) { - try { - await win.loadURL(devServerUrl); - return { id: "editor", window: win }; - } catch { - // Fallback to built renderer when the dev server is unavailable. - } - } - - await win.loadFile(getRendererIndexPath(), { hash: "/editor" }); - - return { id: "editor", window: win }; -}; - -export const makerRegistry: Record> = { - onboarding: createOnboardingWindow, - projects: createProjectsWindow, - createProject: createNewProjectModal, - editor: viewport, -}; diff --git a/editor/src/preload/preload.ts b/editor/src/preload/preload.ts deleted file mode 100644 index 20e5c889..00000000 --- a/editor/src/preload/preload.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { contextBridge, ipcRenderer } from "electron"; -import type { - EditorControlsApi, - GeneralTask, - StartupTask, - StartupTaskUpdate, - WindowApi, -} from "../shared/types/ipc"; - -const api: WindowApi = { - getAppInfo: () => ipcRenderer.invoke("app:get-info"), - setTitle: (title) => ipcRenderer.invoke("window:set-title", title), - onThemeChanged: (callback) => { - const listener = (_event: unknown, theme: "light" | "dark") => - callback(theme); - ipcRenderer.on("theme:changed", listener); - - return () => { - ipcRenderer.removeListener("theme:changed", listener); - }; - }, - showWindow: (id) => ipcRenderer.send("window:show", id), - hideWindow: (id) => ipcRenderer.send("window:hide", id), - destroyWindow: (id) => ipcRenderer.send("window:destroy", id), - fileDialog: (options) => ipcRenderer.invoke("file-dialog", options), - storeOnboardingData: (onBoardingData) => - ipcRenderer.invoke("store-onboarding-data", onBoardingData), -}; - -const startupTask: StartupTask = { - start: () => ipcRenderer.invoke("startup-task:start"), - - onUpdate: (callback) => { - const listener = ( - _event: Electron.IpcRendererEvent, - payload: { runId: string; update: StartupTaskUpdate }, - ) => { - callback(payload.update); - }; - - ipcRenderer.on("startup-task:update", listener); - - return () => { - ipcRenderer.removeListener("startup-task:update", listener); - }; - }, -}; - -const generalTasks: GeneralTask = { - getProjects: () => ipcRenderer.invoke("general:get-projects"), - createProject: (payload) => - ipcRenderer.invoke("general:create-project", payload), - openProject: (payload) => - ipcRenderer.invoke("general:open-project", payload), -}; - -const editorControls: EditorControlsApi = { - setEnabled: (enabled) => - ipcRenderer.invoke("editor-controls:set-enabled", enabled), - setPlaying: (playing) => - ipcRenderer.invoke("editor-controls:set-playing", playing), - setMode: (mode) => ipcRenderer.invoke("editor-controls:set-mode", mode), - getSelection: () => ipcRenderer.invoke("editor-controls:get-selection"), -}; - -contextBridge.exposeInMainWorld("app", api); -contextBridge.exposeInMainWorld("startupTask", startupTask); -contextBridge.exposeInMainWorld("tasks", generalTasks); -contextBridge.exposeInMainWorld("editorControls", editorControls); diff --git a/editor/src/renderer/assets/atlasBall.png b/editor/src/renderer/assets/atlasBall.png deleted file mode 100644 index edbc6783..00000000 Binary files a/editor/src/renderer/assets/atlasBall.png and /dev/null differ diff --git a/editor/src/renderer/assets/gi.png b/editor/src/renderer/assets/gi.png deleted file mode 100644 index f8d2bf96..00000000 Binary files a/editor/src/renderer/assets/gi.png and /dev/null differ diff --git a/editor/src/renderer/assets/pathtracing.png b/editor/src/renderer/assets/pathtracing.png deleted file mode 100644 index c7449202..00000000 Binary files a/editor/src/renderer/assets/pathtracing.png and /dev/null differ diff --git a/editor/src/renderer/assets/pbr.png b/editor/src/renderer/assets/pbr.png deleted file mode 100644 index 2013b8ff..00000000 Binary files a/editor/src/renderer/assets/pbr.png and /dev/null differ diff --git a/editor/src/renderer/assets/sign.jpg b/editor/src/renderer/assets/sign.jpg deleted file mode 100644 index 980be857..00000000 Binary files a/editor/src/renderer/assets/sign.jpg and /dev/null differ diff --git a/editor/src/renderer/index.html b/editor/src/renderer/index.html deleted file mode 100644 index c59a084b..00000000 --- a/editor/src/renderer/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - My Electron App - - -
- - - \ No newline at end of file diff --git a/editor/src/renderer/src/components/AppLogo.tsx b/editor/src/renderer/src/components/AppLogo.tsx deleted file mode 100644 index 97995820..00000000 --- a/editor/src/renderer/src/components/AppLogo.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useState } from "react"; -import { AppInfo } from "../model/app"; - -type Props = { - className?: string; -}; - -export default function AppLogo({ className }: Props) { - const [appInfo, setAppInfo] = useState({ - debug: false, - buildId: "", - platform: "", - }); - - useEffect(() => { - window.app.getAppInfo().then(setAppInfo); - }, []); - - return ( -
- {(!appInfo.debug && ( - App Logo - )) || ( - App Logo - )} -
- ); -} diff --git a/editor/src/renderer/src/components/Button.tsx b/editor/src/renderer/src/components/Button.tsx deleted file mode 100644 index ed88b6ca..00000000 --- a/editor/src/renderer/src/components/Button.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import clsx from "clsx"; -import { twMerge } from "tailwind-merge"; - -type Props = { - type: "primary" | "secondary" | "destructive" | "inactive" | "special"; - onClick: () => void; - className?: string; -}; - -export default function Button({ - type, - onClick, - className, - children, -}: React.PropsWithChildren) { - const baseClasses = - "rounded-[100px] px-4 py-2 font-medium transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50 min-w-[252px] text-center min-h-[41px]"; - - if (type === "special") { - return ( - <> - - - - ); - } - - const typeClasses = { - primary: "bg-accent text-white hover:bg-accent-hover", - secondary: "bg-gray-300 text-gray-800 hover:bg-gray-400", - destructive: "bg-red-600 text-white hover:bg-red-700", - inactive: "bg-gray-300 text-gray-500 cursor-not-allowed", - }[type]; - - return ( - - ); -} diff --git a/editor/src/renderer/src/components/TextField.tsx b/editor/src/renderer/src/components/TextField.tsx deleted file mode 100644 index 11567c1f..00000000 --- a/editor/src/renderer/src/components/TextField.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import clsx from "clsx"; -import { - forwardRef, - type ComponentPropsWithoutRef, - type ReactNode, -} from "react"; -import { twMerge } from "tailwind-merge"; - -type Props = Omit, "size"> & { - leading?: ReactNode; - trailing?: ReactNode; - inputClassName?: string; - size?: "sm" | "md"; -}; - -const fieldSizeClasses = { - sm: { - shell: "h-10 rounded-2xl px-3.5", - input: "text-sm", - side: "text-sm", - }, - md: { - shell: "h-11 rounded-2xl px-4", - input: "text-[15px]", - side: "text-sm", - }, -}; - -const TextField = forwardRef(function TextField( - { - leading, - trailing, - inputClassName, - className, - size = "sm", - disabled, - ...props - }, - ref, -) { - const sizing = fieldSizeClasses[size]; - - return ( - - - - {leading ? ( - - {leading} - - ) : null} - - - - {trailing ? ( - - {trailing} - - ) : null} - - ); -}); - -export default TextField; diff --git a/editor/src/renderer/src/main.tsx b/editor/src/renderer/src/main.tsx deleted file mode 100644 index 7b0f6b26..00000000 --- a/editor/src/renderer/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import "./styles.css"; -import AppRouter from "./router"; - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/editor/src/renderer/src/model/app.ts b/editor/src/renderer/src/model/app.ts deleted file mode 100644 index c698848e..00000000 --- a/editor/src/renderer/src/model/app.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type AppInfo = { - debug: boolean; - buildId: string; - platform: string; -}; - -export const onboardingData = { - runtimePath: null as string | null, - executablePath: null as string | null, -}; - -export function setOnboardingData(runtimePath: string, executablePath: string) { - onboardingData.runtimePath = runtimePath; - onboardingData.executablePath = executablePath; -} diff --git a/editor/src/renderer/src/router.tsx b/editor/src/renderer/src/router.tsx deleted file mode 100644 index 48d27c67..00000000 --- a/editor/src/renderer/src/router.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createHashRouter, RouterProvider } from "react-router-dom"; -import TestPage from "./views/Test"; -import Splash from "./views/Splash"; -import Onboarding from "./views/Onboarding"; -import Projects from "./views/Projects"; -import { CreateProject } from "./views/modal/CreateProject"; - -const router = createHashRouter([ - { path: "/test", Component: TestPage }, - { path: "/onboarding", Component: Onboarding }, - { path: "/splash", Component: Splash }, - { path: "/projects", Component: Projects }, - { path: "/createProject", Component: CreateProject }, -]); - -export default function AppRouter() { - return ; -} diff --git a/editor/src/renderer/src/styles.css b/editor/src/renderer/src/styles.css deleted file mode 100644 index 41bd786f..00000000 --- a/editor/src/renderer/src/styles.css +++ /dev/null @@ -1,11 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap'); -@import "tailwindcss"; - -@theme { - --color-secondary: #999999; - --color-accent: #0091FF; - --color-accent-hover: #48ADF9; - --color-destroy: #FF4245; - --font-manrope: 'Manrope', sans-serif; -} - diff --git a/editor/src/renderer/src/views/Onboarding.tsx b/editor/src/renderer/src/views/Onboarding.tsx deleted file mode 100644 index 752dee87..00000000 --- a/editor/src/renderer/src/views/Onboarding.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { useState } from "react"; -import AppLogo from "../components/AppLogo"; -import Button from "../components/Button"; -import { onboardingData, setOnboardingData } from "../model/app"; - -export default function Onboarding() { - const [step, setStep] = useState(0); - const [runtimePath, setRuntimePath] = useState(null); - const [executablePath, setExecutablePath] = useState(null); - - const nextStep = () => { - setStep((prev) => Math.min(prev + 1, 4)); - }; - - function selectRuntime() { - window.app - .fileDialog({ - title: "Select Atlas Runtime", - buttonLabel: "Select Runtime", - properties: ["openFile"], - filters: [ - { name: "Runtime", extensions: ["dylib"] }, - { name: "All Files", extensions: ["*"] }, - ], - }) - .then((paths) => { - if (paths && paths.length > 0) { - setRuntimePath(paths[0] as string); - } - }); - } - - function selectExecutable() { - window.app - .fileDialog({ - title: "Select Atlas Executable", - buttonLabel: "Select Executable", - properties: ["openFile"], - filters: [{ name: "All Files", extensions: ["*"] }], - }) - .then((paths) => { - if (paths && paths.length > 0) { - setExecutablePath(paths[0] as string); - } - }); - } - - function canContinue() { - if (step === 4) { - return runtimePath !== null && executablePath !== null; - } - } - - async function finishOnboarding() { - setOnboardingData(runtimePath!, executablePath!); - await window.app.storeOnboardingData(onboardingData); - window.app.showWindow("splashOnboarding"); - window.app.destroyWindow("onboarding"); - } - - const welcomeMessage = ` - Welcome to Atlas Engine! We're excited to have you on board. This is an open-source project, and we encourage you to explore the codebase, contribute, and provide feedback. If you have any questions or need assistance, feel free to reach out to us on our GitHub repository or join our community forums. - Let's build something amazing together! Building games should be modern, fun, and accessible to everyone, and we're committed to making that a reality with Atlas Engine. - This is just the beginning of our journey, and we can't wait to see what you'll create with Atlas Engine. - - Happy coding! - `; - - return ( -
-
-
- -

Welcome to

-

Atlas Engine

- -
-

by neutral software

-
- -
- -
-
- -
-

- Message from the team -

-

{welcomeMessage}

-
- Signature -
- Max Van den Eynde, Founder and Director of Neutral - Software. -
-
- -
- -
-
- -
-

Choose a runtime

- -
- - - -
-
- -
-

Choose a runtime

- -
- - - -
-
- -
-

- Specify a runtime path -

- -

- If you already have a compatible runtime installed, you - can specify its path here to use it with Atlas Engine. -

- - {runtimePath && ( -
- Selected runtime path: {runtimePath} -
- )} - - - - {executablePath && ( -
- Selected executable path: {executablePath} -
- )} - - - -
- -
-
-
-
- ); -} diff --git a/editor/src/renderer/src/views/Projects.tsx b/editor/src/renderer/src/views/Projects.tsx deleted file mode 100644 index 7fe67abb..00000000 --- a/editor/src/renderer/src/views/Projects.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { - ChevronDown, - ChevronUp, - Folder, - GraduationCap, - LucideIcon, - Search, - Settings, - Star, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import Button from "../components/Button"; -import TextField from "../components/TextField"; -import { Project } from "src/shared/types/atlas"; - -export default function Projects() { - const [page, setPage] = useState(0); - const [search, setSearch] = useState(""); - const [projects, setProjects] = useState([]); - const [modifiedUp, setModifiedUp] = useState(true); - const [starFilter, setStarFilter] = useState(false); - - function renderOption(label: string, index: number, icon: LucideIcon) { - const Icon = icon; - const active = page === index; - return ( - - ); - } - - useEffect(() => { - let isMounted = true; - - const loadProjects = async () => { - const nextProjects = await window.tasks.getProjects(); - if (isMounted && nextProjects) { - setProjects(nextProjects); - } - }; - - void loadProjects(); - - const handleFocus = () => { - void loadProjects(); - }; - - window.addEventListener("focus", handleFocus); - - return () => { - isMounted = false; - window.removeEventListener("focus", handleFocus); - }; - }, []); - - const filteredProjects = useMemo(() => { - const q = search.trim().toLowerCase(); - let filtered = !q - ? [...projects] - : projects.filter((p) => p.name.toLowerCase().includes(q)); - - filtered.sort((a, b) => - modifiedUp - ? b.modified.getTime() - a.modified.getTime() - : a.modified.getTime() - b.modified.getTime(), - ); - - if (starFilter) filtered = filtered.filter((p) => p.starred); - return filtered; - }, [search, projects, modifiedUp, starFilter]); - - function timeAgo(date: Date): string { - const now = new Date(); - const diff = (date.getTime() - now.getTime()) / 1000; - const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - const divisions = [ - { amount: 60, name: "seconds" }, - { amount: 60, name: "minutes" }, - { amount: 24, name: "hours" }, - { amount: 7, name: "days" }, - { amount: 4.34524, name: "weeks" }, - { amount: 12, name: "months" }, - { amount: Infinity, name: "years" }, - ]; - let duration = diff; - for (const division of divisions) { - if (Math.abs(duration) < division.amount) { - return rtf.format( - Math.round(duration), - division.name as Intl.RelativeTimeFormatUnit, - ); - } - duration /= division.amount; - } - return ""; - } - - function newProject() { - window.app.showWindow("createProject"); - } - - function projectsPage() { - return ( -
- {/* Header */} -
-

- Projects -

- -
- setSearch(e.target.value)} - placeholder="Search…" - leading={ - - } - /> -
- -
- - -
-
- - {/* Table header */} -
- - - Name - - -
- - {/* Project list */} -
-
- {filteredProjects.length === 0 && ( -

- No projects found. -

- )} - {filteredProjects.map((project, index) => ( -
{ - console.log( - "Opening project at path:", - project.path, - ); - window.tasks.openProject({ - path: project.path, - }); - window.app.showWindow("editor"); - window.app.destroyWindow("projects"); - }} - > - {/* Star */} - - - {/* Folder icon */} -
- -
- - {/* Name + path */} -
- - {project.name} - - - {project.path} - -
- - {/* Time */} - - {timeAgo(project.modified)} - -
- ))} -
-
-
- ); - } - - return ( -
-
- - {/* Sidebar */} - - -
- {page === 0 && projectsPage()} - {page === 1 && ( -
- Learn -
- )} - {page === 2 && ( -
- Settings -
- )} -
-
- ); -} diff --git a/editor/src/renderer/src/views/Splash.tsx b/editor/src/renderer/src/views/Splash.tsx deleted file mode 100644 index 626cba94..00000000 --- a/editor/src/renderer/src/views/Splash.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useEffect, useState } from "react"; -import { AppInfo } from "../model/app"; -import AppLogo from "../components/AppLogo"; - -export default function Splash() { - const [appInfo, setAppInfo] = useState({ - debug: false, - buildId: "", - platform: "", - }); - - const [startupMessage, setStartupMessage] = useState( - "Loading the engine...", - ); - - function openOnboarding() { - window.app.showWindow("onboarding"); - window.app.destroyWindow("splash"); - } - - function openProjects() { - window.app.showWindow("projects"); - window.app.destroyWindow("splash"); - } - - useEffect(() => { - let transitionTimer: ReturnType | null = null; - - window.app.getAppInfo().then(setAppInfo); - const unsubscribe = window.startupTask.onUpdate((update) => { - if (typeof update === "string") { - switch (update) { - case "starting": - setStartupMessage("Starting the engine..."); - break; - case "locating-config-file": - setStartupMessage("Locating configuration files..."); - break; - case "config-file-found": { - setStartupMessage("Configuration files found."); - break; - } - case "needs-config": - setStartupMessage( - "Configuration file is missing the required fields.", - ); - transitionTimer = setTimeout(() => { - openOnboarding(); - }, 600); - break; - case "checking-executable": - setStartupMessage("Checking atlas executable..."); - break; - case "loading-runtimelib": - setStartupMessage("Loading the runtime..."); - break; - case "done": - setStartupMessage("Engine is ready."); - transitionTimer = setTimeout(() => { - openProjects(); - }, 600); - break; - } - } else if (update.type === "error") { - setStartupMessage(`Error: ${update.error}`); - } - }); - - void window.startupTask.start(); - return () => { - if (transitionTimer) { - clearTimeout(transitionTimer); - } - unsubscribe(); - }; - }, []); - - const debugMessage = ` - As this software is in its development version issues may be found with the experience. If you meant to use the traditional version please access: - https://atlasengine.org to get the official builds. In development builds, the engine may require you to have a runtime already installed. Therefore, make sure - that you have an appropiate runtime in your system that works with this version. - `; - - return ( -
- {(!appInfo.debug && ( -
- -
-

Atlas Engine

-

- Alpha 9 -

-

- by neutral software -

-
- {startupMessage} -
-
-
- )) || ( -
-
- -
-

- Atlas Engine (Development) -

-

- {`Alpha 9 (build ${appInfo.buildId})`} -

-

- by neutral software -

-
- {startupMessage} -
-
-
-
- {debugMessage} -
-
- )} -
- ); -} diff --git a/editor/src/renderer/src/views/Test.tsx b/editor/src/renderer/src/views/Test.tsx deleted file mode 100644 index d3811de9..00000000 --- a/editor/src/renderer/src/views/Test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; - -export default function TestPage() { - return ( -
-

Test page

-
- ); -} diff --git a/editor/src/renderer/src/views/modal/CreateProject.tsx b/editor/src/renderer/src/views/modal/CreateProject.tsx deleted file mode 100644 index 5db41882..00000000 --- a/editor/src/renderer/src/views/modal/CreateProject.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { ChevronLeft, ChevronRight, Folder } from "lucide-react"; -import { useState } from "react"; -import type { CreateProjectStyle } from "src/shared/types/ipc"; -import Button from "../../components/Button"; -import TextField from "../../components/TextField"; - -type ProjectOption = { - style: CreateProjectStyle; - name: string; - description: string; - image: string; - color: string; -}; - -const options: ProjectOption[] = [ - { - style: "pbr", - name: "PBR (Physically Based Rendering)", - description: - "Create a project with a good balance of visual quality and performance, suitable for most applications.", - image: "../../../assets/pbr.png", - color: "bg-green-500", - }, - { - style: "pathtracing", - name: "Path Tracing", - description: - "Create a project with hyperrealistic lighting that consumes significant computational resources.", - image: "../../../assets/pathtracing.png", - color: "bg-red-500", - }, - { - style: "pbr-gi", - name: "PBR and Global Illumination", - description: - "Create a project with enhanced lighting effects that combines PBR with real-time global illumination techniques.", - image: "../../../assets/gi.png", - color: "bg-blue-500", - }, -]; - -function buildProjectPath(location: string, name: string) { - const trimmedLocation = location.trim(); - const trimmedName = name.trim(); - - if (!trimmedLocation) { - return ""; - } - - if (!trimmedName) { - return trimmedLocation; - } - - const needsSeparator = - !trimmedLocation.endsWith("/") && !trimmedLocation.endsWith("\\"); - - return `${trimmedLocation}${needsSeparator ? "/" : ""}${trimmedName}`; -} - -function formatCreateError(error: unknown) { - if (!(error instanceof Error)) { - return "Failed to create project."; - } - - const remotePrefix = - "Error invoking remote method 'general:create-project': Error: "; - - return error.message.startsWith(remotePrefix) - ? error.message.slice(remotePrefix.length) - : error.message; -} - -export function CreateProject() { - const [option, setOption] = useState(0); - const [step, setStep] = useState(0); - const [name, setName] = useState(""); - const [location, setLocation] = useState(""); - const [isCreating, setIsCreating] = useState(false); - const [error, setError] = useState(null); - - const selectedOption = options[option]!; - const canCreate = - name.trim().length > 0 && location.trim().length > 0 && !isCreating; - const projectPath = buildProjectPath(location, name); - - function typeCard(projectOption: ProjectOption) { - return ( -
-
- {projectOption.name} -
-
-

{projectOption.name}

-

- {projectOption.description} -

-
-
- ); - } - - async function selectLocation() { - if (isCreating) { - return; - } - - const paths = await window.app.fileDialog({ - title: "Select Project Location", - buttonLabel: "Select Location", - properties: ["openDirectory", "createDirectory"], - }); - - if (paths && paths.length > 0) { - setLocation(paths[0] as string); - setError(null); - } - } - - async function createProject() { - if (!canCreate) { - return; - } - - setIsCreating(true); - setError(null); - - try { - await window.tasks.createProject({ - name, - location, - style: selectedOption.style, - }); - window.app.destroyWindow("createProject"); - } catch (createError) { - setError(formatCreateError(createError)); - } finally { - setIsCreating(false); - } - } - - function firstStep() { - return ( -
-

Create a project

-

- Choose the rendering style for this project. -

- -
- - {typeCard(selectedOption)} - -
- - -
- ); - } - - function secondStep() { - return ( -
-
-
- - - {selectedOption.name} - -
- -
-

Project details

-

- Enter a name and choose where the project folder - should be created. -

-
- -
-
- - { - setName(event.target.value); - setError(null); - }} - placeholder="MyAtlasProject" - disabled={isCreating} - size="md" - /> -
- -
- -
- { - setLocation(event.target.value); - setError(null); - }} - placeholder="/Users/maxvdec/Projects" - disabled={isCreating} - size="md" - className="min-w-0 flex-1" - /> - -
-
-
- -
-

- Output path -

-

- {projectPath || "Select a location and project name"} -

-
- - {error ? ( -
-

- {error} -

-
- ) : null} - -
- - -
-
-
- ); - } - - return
{step === 0 ? firstStep() : secondStep()}
; -} diff --git a/editor/src/shared/generated/build.ts b/editor/src/shared/generated/build.ts deleted file mode 100644 index ba0cbd8d..00000000 --- a/editor/src/shared/generated/build.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const DEBUG = true as const; -export const BUILDID = "20260425" as const; -export const APP_MODE = "development" as const; diff --git a/editor/src/shared/types/atlas.ts b/editor/src/shared/types/atlas.ts deleted file mode 100644 index 70344502..00000000 --- a/editor/src/shared/types/atlas.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type Project = { - name: string; - starred: boolean; - path: string; - modified: Date; - id: string; -}; diff --git a/editor/src/shared/types/ipc.ts b/editor/src/shared/types/ipc.ts deleted file mode 100644 index c3ab2635..00000000 --- a/editor/src/shared/types/ipc.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Project } from "./atlas"; - -export type AppPlatform = - | "aix" - | "darwin" - | "freebsd" - | "linux" - | "openbsd" - | "sunos" - | "win32" - | "cygwin" - | "netbsd"; - -export interface AppInfo { - debug: boolean; - buildId: string; - platform: AppPlatform; -} - -export interface FileDialogOptions { - title?: string; - buttonLabel?: string; - defaultPath?: string; - filters?: Array<{ - name: string; - extensions: string[]; - }>; - properties?: Array< - | "openFile" - | "openDirectory" - | "multiSelections" - | "showHiddenFiles" - | "createDirectory" - | "promptToCreate" - | "noResolveAliases" - | "treatPackageAsDirectory" - | "dontAddToRecent" - >; -} - -export interface WindowApi { - getAppInfo(): Promise; - setTitle(title: string): Promise; - onThemeChanged(callback: (theme: "light" | "dark") => void): () => void; - showWindow(id: string): void; - hideWindow(id: string): void; - destroyWindow(id: string): void; - fileDialog(options: FileDialogOptions): Promise; - storeOnboardingData(onBoardingData: { - runtimePath: string | null; - executablePath: string | null; - }): Promise; -} - -export type CreateProjectStyle = "pbr" | "pathtracing" | "pbr-gi"; -export type EditorControlMode = "none" | "move" | "rotate" | "scale"; - -export type StartupTaskUpdate = - | "starting" - | "locating-config-file" - | "config-file-found" - | "checking-executable" - | "loading-runtimelib" - | "needs-config" - | "done" - | { type: "error"; error: string }; - -export type TaskConcurrency = "join" | "skip" | "parallel" | "queue"; - -export interface TaskContext { - emit(update: TUpdate): void; - signal: AbortSignal; - runId: string; -} - -export type TaskRunner< - TUpdate = unknown, - TResult = void, - TArgs extends unknown[] = [], -> = (ctx: TaskContext, ...args: TArgs) => Promise; - -export interface TaskDefinition< - TUpdate = unknown, - TResult = void, - TArgs extends unknown[] = [], -> { - name: string; - concurrency: TaskConcurrency; - run: TaskRunner; -} - -export interface StartupTask { - onUpdate(callback: (update: StartupTaskUpdate) => void): () => void; - start(): Promise; -} - -export interface GeneralTask { - getProjects(): Promise; - createProject(payload: { - name: string; - location: string; - style: CreateProjectStyle; - }): Promise; - openProject(payload: { path: string }): Promise; -} - -export interface EditorControlsApi { - setEnabled(enabled: boolean): Promise; - setPlaying(playing: boolean): Promise; - setMode(mode: EditorControlMode): Promise; - getSelection(): Promise<{ id: number; name: string }>; -} - -export type WindowHandle = { - id: string; - window: TWindow; -}; - -export type WindowMaker = () => Promise< - WindowHandle ->; - -declare global { - interface Window { - app: WindowApi; - startupTask: StartupTask; - tasks: GeneralTask; - editorControls: EditorControlsApi; - } -} diff --git a/editor/styling/dark.qss b/editor/styling/dark.qss new file mode 100644 index 00000000..e45929f7 --- /dev/null +++ b/editor/styling/dark.qss @@ -0,0 +1,1369 @@ +* { + font-family: "Manrope"; + font-size: 12px; + color: #E7ECF3; + selection-background-color: #647B8D; + selection-color: #FFFFFF; +} + +QWidget { + background-color: #18191B; + color: #E7ECF3; +} + +QMainWindow, +QDialog, +QFrame { + background-color: #18191B; +} + +QLabel { + background: transparent; +} + +QLabel:disabled { + color: #566174; +} + +QToolTip { + background-color: #34373A; + color: #F7F9FC; + border: 1px solid #505459; + border-radius: 8px; + padding: 4px 6px; +} + +QGroupBox { + background-color: #242628; + border: 1px solid #3A3D40; + border-radius: 10px; + margin-top: 14px; + padding: 8px; + color: #F1F4F8; + font-weight: 650; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 9px; + padding: 0 6px; + color: #AEB8C8; + background-color: #242628; +} + +QScrollArea, +QAbstractScrollArea { + background-color: #1E2022; + border: none; +} + +QAbstractScrollArea::corner { + background-color: #1E2022; +} + +QScrollBar:vertical { + background-color: transparent; + width: 9px; + margin: 2px; + border: none; +} + +QScrollBar:horizontal { + background-color: transparent; + height: 9px; + margin: 2px; + border: none; +} + +QScrollBar::handle:vertical, +QScrollBar::handle:horizontal { + background-color: #505357; + border-radius: 4px; + min-height: 30px; + min-width: 30px; +} + +QScrollBar::handle:vertical:hover, +QScrollBar::handle:horizontal:hover { + background-color: #65696E; +} + +QScrollBar::add-line, +QScrollBar::sub-line, +QScrollBar::add-page, +QScrollBar::sub-page { + background: none; + border: none; + width: 0; + height: 0; +} + +QPushButton, +QToolButton { + background-color: #2B2E31; + border: 1px solid #45494D; + border-radius: 8px; + padding: 4px 8px; + color: #E9EDF4; + min-height: 18px; + font-weight: 550; +} + +QPushButton:hover, +QToolButton:hover { + background-color: #363A3E; + border-color: #5A5F65; +} + +QPushButton:pressed, +QToolButton:pressed { + background-color: #232527; + border-color: #6F7B84; +} + +QPushButton:checked, +QToolButton:checked { + background-color: #3A4248; + border-color: #6F7B84; + color: #FFFFFF; +} + +QPushButton:default, +#primaryAction { + background-color: #596A76; + border-color: #73838E; + color: #FFFFFF; + font-weight: 650; +} + +QPushButton:default:hover, +#primaryAction:hover { + background-color: #667985; + border-color: #82919B; +} + +QPushButton:disabled, +QToolButton:disabled { + background-color: #202224; + border-color: #303235; + color: #566174; +} + +QToolButton::menu-indicator { + image: none; +} + +QLineEdit, +QTextEdit, +QPlainTextEdit, +QComboBox, +QSpinBox, +QDoubleSpinBox, +QDateEdit, +QTimeEdit, +QDateTimeEdit { + background-color: #1B1D1F; + border: 1px solid #3B3E42; + border-radius: 8px; + padding: 4px 6px; + color: #EEF2F7; +} + +QLineEdit:hover, +QTextEdit:hover, +QPlainTextEdit:hover, +QComboBox:hover, +QSpinBox:hover, +QDoubleSpinBox:hover { + border-color: #54585D; +} + +QLineEdit:focus, +QTextEdit:focus, +QPlainTextEdit:focus, +QComboBox:focus, +QSpinBox:focus, +QDoubleSpinBox:focus { + background-color: #222426; + border-color: #71808A; +} + +QLineEdit:disabled, +QTextEdit:disabled, +QComboBox:disabled, +QSpinBox:disabled, +QDoubleSpinBox:disabled { + background-color: #202224; + border-color: #303235; + color: #566174; +} + +QComboBox { + padding-right: 24px; +} + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 22px; + border-left: 1px solid #3B3E42; +} + +QComboBox QAbstractItemView { + background-color: #292C2F; + border: 1px solid #494D52; + border-radius: 10px; + padding: 3px; + selection-background-color: #3A4248; +} + +QAbstractSpinBox::up-button, +QAbstractSpinBox::down-button { + background-color: #292C2F; + border: none; + width: 16px; +} + +QAbstractSpinBox::up-button:hover, +QAbstractSpinBox::down-button:hover { + background-color: #3E4246; +} + +QCheckBox, +QRadioButton { + spacing: 7px; + color: #C9D1DE; +} + +QCheckBox::indicator, +QRadioButton::indicator { + width: 15px; + height: 15px; + background-color: #1B1D1F; + border: 1px solid #55595D; + border-radius: 5px; +} + +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + border-color: #747B81; +} + +QCheckBox::indicator:checked, +QRadioButton::indicator:checked { + background-color: #71808A; + border-color: #89969F; +} + +QSlider::groove:horizontal { + background-color: #34373A; + height: 4px; + border-radius: 2px; +} + +QSlider::handle:horizontal { + background-color: #7D8991; + border: 2px solid #A8AFB4; + width: 12px; + margin: -5px 0; + border-radius: 7px; +} + +QProgressBar { + background-color: #26282B; + border: 1px solid #42464A; + border-radius: 6px; + height: 8px; + text-align: center; +} + +QProgressBar::chunk { + background-color: #71808A; + border-radius: 6px; +} + +QTreeView, +QListView, +QListWidget, +QTableView, +QTableWidget { + background-color: #1E2022; + alternate-background-color: #232527; + border: none; + color: #DCE3EC; + show-decoration-selected: 1; +} + +QTreeView::item, +QListView::item, +QListWidget::item, +QTableView::item, +QTableWidget::item { + border: 1px solid transparent; + border-radius: 7px; + padding: 3px 5px; +} + +QTreeView::item:hover, +QListView::item:hover, +QListWidget::item:hover, +QTableView::item:hover, +QTableWidget::item:hover { + background-color: #2B2E31; + border-color: #43474B; +} + +QTreeView::item:selected, +QListView::item:selected, +QListWidget::item:selected, +QTableView::item:selected, +QTableWidget::item:selected { + background-color: #393F44; + border-color: #66737C; + color: #FFFFFF; +} + +QHeaderView { + background-color: #202224; +} + +QHeaderView::section { + background-color: #292C2F; + border: none; + border-right: 1px solid #404347; + border-bottom: 1px solid #404347; + padding: 5px 7px; + color: #98A4B7; + font-weight: 600; +} + +QTabWidget::pane { + background-color: #1E2022; + border: 1px solid #3B3E42; + border-radius: 0 0 10px 10px; +} + +QTabBar::tab { + background-color: #202224; + border: none; + border-right: 1px solid #383B3F; + border-bottom: 1px solid #3B3E42; + color: #7F8B9D; + min-width: 88px; + padding: 5px 10px; + margin: 2px 1px; + border-radius: 8px; +} + +QTabBar::tab:hover { + background-color: #2B2E31; + color: #C8D1DF; +} + +QTabBar::tab:selected { + background-color: #36393C; + color: #F2F5F9; + border-bottom: 2px solid #78858E; +} + +QMenuBar#atlasMenuBar { + background-color: #141517; + border-bottom: 1px solid #303236; + padding: 2px 6px; +} + +QMenuBar#atlasMenuBar::item { + background: transparent; + color: #B8C2D0; + padding: 5px 9px; + border-radius: 7px; +} + +QMenuBar#atlasMenuBar::item:selected, +QMenuBar#atlasMenuBar::item:pressed { + background-color: #34373A; + color: #FFFFFF; +} + +QMenu { + background-color: #292C2F; + border: 1px solid #494D52; + border-radius: 10px; + padding: 3px; +} + +QMenu::item { + background: transparent; + border-radius: 7px; + padding: 5px 30px 5px 8px; + color: #DCE3EC; +} + +QMenu::item:selected { + background-color: #3A3E42; + color: #FFFFFF; +} + +QMenu::item:disabled { + color: #596477; +} + +QMenu::separator { + background-color: #424549; + height: 1px; + margin: 5px 8px; +} + +QSplitter::handle { + background-color: #111214; +} + +QSplitter::handle:horizontal { + width: 4px; +} + +QSplitter::handle:vertical { + height: 4px; +} + +QSplitter::handle:hover { + background-color: #71889A; +} + +QStatusBar { + background-color: #141517; + border-top: 1px solid #303236; + color: #8490A4; +} + +#atlasStatusBar { + min-height: 21px; + padding: 0 6px; +} + +#statusRuntimeIcon { + padding: 0 4px; +} + +#statusRenderer { + background-color: #2B2E30; + border: 1px solid #45494D; + border-radius: 8px; + color: #B6BCB8; + font-size: 9px; + font-weight: 700; + padding: 2px 6px; +} + +#statusVersion { + color: #68758A; + font-size: 9px; + padding: 0 6px; +} + +#workspaceBar { + background-color: #1E2022; + border: none; + border-bottom: 1px solid #3B3E42; + spacing: 3px; + padding: 2px 5px 2px 0; +} + +#workspaceIdentity { + background: transparent; + border-right: 1px solid #424549; +} + +#workspaceMark { + background: transparent; +} + +#workspaceBrand { + color: #F5F7FA; + font-size: 12px; + font-weight: 800; +} + +#workspaceProject { + color: #7F8B9D; + font-size: 11px; +} + +#workspaceModeButton { + background-color: transparent; + border: 1px solid transparent; + border-radius: 9px; + color: #94A0B2; + padding: 4px 8px; + margin: 0 1px; +} + +#workspaceModeButton:hover { + background-color: #303336; + color: #DDE4ED; +} + +#workspaceModeButton:checked { + background-color: #3A3E42; + border-color: #5E656B; + color: #FFFFFF; +} + +#workspaceUtilityButton { + background: transparent; + border-color: transparent; + min-width: 26px; + padding: 3px 5px; +} + +#workspaceBuildButton, +#workspaceLaunchButton { + min-width: 26px; + padding: 3px 5px; + background-color: #292C2F; + border-color: #45494D; + margin-left: 3px; +} + +#workspaceBuildButton:hover, +#workspaceLaunchButton:hover { + background-color: #363A3E; + border-color: #5A5F65; +} + +#sceneTabs { + background-color: #191A1C; + border-bottom: 1px solid #3B3E42; +} + +#sceneTabs::tab { + background-color: #202224; + border-right: 1px solid #3A3D40; + color: #7F8B9D; + min-width: 112px; + padding: 5px 10px; + margin: 2px; + border-radius: 8px; +} + +#sceneTabs::tab:selected { + background-color: #36393C; + color: #F3F6FA; + border-bottom: 2px solid #78858E; +} + +#viewportTools { + background-color: #141517; +} + +#viewportToolbar { + background-color: #202224; + border-bottom: 1px solid #3B3E42; +} + +#viewportPlaybackButton, +#viewportModeButton, +#viewportOptionButton, +#viewportShadingButton { + background-color: #292C2F; + border-color: #414549; + min-width: 25px; + min-height: 22px; + padding: 3px 5px; +} + +#viewportPlaybackButton:hover, +#viewportModeButton:hover, +#viewportOptionButton:hover, +#viewportShadingButton:hover { + background-color: #363A3E; + border-color: #5A5F65; +} + +#viewportModeButton:checked, +#viewportShadingButton:checked { + background-color: #3A4248; + border-color: #6F7B84; +} + +#viewportFpsLabel { + color: #9CA5A0; + font-size: 10px; + font-weight: 650; +} + +#viewportShortcutHint { + background-color: #141517; + border-top: 1px solid #303236; + color: #68758A; + font-size: 10px; + padding: 3px 7px; +} + +#panelToolbar, +#materialEditorHeader, +#postProcessingToolbar, +#workspaceToolbar { + background-color: #232527; + border-bottom: 1px solid #404347; + padding: 3px; +} + +#panelAddButton, +#materialSaveButton, +#workspaceApplyButton { + background-color: #303438; + border-color: #4C5257; + color: #D8DBDE; +} + +#panelMoreButton, +#browserNavigationButton, +#browserRevealButton { + background-color: transparent; + border-color: transparent; + min-width: 24px; +} + +#panelMoreButton:hover, +#browserNavigationButton:hover, +#browserRevealButton:hover { + background-color: #34373A; + border-color: #484C50; +} + +#contentPathField { + background-color: #1B1D1F; + color: #8490A4; + font-size: 11px; +} + +#contentSearchField { + min-width: 130px; +} + +QTreeView#hierarchyTree { + background-color: #1E2022; + border-top: 1px solid #303236; + padding: 5px 3px; +} + +QTreeView#hierarchyTree::item { + min-height: 24px; + padding: 3px 5px; +} + +QTreeView#hierarchyTree::item:selected { + background-color: #343C43; + border-left: 2px solid #8498A8; +} + +QListView#contentGrid { + background-color: #1A1C1E; + border-top: 1px solid #303236; + padding: 6px; +} + +QListView#contentGrid::item { + background-color: #232527; + border: 1px solid #34373A; + border-radius: 11px; + padding: 7px; + color: #BCC6D4; +} + +QListView#contentGrid::item:hover { + background-color: #2E3134; + border-color: #4B5055; +} + +QListView#contentGrid::item:selected { + background-color: #393F44; + border-color: #66737C; + color: #FFFFFF; +} + +QScrollArea#inspectorScroll, +#inspectorContent, +#materialEditorBody, +#postProcessingBody { + background-color: #1E2022; + border: none; +} + +#inspectorHeader { + background-color: #25272A; + border: 1px solid #404347; + border-radius: 12px; + padding: 6px; +} + +#inspectorObjectIcon { + background-color: #34373B; + border: 1px solid #4E5257; + border-radius: 10px; + padding: 4px; +} + +#inspectorNameField { + background: transparent; + border: 1px solid transparent; + color: #F3F6FA; + font-size: 14px; + font-weight: 700; +} + +#inspectorNameField:hover, +#inspectorNameField:focus { + background-color: #1E2022; + border-color: #5A5F65; +} + +#inspectorTypeLabel { + color: #7E8A9E; + font-size: 10px; + font-weight: 600; +} + +#inspectorComponent { + background-color: #242628; + border: 1px solid #3B3E41; + border-radius: 11px; + margin-top: 3px; +} + +#inspectorComponentHeaderRow { + background-color: #292C2F; + border-bottom: 1px solid #3B3E41; + border-radius: 11px 11px 0 0; +} + +#inspectorComponentHeader { + background: transparent; + border: none; + color: #E8ECF2; + font-weight: 650; + text-align: left; + padding: 5px 7px; +} + +#inspectorComponentHeader:hover { + background-color: #303337; +} + +#inspectorComponentRemoveButton { + background: transparent; + border: none; + min-width: 22px; +} + +#inspectorComponentRemoveButton:hover { + background-color: #3A3031; + border: 1px solid #695556; +} + +#inspectorComponentBody { + background-color: #242628; + padding: 5px; +} + +#inspectorPropertyRow { + background: transparent; + min-height: 27px; +} + +#inspectorPropertyLabel { + color: #929EB0; + font-size: 11px; +} + +#inspectorVectorField, +#inspectorColorField, +#inspectorNumericField { + background-color: #1B1D1F; + border: 1px solid #404448; + border-radius: 9px; +} + +#inspectorVectorField QDoubleSpinBox, +#inspectorNumericField QDoubleSpinBox { + background: transparent; + border: none; +} + +#inspectorAxisLabel { + background-color: #34373A; + border-radius: 7px; + color: #AAB5C5; + font-size: 9px; + font-weight: 750; + padding: 2px 4px; +} + +#inspectorSyncButton, +#inspectorArrayButton { + background: transparent; + border-color: transparent; + min-width: 22px; +} + +#inspectorSyncButton[matched="true"] { + background-color: #343936; + border-color: #555F59; + color: #B6BEB9; +} + +#inspectorColorSwatch { + background-color: #292C2F; + border: 1px solid #4D5155; + border-radius: 8px; + padding: 2px; +} + +#inspectorColorText { + color: #9CA8BA; + font-family: "Menlo"; + font-size: 10px; +} + +#inspectorNestedGroup { + background-color: #202224; + border: 1px solid #383B3F; + border-radius: 10px; + margin: 3px 0; + padding: 5px; +} + +#inspectorNestedTitle, +#inspectorArrayTitle { + color: #AEB8C8; + font-weight: 650; +} + +#inspectorEmptyTitle, +#materialEditorEmpty, +#postProcessingEmpty { + color: #C4CDDA; + font-size: 14px; + font-weight: 650; +} + +#inspectorEmptyHint { + color: #728096; +} + +#inspectorOpenAssetButton, +#inspectorAddComponentButton { + background-color: #292C2F; + border: 1px dashed #5A5F65; + color: #C7D0DD; + padding: 6px; +} + +#inspectorAddComponentButton:hover { + background-color: #363A3E; + border-color: #646B70; + color: #FFFFFF; +} + +#inspectorAudioControls { + background-color: #202224; + border-radius: 9px; +} + +#materialEditorTitle, +#postProcessingTitle, +#postProcessingSectionTitle, +#inspectorCameraTitle, +#workspaceTitle { + color: #F3F6FA; + font-size: 13px; + font-weight: 700; +} + +#materialEditorStatus, +#postProcessingStatus, +#workspaceStatus, +#workspaceAutoApply { + color: #7E8A9E; + font-size: 10px; +} + +#materialPreview { + background-color: #141517; + border: 1px solid #44484C; + border-radius: 12px; +} + +#materialColorButton { + background-color: #292C2F; + border-color: #45494D; + border-radius: 9px; + color: #C9CDD0; + text-align: left; + padding: 4px 8px; +} + +#materialColorButton:hover { + background-color: #34383B; + border-color: #5A5F65; +} + +#materialTextureSlot { + background-color: #232527; + border: 1px solid #404448; + border-radius: 10px; + padding: 4px; +} + +#materialTexturePreview { + background-color: #1A1C1E; + border: 1px solid #494D52; + border-radius: 9px; + color: #9E897D; + font-weight: 750; +} + +#materialTextureLabel { + color: #D6DDE7; + font-weight: 650; +} + +#materialTexturePath { + color: #7D899C; +} + +#environmentPages, +#environmentWorkspace, +#environmentPage { + background-color: #1E2022; + border: none; +} + +QListWidget#environmentCategories { + background-color: #1B1D1F; + border-right: 1px solid #3B3E42; + padding: 5px; +} + +QListWidget#environmentCategories::item { + padding: 6px 8px; + margin: 2px 0; +} + +QListWidget#environmentCategories::item:selected { + background-color: #343C43; + border-left: 2px solid #8498A8; +} + +#environmentPageTitle { + color: #F3F6FA; + font-size: 17px; + font-weight: 750; +} + +#environmentPageSubtitle { + color: #778499; +} + +QGroupBox#environmentSection { + background-color: #242628; + border-color: #3B3E42; +} + +ads--CDockContainerWidget { + background-color: #111214; +} + +ads--CDockContainerWidget > QSplitter { + background-color: #111214; +} + +ads--CDockContainerWidget ads--CDockSplitter::handle { + background-color: #111214; +} + +ads--CDockContainerWidget ads--CDockSplitter::handle:hover { + background-color: #71889A; +} + +ads--CDockAreaWidget { + background-color: #1E2022; + border: 1px solid #34373A; +} + +ads--CDockAreaWidget[focused="true"] { + border-color: #50545A; +} + +ads--CDockAreaTitleBar { + background-color: #202224; + border-bottom: 1px solid #3B3E42; + min-height: 26px; +} + +ads--CDockAreaWidget[focused="true"] ads--CDockAreaTitleBar { + background-color: #26282B; +} + +#tabsContainerWidget { + background-color: transparent; +} + +ads--CTitleBarButton { + background: transparent; + border: none; + border-radius: 7px; + min-width: 22px; + min-height: 22px; + padding: 2px; +} + +ads--CTitleBarButton:hover { + background-color: #3E4246; +} + +ads--CDockWidgetTab { + background-color: #202224; + border: none; + border-right: 1px solid #3A3D40; + border-bottom: 1px solid #3B3E42; + padding: 1px 5px; + margin: 2px 1px; + border-radius: 7px; +} + +ads--CDockWidgetTab:hover { + background-color: #2C2F32; +} + +ads--CDockWidgetTab[activeTab="true"] { + background-color: #373A3D; + border-bottom: 2px solid #78858E; +} + +ads--CDockWidgetTab[focused="true"] { + background-color: #2B2E31; +} + +ads--CDockWidgetTab #dockWidgetTabLabel { + color: #7F8B9D; + font-size: 11px; + padding: 4px 2px; +} + +ads--CDockWidgetTab[activeTab="true"] #dockWidgetTabLabel, +ads--CDockWidgetTab[focused="true"] #dockWidgetTabLabel { + color: #F1F4F8; +} + +#tabCloseButton, +#dockAreaCloseButton, +#detachGroupButton, +#dockAreaAutoHideButton, +#dockAreaMinimizeButton, +#tabsMenuButton { + background: transparent; + border: none; + min-width: 20px; + min-height: 20px; +} + +#tabCloseButton:hover, +#dockAreaCloseButton:hover, +#detachGroupButton:hover, +#dockAreaAutoHideButton:hover, +#dockAreaMinimizeButton:hover, +#tabsMenuButton:hover { + background-color: #424549; +} + +ads--CDockWidget { + background-color: #1E2022; +} + +ads--CAutoHideSideBar, +#sideTabsContainerWidget { + background-color: #141517; +} + +ads--CAutoHideTab { + background-color: #202224; + border: 1px solid #404448; + color: #7F8B9D; + padding: 4px; + border-radius: 8px; +} + +ads--CAutoHideTab:hover, +ads--CAutoHideTab[activeTab="true"] { + background-color: #393F44; + border-color: #66737C; + color: #FFFFFF; +} + +#projectBrowserRoot, +#projectBrowserContent { + background-color: #18191B; +} + +#projectSidebar { + background-color: #151618; + border-right: 1px solid #303236; +} + +#projectBrand { + color: #F5F7FA; + font-size: 16px; + font-weight: 750; +} + +#projectSidebarVersion { + color: #68758A; + font-size: 10px; + font-weight: 600; +} + +#projectNavSelected { + background-color: #34383C; + border: 1px solid #51575C; + border-left: 3px solid #79868F; + border-radius: 10px; + color: #DCE9FA; + padding: 6px 9px; + text-align: left; +} + +#projectNavSelected:disabled { + color: #DCE9FA; +} + +#projectBrowserTitle, +#dialogTitle { + color: #F4F6FA; + font-size: 24px; + font-weight: 750; +} + +#projectBrowserSubtitle, +#dialogSubtitle, +#emptyStateSubtitle, +#templateDescription { + color: #7F8B9D; +} + +#projectSearch { + background-color: #202224; + border: 1px solid #43474B; + border-radius: 11px; + padding: 7px 9px; + font-size: 13px; +} + +#projectSearch:focus { + border-color: #71808A; +} + +#projectList { + background-color: transparent; + border: none; +} + +#projectList::item { + background: transparent; + border: none; + padding: 0; +} + +#projectRow { + background-color: #242628; + border: 1px solid #3B3E41; + border-radius: 13px; +} + +#projectRow:hover { + background-color: #2C2F32; + border-color: #565B61; +} + +#projectRow[available="false"] { + background-color: #202224; + border-color: #303235; +} + +#projectIcon, +#templateIcon { + background-color: #303337; + border: 1px solid #484C50; + border-radius: 10px; +} + +#projectName { + color: #EEF2F7; + font-size: 14px; + font-weight: 700; +} + +#projectPath, +#projectDate { + color: #768398; + font-size: 10px; +} + +#rendererBadge { + background-color: #2C302E; + border: 1px solid #494F4B; + border-radius: 8px; + color: #B4BBB6; + padding: 3px 7px; + font-size: 9px; + font-weight: 700; +} + +#projectMoreButton { + background: transparent; + border-color: transparent; +} + +#projectMoreButton:hover { + background-color: #3E4246; + border-color: #51565B; +} + +#projectEmptyState { + background-color: #1E2022; + border: 1px dashed #484C51; + border-radius: 14px; +} + +#emptyStateTitle { + color: #CDD5E1; + font-size: 16px; + font-weight: 700; +} + +#createProjectDialog { + background-color: #1E2022; +} + +QFrame[templateCard="true"] { + background-color: #242628; + border: 1px solid #42464A; + border-radius: 13px; +} + +QFrame[templateCard="true"]:hover { + background-color: #2B2E31; + border-color: #5A5F65; +} + +QFrame[templateCard="true"][selected="true"] { + background-color: #393F44; + border-color: #66737C; +} + +#templateOption { + color: #E7ECF3; + font-weight: 650; +} + +#fieldLabel { + color: #AAB5C5; + font-size: 11px; + font-weight: 650; +} + +#dialogError { + color: #FF7D8A; + font-size: 11px; +} + +QPushButton[secondary="true"] { + background-color: #292C2F; + border-color: #494D52; +} + +QPushButton[secondary="true"]:hover { + background-color: #363A3E; + border-color: #5D6268; +} + +#projectSettingsDialog, +#exportDialog { + background-color: #18191B; +} + +#dialogHero { + background-color: #242628; + border: 1px solid #42464A; + border-radius: 13px; + padding: 6px; +} + +#dialogHeroIcon { + background-color: #2B3034; + border: 1px solid #50575C; + border-radius: 10px; + padding: 6px; +} + +#dialogHeroTitle { + color: #F3F6FA; + font-size: 17px; + font-weight: 750; +} + +#dialogHeroSubtitle, +#exportSummary { + color: #7F8B9D; +} + +#exportSummary { + background-color: #232527; + border: 1px solid #3B3E42; + border-radius: 10px; + padding: 6px; +} + +#exportLog { + background-color: #141517; + border-color: #404448; + color: #B7BDBC; + font-family: "Menlo"; + font-size: 10px; +} + +#commandPaletteDialog, +#globalSearchDialog { + background-color: #202224; + border: 1px solid #5A5F65; + border-radius: 14px; +} + +#commandPaletteSearch, +#globalSearchField { + background-color: #1A1C1E; + border: 1px solid #505459; + border-radius: 11px; + padding: 7px 9px; + font-size: 14px; +} + +#commandPaletteSearch:focus, +#globalSearchField:focus { + border-color: #71808A; +} + +#commandPaletteList, +#globalSearchResults { + background-color: #202224; + border: none; + padding: 5px; +} + +#commandPaletteList::item, +#globalSearchResults::item { + min-height: 30px; + padding: 6px 8px; +} diff --git a/editor/styling/icons.cpp b/editor/styling/icons.cpp new file mode 100644 index 00000000..7eaadb58 --- /dev/null +++ b/editor/styling/icons.cpp @@ -0,0 +1,242 @@ +#include "editor/styling/icons.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +QString iconFamily; +bool iconFontAttempted = false; + +ushort codepoint(styling::Icon icon) { + switch (icon) { + case styling::Icon::Aperture: + return 0xE00A; + case styling::Icon::ArrowClockwise: + return 0xE036; + case styling::Icon::ArrowCounterClockwise: + return 0xE038; + case styling::Icon::ArrowLeft: + return 0xE058; + case styling::Icon::ArrowRight: + case styling::Icon::Assign: + return 0xE06C; + case styling::Icon::ArrowUp: + return 0xE08E; + case styling::Icon::ArrowsOutCardinal: + return 0xE0A4; + case styling::Icon::BoundingBox: + return 0xE6CE; + case styling::Icon::Camera: + return 0xE10E; + case styling::Icon::CaretDown: + return 0xE136; + case styling::Icon::CaretLeft: + return 0xE138; + case styling::Icon::CaretRight: + return 0xE13A; + case styling::Icon::CaretUp: + return 0xE13C; + case styling::Icon::Check: + return 0xE182; + case styling::Icon::Close: + return 0xE4F6; + case styling::Icon::Cloud: + return 0xE1AA; + case styling::Icon::Code: + return 0xE1BC; + case styling::Icon::Crosshair: + return 0xE1D6; + case styling::Icon::Cube: + return 0xE1DA; + case styling::Icon::CubeFocus: + return 0xED0A; + case styling::Icon::CubeTransparent: + return 0xEC7C; + case styling::Icon::CursorClick: + return 0xE7C8; + case styling::Icon::Database: + return 0xE1DE; + case styling::Icon::DotsVertical: + return 0xE208; + case styling::Icon::DotsNine: + return 0xE1FC; + case styling::Icon::Export: + return 0xEAF0; + case styling::Icon::Eye: + return 0xE220; + case styling::Icon::EyeSlash: + return 0xE224; + case styling::Icon::File: + return 0xE230; + case styling::Icon::FileCode: + return 0xE914; + case styling::Icon::FilmStrip: + return 0xE792; + case styling::Icon::FloppyDisk: + return 0xE248; + case styling::Icon::Folder: + return 0xE24A; + case styling::Icon::FolderOpen: + return 0xE256; + case styling::Icon::GameController: + return 0xE26E; + case styling::Icon::Gear: + return 0xE270; + case styling::Icon::Globe: + return 0xE288; + case styling::Icon::Hand: + return 0xE298; + case styling::Icon::HardDrives: + return 0xE2A0; + case styling::Icon::Image: + return 0xE2CA; + case styling::Icon::Info: + return 0xE2CE; + case styling::Icon::Layout: + return 0xE6D6; + case styling::Icon::Lightbulb: + return 0xE2DC; + case styling::Icon::MagnifyingGlass: + return 0xE30C; + case styling::Icon::Material: + return 0xE6F0; + case styling::Icon::Monitor: + return 0xE32E; + case styling::Icon::MonitorPlay: + return 0xE58C; + case styling::Icon::Mountains: + return 0xE7AE; + case styling::Icon::MusicNote: + return 0xE33C; + case styling::Icon::Package: + return 0xE390; + case styling::Icon::PaintBrush: + return 0xE6F0; + case styling::Icon::Palette: + return 0xE6C8; + case styling::Icon::Pause: + return 0xE39E; + case styling::Icon::Play: + return 0xE3D0; + case styling::Icon::Plus: + return 0xE3D4; + case styling::Icon::RocketLaunch: + return 0xE3FE; + case styling::Icon::Rows: + return 0xE5A2; + case styling::Icon::Sidebar: + return 0xEAB6; + case styling::Icon::SkipForward: + return 0xE5A6; + case styling::Icon::SlidersHorizontal: + return 0xE434; + case styling::Icon::Sparkle: + return 0xE6A2; + case styling::Icon::SpeakerHigh: + return 0xE44A; + case styling::Icon::Sphere: + return 0xEE66; + case styling::Icon::SquaresFour: + return 0xE464; + case styling::Icon::Stack: + return 0xE466; + case styling::Icon::Stop: + return 0xE46C; + case styling::Icon::Sun: + return 0xE472; + case styling::Icon::TerminalWindow: + return 0xEAE8; + case styling::Icon::Trash: + return 0xE4A6; + case styling::Icon::TreeStructure: + return 0xE67C; + case styling::Icon::Warning: + return 0xE4E0; + case styling::Icon::Waveform: + return 0xE802; + case styling::Icon::Wrench: + return 0xE5D4; + } + return 0xE230; +} + +QPixmap renderIcon(styling::Icon icon, const QColor &color, int size) { + QPixmap pixmap(size, size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + painter.setPen(color); + QFont font(iconFamily); + font.setPixelSize(qRound(size * 0.82)); + font.setStyleStrategy(QFont::PreferAntialias); + painter.setFont(font); + painter.drawText(pixmap.rect(), Qt::AlignCenter, + QString(QChar(codepoint(icon)))); + return pixmap; +} + +} + +bool styling::loadIconFont() { + if (iconFontAttempted) + return !iconFamily.isEmpty(); + iconFontAttempted = true; + const int id = QFontDatabase::addApplicationFont( + ":/editor/assets/Phosphor.ttf"); + const QStringList families = QFontDatabase::applicationFontFamilies(id); + if (!families.isEmpty()) + iconFamily = families.first(); + return !iconFamily.isEmpty(); +} + +QIcon styling::icon(Icon icon, const QColor &color) { + loadIconFont(); + if (iconFamily.isEmpty()) + return {}; + QIcon result; + const QColor disabled("#566174"); + const QColor active = color.lighter(118); + for (const int size : {16, 20, 24, 32, 48}) { + result.addPixmap(renderIcon(icon, color, size), QIcon::Normal, + QIcon::Off); + result.addPixmap(renderIcon(icon, active, size), QIcon::Active, + QIcon::Off); + result.addPixmap(renderIcon(icon, disabled, size), QIcon::Disabled, + QIcon::Off); + } + return result; +} + +QIcon styling::colorSwatch(const QColor &color, const QSize &size) { + const QSize swatchSize = size.expandedTo(QSize(8, 8)); + QPixmap pixmap(swatchSize); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF bounds = QRectF(pixmap.rect()).adjusted(0.5, 0.5, -0.5, -0.5); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor("#D0D0D0")); + painter.drawRoundedRect(bounds, 4.0, 4.0); + painter.save(); + QPainterPath clip; + clip.addRoundedRect(bounds, 4.0, 4.0); + painter.setClipPath(clip); + painter.fillRect(QRectF(bounds.left(), bounds.top(), bounds.width() / 2.0, + bounds.height() / 2.0), + QColor("#8A8A8A")); + painter.fillRect(QRectF(bounds.center().x(), bounds.center().y(), + bounds.width() / 2.0, bounds.height() / 2.0), + QColor("#8A8A8A")); + painter.fillRect(bounds, color); + painter.restore(); + painter.setBrush(Qt::NoBrush); + painter.setPen(QColor("#5B5D60")); + painter.drawRoundedRect(bounds, 4.0, 4.0); + return QIcon(pixmap); +} diff --git a/editor/styling/theme.cpp b/editor/styling/theme.cpp new file mode 100644 index 00000000..13440703 --- /dev/null +++ b/editor/styling/theme.cpp @@ -0,0 +1,58 @@ +/* +* theme.cpp +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Theme definition for the Atlas Editor +* Copyright (c) 2026 Max Van den Eynde +*/ + +#include +#include + +#include "editor/application/styling.h" +#include "editor/core/themes.h" + +void styling::applyColorPalette(QApplication& app) { + QPalette p; + + const QColor bg = QColor("#18191B"); + const QColor panel = QColor("#25272A"); + const QColor panel2 = QColor("#1E2022"); + const QColor text = QColor("#E7ECF3"); + const QColor mutedText = QColor("#8D9094"); + const QColor accent = QColor("#71808A"); + + p.setColor(QPalette::Window, bg); + p.setColor(QPalette::WindowText, text); + p.setColor(QPalette::Base, panel2); + p.setColor(QPalette::AlternateBase, panel); + p.setColor(QPalette::ToolTipBase, panel2); + p.setColor(QPalette::ToolTipText, text); + p.setColor(QPalette::Text, text); + p.setColor(QPalette::Button, QColor("#2B2E31")); + p.setColor(QPalette::ButtonText, text); + p.setColor(QPalette::BrightText, QColor("#FFFFFF")); + p.setColor(QPalette::Light, QColor("#505357")); + p.setColor(QPalette::Midlight, QColor("#424549")); + p.setColor(QPalette::Mid, QColor("#34373A")); + p.setColor(QPalette::Dark, QColor("#141517")); + p.setColor(QPalette::Shadow, QColor("#0D0E0F")); + p.setColor(QPalette::Highlight, accent); + p.setColor(QPalette::HighlightedText, QColor("#FFFFFF")); + p.setColor(QPalette::PlaceholderText, mutedText); + + p.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#566174")); + p.setColor(QPalette::Disabled, QPalette::Text, QColor("#566174")); + p.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#566174")); + p.setColor(QPalette::Disabled, QPalette::Button, QColor("#232527")); + p.setColor(QPalette::Disabled, QPalette::Base, QColor("#202224")); + p.setColor(QPalette::Disabled, QPalette::Highlight, QColor("#3A4146")); + + app.setPalette(p); +} + +void styling::applyTheme(QApplication& app) { + applyColorPalette(app); + app.setStyleSheet(QString::fromUtf8(DARK_THEME)); +} diff --git a/editor/tsconfig.base.json b/editor/tsconfig.base.json deleted file mode 100644 index 74e89831..00000000 --- a/editor/tsconfig.base.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "isolatedModules": true - } -} diff --git a/editor/tsconfig.json b/editor/tsconfig.json deleted file mode 100644 index b409dd62..00000000 --- a/editor/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.main.json" }, - { "path": "./tsconfig.preload.json" }, - { "path": "./tsconfig.renderer.json" } - ] -} diff --git a/editor/tsconfig.main.json b/editor/tsconfig.main.json deleted file mode 100644 index 092c760e..00000000 --- a/editor/tsconfig.main.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "ignoreDeprecations": "6.0", - "rootDir": "src", - "outDir": "dist-electron", - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", - "lib": ["ES2022"], - "types": ["node", "electron"], - }, - "include": ["src/main/**/*.ts", "src/shared/**/*.ts"], -} diff --git a/editor/tsconfig.preload.json b/editor/tsconfig.preload.json deleted file mode 100644 index 1e5f6688..00000000 --- a/editor/tsconfig.preload.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "ignoreDeprecations": "6.0", - "rootDir": "src", - "outDir": "dist-electron", - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", - "lib": ["ES2022", "DOM"], - "types": ["node", "electron"] - }, - "include": ["src/preload/**/*.ts", "src/shared/**/*.ts"] -} diff --git a/editor/tsconfig.renderer.json b/editor/tsconfig.renderer.json deleted file mode 100644 index 347b2fbb..00000000 --- a/editor/tsconfig.renderer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "ignoreDeprecations": "6.0", - "baseUrl": ".", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "jsx": "react-jsx", - "types": ["vite/client"] - }, - "include": [ - "src/renderer/src/**/*.ts", - "src/renderer/src/**/*.tsx", - "src/shared/**/*.ts" - ] -} diff --git a/editor/views/editor/editor.cpp b/editor/views/editor/editor.cpp new file mode 100644 index 00000000..871577db --- /dev/null +++ b/editor/views/editor/editor.cpp @@ -0,0 +1,1773 @@ +/* + * editor.cpp + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Editor view and window + * 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 +#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 "DockManager.h" +#include "editor/debug.h" +#include "editor/styling/icons.h" +#include "editor/views/fileExplorer.h" +#include "editor/views/hierarchyPanel.h" +#include "editor/views/inspectorView.h" +#include "editor/views/materialEditor.h" +#include "editor/views/postProcessing.h" +#include "editor/views/viewport.h" +#include "editor/views/viewportTools.h" + +namespace { +constexpr int DockStateVersion = 9; +constexpr auto DockStateKey = "docking/state/v9"; + +QIcon commandIcon(const QString &name) { + const QString command = name.toLower(); + if (command.contains("save")) + return styling::icon(styling::Icon::FloppyDisk, "#A1957D"); + if (command.contains("open")) + return styling::icon(styling::Icon::FolderOpen, "#7E929C"); + if (command.contains("new") || command.contains("create") || + command.contains("add")) + return styling::icon(styling::Icon::Plus, "#8498A8"); + if (command.contains("export")) + return styling::icon(styling::Icon::Export, "#7E929C"); + if (command.contains("build")) + return styling::icon(styling::Icon::Package, "#A1957D"); + if (command.contains("run") || command.contains("play")) + return styling::icon(styling::Icon::RocketLaunch, "#849589"); + if (command.contains("stop")) + return styling::icon(styling::Icon::Stop, "#A17F7F"); + if (command.contains("reload") || command.contains("refresh") || + command.contains("undo")) + return styling::icon(styling::Icon::ArrowCounterClockwise, "#7E929C"); + if (command.contains("redo")) + return styling::icon(styling::Icon::ArrowClockwise, "#7E929C"); + if (command.contains("settings")) + return styling::icon(styling::Icon::Gear, "#8498A8"); + if (command.contains("find") || command.contains("search") || + command.contains("palette")) + return styling::icon(styling::Icon::MagnifyingGlass, "#7E929C"); + if (command.contains("screenshot")) + return styling::icon(styling::Icon::Camera, "#9E897D"); + if (command.contains("delete") || command.contains("remove")) + return styling::icon(styling::Icon::Trash, "#A17F7F"); + if (command.contains("layout") || command.contains("window")) + return styling::icon(styling::Icon::Layout, "#8498A8"); + if (command.contains("close") || command.contains("quit")) + return styling::icon(styling::Icon::Close, "#A17F7F"); + if (command.contains("camera")) + return styling::icon(styling::Icon::Camera, "#9E897D"); + if (command.contains("light")) + return styling::icon(styling::Icon::Lightbulb, "#A1957D"); + if (command.contains("object")) + return styling::icon(styling::Icon::Cube, "#8498A8"); + return {}; +} + +bool copyExportPath(const QString &sourcePath, const QString &destinationPath, + QString *error) { + const QFileInfo source(sourcePath); + if (source.isDir()) { + if (!QDir().mkpath(destinationPath)) { + if (error != nullptr) + *error = QStringLiteral("Could not create %1") + .arg(destinationPath); + return false; + } + QDir sourceDirectory(sourcePath); + const QFileInfoList entries = sourceDirectory.entryInfoList( + QDir::NoDotAndDotDot | QDir::AllEntries | QDir::Hidden | + QDir::System); + for (const QFileInfo &entry : entries) { + if (!copyExportPath( + entry.absoluteFilePath(), + QDir(destinationPath).filePath(entry.fileName()), error)) + return false; + } + return true; + } + QFile::remove(destinationPath); + if (!QFile::copy(sourcePath, destinationPath)) { + if (error != nullptr) + *error = QStringLiteral("Could not copy %1").arg(sourcePath); + return false; + } + QFile::setPermissions(destinationPath, source.permissions()); + return true; +} + +QString atlasCliPath() { + const QDir applicationDirectory(QCoreApplication::applicationDirPath()); + const QStringList candidates{ + applicationDirectory.filePath("../../target/debug/atlas"), + applicationDirectory.filePath("../../target/release/atlas"), + applicationDirectory.filePath("atlas")}; + for (const QString &candidate : candidates) { + const QFileInfo info(candidate); + if (info.isFile() && info.isExecutable()) + return info.absoluteFilePath(); + } + return QStandardPaths::findExecutable("atlas"); +} + +QString tomlQuoted(QString value) { + value.replace('\\', "\\\\"); + value.replace('"', "\\\""); + value.replace('\n', "\\n"); + return QStringLiteral("\"%1\"").arg(value); +} + +void setTomlValue(QStringList *lines, const QString §ion, + const QString &key, const QString &value) { + int start = 0; + int end = lines->size(); + if (!section.isEmpty()) { + const QString heading = QStringLiteral("[%1]").arg(section); + start = lines->indexOf(heading); + if (start < 0) { + if (!lines->isEmpty() && !lines->last().isEmpty()) + lines->append(QString()); + lines->append(heading); + lines->append(QStringLiteral("%1 = %2").arg(key, value)); + return; + } + ++start; + } + for (int index = start; index < lines->size(); ++index) { + if (lines->at(index).trimmed().startsWith('[')) { + end = index; + break; + } + } + const QRegularExpression expression( + QStringLiteral("^\\s*%1\\s*=").arg(QRegularExpression::escape(key))); + for (int index = start; index < end; ++index) { + if (expression.match(lines->at(index)).hasMatch()) { + (*lines)[index] = QStringLiteral("%1 = %2").arg(key, value); + return; + } + } + lines->insert(end, QStringLiteral("%1 = %2").arg(key, value)); +} +} + +#ifndef ATLAS_VERSION +#define ATLAS_VERSION "Alpha 9" +#endif + +#ifndef ATLAS_BUILD_STRING +#define ATLAS_BUILD_STRING "" +#endif + +EditorWindow::EditorWindow(const QString &projectFile, QWidget *parent) + : QMainWindow(parent), projectFile(projectFile) { + setupWindow(); + setupMenus(); + setupDocks(); + setupWorkspaceBar(); + + restoreLayout(); + qApp->installEventFilter(this); + refreshScriptWatcher(); +} + +void EditorWindow::setupWindow() { + const auto project = ProjectStore::projectInfo(projectFile); + projectName = project.has_value() ? project->name : QStringLiteral("Project"); + updateWindowTitle(false); + setMinimumSize(1100, 700); + resize(1440, 900); + menuBar()->setNativeMenuBar(true); + menuBar()->setObjectName("atlasMenuBar"); + + ads::CDockManager::setConfigFlag(ads::CDockManager::OpaqueSplitterResize, + true); + ads::CDockManager::setConfigFlag(ads::CDockManager::FocusHighlighting, + true); + ads::CDockManager::setConfigFlag(ads::CDockManager::DisableStylesheet, + true); + ads::CDockManager::setConfigFlag( + ads::CDockManager::DockAreaHasTabsMenuButton, false); + ads::CDockManager::setConfigFlag(ads::CDockManager::DockAreaHasUndockButton, + true); + ads::CDockManager::setConfigFlag(ads::CDockManager::DockAreaHasCloseButton, + false); + + coreManager = new ads::CDockManager(this); + setCentralWidget(coreManager); + dockManager = new EditorDockManager(coreManager); + layoutSaveTimer = new QTimer(this); + scriptWatcher = new QFileSystemWatcher(this); + layoutSaveTimer->setSingleShot(true); + layoutSaveTimer->setInterval(250); + connect(layoutSaveTimer, &QTimer::timeout, this, [this] { + if (!restoringLayout && !closing) + saveLayout(); + }); + connect(scriptWatcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString &) { + QTimer::singleShot(120, this, [this] { + refreshScriptWatcher(); + if (viewportPanel != nullptr) + viewportPanel->reloadRuntime(); + }); + }); +} + +void EditorWindow::setupMenus() { + auto *fileMenu = menuBar()->addMenu("File"); + auto addCommand = [this](QMenu *menu, const QString &name, + const QString &shortcut, + const std::function &handler) { + QAction *action = menu->addAction(name, this, handler); + action->setIcon(commandIcon(name)); + if (!shortcut.isEmpty()) { + const bool plainShift = shortcut.startsWith("Shift+") && + !shortcut.contains("Meta+") && + !shortcut.contains("Alt+") && + !shortcut.contains("Ctrl+"); + if (plainShift) { + action->setProperty("atlasShortcut", shortcut); + } else { + QString nativeShortcut = shortcut; + nativeShortcut.replace("Meta+", "Ctrl+"); + action->setShortcut(QKeySequence(nativeShortcut)); + action->setShortcutContext(Qt::ApplicationShortcut); + } + } + return action; + }; + addCommand(fileMenu, "New Scene", "Meta+N", [this] { createScene(); }); + addCommand(fileMenu, "Open Scene…", "Meta+O", [this] { openScene(); }); + auto *saveAction = fileMenu->addAction("Save Scene"); + saveAction->setIcon( + styling::icon(styling::Icon::FloppyDisk, "#A1957D")); + saveAction->setShortcut(QKeySequence::Save); + saveAction->setShortcutContext(Qt::ApplicationShortcut); + connect(saveAction, &QAction::triggered, this, [this] { + if (materialEditorPanel != nullptr && + materialEditorPanel->isVisible()) { + materialEditorPanel->saveMaterial(); + } + if (viewportPanel != nullptr) { + viewportPanel->saveRuntimeScene(); + } + }); + addCommand(fileMenu, "Save Scene As…", "Meta+Shift+S", + [this] { saveSceneAs(); }); + addCommand(fileMenu, "Close Scene", "Meta+W", [this] { + if (viewportTools != nullptr) + viewportTools->closeCurrentSceneTab(); + }); + fileMenu->addSeparator(); + addCommand(fileMenu, "Export Project…", QString(), + [this] { showExportDialog(); }); + addCommand(fileMenu, "Build Project", "Meta+B", + [this] { runProjectCommand(true); }); + addCommand(fileMenu, "Run Project", "Meta+Shift+B", + [this] { runProjectCommand(false); }); + fileMenu->addSeparator(); + auto *quitAction = addCommand(fileMenu, "Quit Atlas Engine", "Meta+Q", + [] { QApplication::quit(); }); + quitAction->setMenuRole(QAction::QuitRole); + + auto *editMenu = menuBar()->addMenu("Edit"); + auto *undoAction = editMenu->addAction("Undo"); + undoAction->setIcon( + styling::icon(styling::Icon::ArrowCounterClockwise, "#7E929C")); + undoAction->setShortcut(QKeySequence::Undo); + undoAction->setShortcutContext(Qt::ApplicationShortcut); + connect(undoAction, &QAction::triggered, this, [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) { + field->undo(); + } else if (materialEditorPanel != nullptr && + materialEditorPanel->isAncestorOf( + QApplication::focusWidget())) { + materialEditorPanel->undo(); + } else if (viewportPanel != nullptr) { + viewportPanel->undo(); + } + }); + auto *redoAction = editMenu->addAction("Redo"); + redoAction->setIcon( + styling::icon(styling::Icon::ArrowClockwise, "#7E929C")); + redoAction->setShortcut(QKeySequence::Redo); + redoAction->setShortcutContext(Qt::ApplicationShortcut); + connect(redoAction, &QAction::triggered, this, [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) { + field->redo(); + } else if (materialEditorPanel != nullptr && + materialEditorPanel->isAncestorOf( + QApplication::focusWidget())) { + materialEditorPanel->redo(); + } else if (viewportPanel != nullptr) { + viewportPanel->redo(); + } + }); + editMenu->addSeparator(); + addCommand(editMenu, "Find…", "Meta+F", + [this] { showGlobalSearch(); }); + editMenu->addSeparator(); + addCommand(editMenu, "Cut", "Meta+X", [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) + field->cut(); + else if (contentBrowserHasFocus()) + contentBrowser->cutSelection(); + else if (viewportPanel != nullptr) + viewportPanel->cutSelectedRuntimeObject(); + }); + addCommand(editMenu, "Copy", "Meta+C", [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) + field->copy(); + else if (contentBrowserHasFocus()) + contentBrowser->copySelection(); + else if (viewportPanel != nullptr) + viewportPanel->copySelectedRuntimeObject(); + }); + addCommand(editMenu, "Paste", "Meta+V", [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) + field->paste(); + else if (contentBrowserHasFocus()) + contentBrowser->pasteSelection(); + else if (viewportPanel != nullptr) + viewportPanel->pasteRuntimeObject(); + }); + addCommand(editMenu, "Duplicate", "Meta+D", [this] { + if (contentBrowserHasFocus()) + contentBrowser->duplicateSelection(); + else if (viewportPanel != nullptr) + viewportPanel->duplicateSelectedRuntimeObject(); + }); + addCommand(editMenu, "Delete", "Backspace", [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) + field->backspace(); + else if (contentBrowserHasFocus()) + contentBrowser->deleteSelection(); + else if (hierarchyPanel != nullptr) + hierarchyPanel->deleteSelectedObject(); + }); + addCommand(editMenu, "Select All Objects", "Meta+A", [this] { + if (auto *field = qobject_cast(QApplication::focusWidget())) + field->selectAll(); + else if (contentBrowserHasFocus() && contentBrowser != nullptr) + contentBrowser->selectAllAssets(); + else if (hierarchyPanel != nullptr) + hierarchyPanel->selectAllObjects(); + }); + addCommand(editMenu, "Deselect All Objects", QString(), [this] { + if (hierarchyPanel != nullptr) + hierarchyPanel->deselectAllObjects(); + }); + editMenu->addSeparator(); + auto *settingsAction = + addCommand(editMenu, "Project Settings…", "Meta+,", + [this] { showProjectSettings(); }); + settingsAction->setMenuRole(QAction::PreferencesRole); + + auto *objectMenu = menuBar()->addMenu("Object"); + addCommand(objectMenu, "Create Empty", "Shift+N", [this] { + if (hierarchyPanel != nullptr) + hierarchyPanel->createObject("group", "Empty Object"); + }); + addCommand(objectMenu, "Create Camera", "Shift+C", [this] { + if (hierarchyPanel != nullptr) + hierarchyPanel->createObject("camera", "Camera"); + }); + addCommand(objectMenu, "Create Light", "Shift+L", [this] { + if (hierarchyPanel != nullptr) + hierarchyPanel->createObject("pointLight", "Point Light"); + }); + addCommand(objectMenu, "Add Object…", "Shift+A", [this] { + if (hierarchyPanel != nullptr) + hierarchyPanel->showCreationPopup(); + }); + addCommand(objectMenu, "Rename", QString(), [this] { + if (contentBrowserHasFocus()) + contentBrowser->renameSelection(); + else if (hierarchyPanel != nullptr) + hierarchyPanel->renameSelectedObject(); + }); + addCommand(objectMenu, "Reparent…", "Shift+R", [this] { + if (hierarchyPanel == nullptr || viewportPanel == nullptr) + return; + const int child = viewportPanel->selectedRuntimeObjectId(); + bool accepted = false; + const int parent = QInputDialog::getInt( + this, "Reparent Object", "Parent object runtime ID (-1 for root)", + -1, -1, std::numeric_limits::max(), 1, &accepted); + if (accepted && child >= 0) + viewportPanel->setRuntimeObjectParent(child, parent); + }); + objectMenu->addSeparator(); + addCommand(objectMenu, "Reset Position", "Meta+Alt+G", [this] { + if (viewportPanel != nullptr) + viewportPanel->resetSelectedTransform(1); + }); + addCommand(objectMenu, "Reset Rotation", "Meta+Alt+R", [this] { + if (viewportPanel != nullptr) + viewportPanel->resetSelectedTransform(2); + }); + addCommand(objectMenu, "Reset Scale", "Meta+Alt+S", [this] { + if (viewportPanel != nullptr) + viewportPanel->resetSelectedTransform(3); + }); + + viewMenu = menuBar()->addMenu("View"); + auto *resetLayoutAction = viewMenu->addAction("Reset Layout"); + resetLayoutAction->setIcon( + styling::icon(styling::Icon::Layout, "#8498A8")); + connect(resetLayoutAction, &QAction::triggered, this, [this] { + if (coreManager != nullptr && !defaultDockState.isEmpty()) { + restoringLayout = true; + coreManager->restoreState(defaultDockState, DockStateVersion); + restoringLayout = false; + configureDockSplitters(); + if (auto *dock = dockManager->panel("workspace")) + dock->setAsCurrentTab(); + scheduleLayoutSave(); + } + }); + addCommand(viewMenu, "Search Hierarchy", "Meta+Shift+F", [this] { + if (hierarchyPanel != nullptr) + hierarchyPanel->focusSearch(); + }); + addCommand(viewMenu, "Search Assets", "Meta+Alt+F", [this] { + if (contentBrowser != nullptr) + contentBrowser->focusSearch(); + }); + addCommand(viewMenu, "Toggle Local / World Transform Space", "Shift+T", + [this] { + if (viewportPanel != nullptr) + viewportPanel->toggleTransformSpace(); + }); + addCommand(viewMenu, "Toggle Transform Snapping", QString(), [this] { + if (viewportPanel != nullptr) + viewportPanel->toggleTransformSnapping(); + }); + addCommand(viewMenu, "Increase Snapping Increment", QString(), [this] { + if (viewportPanel != nullptr) + viewportPanel->changeTransformSnapIncrement(2.0f); + }); + addCommand(viewMenu, "Decrease Snapping Increment", QString(), [this] { + if (viewportPanel != nullptr) + viewportPanel->changeTransformSnapIncrement(0.5f); + }); + + auto *runMenu = menuBar()->addMenu("Run"); + addCommand(runMenu, "Play / Pause", "Meta+P", [this] { + if (viewportPanel == nullptr) + return; + viewportPanel->toggleRuntimePlayback(); + }); + addCommand(runMenu, "Stop", "Meta+Shift+L", [this] { + if (viewportPanel != nullptr) + viewportPanel->stopRuntimePlayback(); + }); + addCommand(runMenu, "Step One Frame", "Meta+Shift+K", [this] { + if (viewportPanel != nullptr) + viewportPanel->stepRuntimeOnce(); + }); + addCommand(runMenu, "Reload Scripts", QString(), [this] { + if (viewportPanel != nullptr) + viewportPanel->reloadRuntime(); + }); + addCommand(runMenu, "Refresh Asset Database", "Meta+Shift+R", [this] { + if (contentBrowser != nullptr) + contentBrowser->refreshAssets(); + }); + addCommand(runMenu, "Take Viewport Screenshot", QString(), + [this] { takeViewportScreenshot(); }); + + auto *toolsMenu = menuBar()->addMenu("Tools"); + auto *toolsSettings = + addCommand(toolsMenu, "Project Settings…", QString(), + [this] { showProjectSettings(); }); + toolsSettings->setMenuRole(QAction::NoRole); + addCommand(toolsMenu, "Install Atlas Toolchain…", QString(), + [this] { ToolchainInstaller::install(this); }); + addCommand(toolsMenu, "Command Palette…", "Meta+Shift+P", + [this] { showCommandPalette(); }); + + windowMenu = menuBar()->addMenu("Window"); + windowMenu->addAction("Minimize", this, &QWidget::showMinimized); + windowMenu->addAction("Zoom", this, + [this] { isMaximized() ? showNormal() : showMaximized(); }); + + auto *helpMenu = menuBar()->addMenu("Help"); + auto *aboutAction = helpMenu->addAction("About Atlas Engine", this, [this] { + QMessageBox::about( + this, "About Atlas Engine", + QStringLiteral("Atlas Engine %1\nby Neutral Software") + .arg(QStringLiteral(ATLAS_VERSION))); + }); + aboutAction->setIcon(styling::icon(styling::Icon::Info, "#7E929C")); + aboutAction->setMenuRole(QAction::AboutRole); +} + +void EditorWindow::setupDocks() { + viewportPanel = new ViewportPanel(projectFile); + connect(viewportPanel, &ViewportPanel::sceneDirtyChanged, this, + &EditorWindow::updateWindowTitle); + connect(viewportPanel, &ViewportPanel::runtimeStartupFinished, this, + [this](bool success, const QString &message) { + if (startupComplete) + return; + startupComplete = true; + emit startupStatusChanged(success ? "Project ready" + : "Runtime unavailable"); + emit startupReady(success, message); + }); + viewportTools = new ViewportTools(viewportPanel, projectFile); + materialEditorPanel = new MaterialEditorPanel(viewportPanel); + postProcessingPanel = new PostProcessingPanel(viewportPanel); + workspaceStack = new QStackedWidget(this); + workspaceStack->setObjectName("editorWorkspaceStack"); + workspaceStack->addWidget(viewportTools); + workspaceStack->addWidget(materialEditorPanel); + workspaceStack->addWidget(postProcessingPanel); + workspaceStack->setCurrentIndex(0); + auto *workspaceDock = dockManager->addPanel( + {.id = "workspace", + .title = "Workspace", + .widget = workspaceStack, + .area = EditorDockArea::Center, + .icon = styling::icon(styling::Icon::CubeFocus, "#7E929C")}); + workspaceDock->setFeature(ads::CDockWidget::NoTab, true); + + hierarchyPanel = new HierarchyPanel(viewportPanel); + auto *hierarchyDock = dockManager->addPanel( + {.id = "hierarchy", + .title = "Scene", + .widget = hierarchyPanel, + .area = EditorDockArea::Left, + .icon = styling::icon(styling::Icon::TreeStructure, "#8498A8")}); + + inspectorPanel = new InspectorPanel(viewportPanel, projectFile); + auto *inspectorDock = dockManager->addPanel( + {.id = "inspector", + .title = "Inspector", + .widget = inspectorPanel, + .area = EditorDockArea::Right, + .icon = styling::icon(styling::Icon::SlidersHorizontal, "#A1957D")}); + + contentBrowser = new ContentBrowserPanel(projectFile); + auto *contentDock = dockManager->addPanel( + {.id = "fileExplorer", + .title = "Content Browser", + .widget = contentBrowser, + .area = EditorDockArea::Bottom, + .icon = styling::icon(styling::Icon::FolderOpen, "#7E929C")}); + + workspaceDock->setAsCurrentTab(); + defaultDockState = coreManager->saveState(DockStateVersion); + + const QList managedDocks{ + workspaceDock, hierarchyDock, inspectorDock, contentDock}; + for (ads::CDockWidget *dock : managedDocks) { + connect(dock, &ads::CDockWidget::topLevelChanged, this, + [this](bool) { scheduleLayoutSave(); }); + connect(dock, &ads::CDockWidget::viewToggled, this, + [this](bool) { scheduleLayoutSave(); }); + } + connect(coreManager, &ads::CDockManager::dockAreaCreated, this, + [this](ads::CDockAreaWidget *) { + QTimer::singleShot(0, this, + &EditorWindow::configureDockSplitters); + }); + configureDockSplitters(); + + if (windowMenu != nullptr) { + windowMenu->addSeparator(); + for (ads::CDockWidget *dock : managedDocks) { + dock->toggleViewAction()->setIcon(dock->icon()); + windowMenu->addAction(dock->toggleViewAction()); + } + windowMenu->addSeparator(); + const QList> panels{ + {"Workspace", workspaceDock}, {"Hierarchy", hierarchyDock}, + {"Inspector", inspectorDock}, + {"Content Browser", contentDock}}; + for (int index = 0; index < panels.size(); ++index) { + const auto &[name, dock] = panels.at(index); + auto *action = windowMenu->addAction( + QStringLiteral("Focus %1").arg(name), this, [dock] { + dock->toggleView(true); + dock->setAsCurrentTab(); + dock->raise(); + }); + action->setIcon(dock->icon()); + action->setShortcut(QKeySequence( + QStringLiteral("Ctrl+%1").arg(index + 1))); + action->setShortcutContext(Qt::ApplicationShortcut); + } + windowMenu->addSeparator(); + const QList> workspaceModes{ + {"Scene", 0}, {"Shading", 1}, {"Post-Processing", 2}}; + for (const auto &[name, index] : workspaceModes) { + auto *action = windowMenu->addAction( + QStringLiteral("Open %1 Workspace").arg(name), this, + [this, workspaceDock, index] { + activateWorkspace(index); + workspaceDock->toggleView(true); + workspaceDock->setAsCurrentTab(); + workspaceDock->raise(); + }); + action->setIcon(index == 0 + ? styling::icon(styling::Icon::CubeFocus, + "#7E929C") + : index == 1 + ? styling::icon(styling::Icon::Material, + "#A1957D") + : styling::icon(styling::Icon::FilmStrip, + "#849589")); + } + } + + connect(hierarchyPanel, &HierarchyPanel::objectActivated, inspectorPanel, + &InspectorPanel::inspectRuntimeObject); + connect(hierarchyPanel, &HierarchyPanel::cameraActivated, inspectorPanel, + &InspectorPanel::inspectCamera); + connect(hierarchyPanel, &HierarchyPanel::environmentActivated, + inspectorPanel, &InspectorPanel::inspectEnvironment); + 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](const QString &path) { + const QString suffix = QFileInfo(path).suffix().toLower(); + if (!path.isEmpty() && suffix != "amat" && + suffix != "material") { + viewportPanel->selectRuntimeObject(-1, false); + } + this->inspectorPanel->inspectFile(path); + }); + connect(contentBrowser, &ContentBrowserPanel::assetActivated, this, + [this](const QString &path) { + materialEditorPanel->openMaterial(path); + activateWorkspace(1); + }); + connect(contentBrowser, &ContentBrowserPanel::sceneActivated, this, + [this](const QString &path) { + if (viewportPanel != nullptr && + viewportPanel->openRuntimeScene(path) && + viewportTools != nullptr) { + viewportTools->openSceneTab(path); + } + }); +} + +void EditorWindow::setupWorkspaceBar() { + auto *bar = new QToolBar("Workspace", this); + bar->setObjectName("workspaceBar"); + bar->setMovable(false); + bar->setFloatable(false); + bar->setIconSize(QSize(17, 17)); + bar->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + addToolBar(Qt::TopToolBarArea, bar); + + auto *identity = new QWidget(bar); + identity->setObjectName("workspaceIdentity"); + auto *identityLayout = new QHBoxLayout(identity); + identityLayout->setContentsMargins(10, 0, 14, 0); + identityLayout->setSpacing(8); + auto *mark = new QLabel(identity); + mark->setObjectName("workspaceMark"); + mark->setPixmap(windowIcon().pixmap(22, 22)); + auto *brand = new QLabel("ATLAS", identity); + brand->setObjectName("workspaceBrand"); + auto *project = new QLabel(projectName, identity); + project->setObjectName("workspaceProject"); + identityLayout->addWidget(mark); + identityLayout->addWidget(brand); + identityLayout->addWidget(project); + bar->addWidget(identity); + + workspaceModeGroup = new QButtonGroup(bar); + workspaceModeGroup->setExclusive(true); + auto addMode = [this, bar](const QString &text, styling::Icon icon, + const QColor &color, int index, + bool selected = false) { + auto *button = new QToolButton(bar); + button->setObjectName("workspaceModeButton"); + button->setText(text); + button->setIcon(styling::icon(icon, color)); + button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + button->setCheckable(true); + button->setChecked(selected); + workspaceModeGroup->addButton(button, index); + bar->addWidget(button); + connect(button, &QToolButton::clicked, this, + [this, index] { activateWorkspace(index); }); + }; + addMode("Scene", styling::Icon::CubeFocus, "#7E929C", 0, true); + addMode("Shading", styling::Icon::Material, "#A1957D", 1); + addMode("Post-Processing", styling::Icon::FilmStrip, "#849589", 2); + + auto *spacer = new QWidget(bar); + spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + bar->addWidget(spacer); + + auto *save = new QToolButton(bar); + save->setObjectName("workspaceUtilityButton"); + save->setIcon(styling::icon(styling::Icon::FloppyDisk, "#A1957D")); + save->setToolTip("Save Scene"); + bar->addWidget(save); + connect(save, &QToolButton::clicked, this, [this] { + if (materialEditorPanel != nullptr && materialEditorPanel->isVisible()) + materialEditorPanel->saveMaterial(); + if (viewportPanel != nullptr) + viewportPanel->saveRuntimeScene(); + }); + + auto *build = new QToolButton(bar); + build->setObjectName("workspaceBuildButton"); + build->setIcon(styling::icon(styling::Icon::Package, "#A1957D")); + build->setToolTip("Build Project"); + bar->addWidget(build); + connect(build, &QToolButton::clicked, this, + [this] { runProjectCommand(true); }); + + auto *launch = new QToolButton(bar); + launch->setObjectName("workspaceLaunchButton"); + launch->setIcon(styling::icon(styling::Icon::RocketLaunch, "#849589")); + launch->setToolTip("Run Project"); + bar->addWidget(launch); + connect(launch, &QToolButton::clicked, this, + [this] { runProjectCommand(false); }); + + statusBar()->setObjectName("atlasStatusBar"); + statusBar()->showMessage("Ready"); + auto *runtimeIcon = new QLabel(statusBar()); + runtimeIcon->setObjectName("statusRuntimeIcon"); + runtimeIcon->setPixmap( + styling::icon(styling::Icon::Check, "#849589").pixmap(14, 14)); + auto *renderer = new QLabel(statusBar()); + renderer->setObjectName("statusRenderer"); + const auto projectInfo = ProjectStore::projectInfo(projectFile); + renderer->setText(projectInfo.has_value() ? projectInfo->renderer + : QStringLiteral("ATLAS")); + auto *version = new QLabel(QStringLiteral(ATLAS_VERSION), statusBar()); + version->setObjectName("statusVersion"); + statusBar()->addPermanentWidget(runtimeIcon); + statusBar()->addPermanentWidget(renderer); + statusBar()->addPermanentWidget(version); + connect(viewportPanel, &ViewportPanel::runtimeAvailabilityChanged, this, + [this, runtimeIcon](bool available) { + runtimeIcon->setPixmap(styling::icon( + available ? styling::Icon::Check : styling::Icon::Warning, + available ? QColor("#849589") : QColor("#A1957D")) + .pixmap(14, 14)); + statusBar()->showMessage(available ? "Runtime ready" + : "Runtime unavailable", + 3000); + }); +} + +void EditorWindow::activateWorkspace(int index) { + if (workspaceStack == nullptr || index < 0 || + index >= workspaceStack->count()) + return; + workspaceStack->setCurrentIndex(index); + if (workspaceModeGroup != nullptr) { + if (auto *button = workspaceModeGroup->button(index)) + button->setChecked(true); + } +} + +void EditorWindow::createScene() { + const QString root = QFileInfo(projectFile).absolutePath(); + QString path = QFileDialog::getSaveFileName( + this, "Create Atlas Scene", QDir(root).filePath("New Scene.ascene"), + "Atlas Scene (*.ascene)"); + if (path.isEmpty()) + return; + if (!path.endsWith(".ascene", Qt::CaseInsensitive)) + path += ".ascene"; + const QString name = QFileInfo(path).completeBaseName(); + const QByteArray contents = + QJsonDocument(QJsonObject{ + {"name", name}, + {"id", name.toLower().replace(' ', '_')}, + {"objects", QJsonArray{}}, + {"lights", QJsonArray{}}, + {"camera", QJsonObject{{"position", QJsonArray{0.0, 1.5, -5.0}}, + {"target", QJsonArray{0.0, 0.0, 0.0}}, + {"fov", 60.0}}}, + {"targets", QJsonArray{QJsonObject{{"name", "Main Target"}, + {"type", "scene"}, + {"render", true}, + {"display", true}}}}}) + .toJson(QJsonDocument::Indented); + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || file.write(contents) != contents.size() || + !file.commit()) { + QMessageBox::warning(this, "Create Scene", + "The scene could not be created."); + return; + } + if (viewportPanel != nullptr && viewportPanel->openRuntimeScene(path) && + viewportTools != nullptr) { + viewportTools->refreshSceneTabs(); + viewportTools->openSceneTab(path); + } +} + +void EditorWindow::openScene() { + const QString path = QFileDialog::getOpenFileName( + this, "Open Atlas Scene", QFileInfo(projectFile).absolutePath(), + "Atlas Scene (*.ascene)"); + if (!path.isEmpty() && viewportPanel != nullptr && + viewportPanel->openRuntimeScene(path) && viewportTools != nullptr) { + viewportTools->openSceneTab(path); + } +} + +void EditorWindow::saveSceneAs() { + if (viewportPanel == nullptr) + return; + QString path = QFileDialog::getSaveFileName( + this, "Save Atlas Scene As", viewportPanel->currentRuntimeScene(), + "Atlas Scene (*.ascene)"); + if (path.isEmpty()) + return; + if (!path.endsWith(".ascene", Qt::CaseInsensitive)) + path += ".ascene"; + if (viewportPanel->saveRuntimeSceneAs(path) && viewportTools != nullptr) { + viewportTools->refreshSceneTabs(); + viewportTools->openSceneTab(path); + } +} + +void EditorWindow::showProjectSettings() { + QDialog dialog(this); + dialog.setObjectName("projectSettingsDialog"); + dialog.setWindowTitle("Project Settings"); + dialog.resize(720, 520); + auto *layout = new QVBoxLayout(&dialog); + auto *header = new QFrame(&dialog); + header->setObjectName("dialogHero"); + auto *headerLayout = new QHBoxLayout(header); + auto *headerIcon = new QLabel(header); + headerIcon->setObjectName("dialogHeroIcon"); + headerIcon->setPixmap( + styling::icon(styling::Icon::Gear, "#8498A8").pixmap(28, 28)); + auto *headerCopy = new QVBoxLayout(); + auto *headerTitle = new QLabel("Project Settings", header); + headerTitle->setObjectName("dialogHeroTitle"); + auto *headerSubtitle = new QLabel( + "Configure runtime, rendering, controls, and packaging for this project.", + header); + headerSubtitle->setObjectName("dialogHeroSubtitle"); + headerCopy->addWidget(headerTitle); + headerCopy->addWidget(headerSubtitle); + headerLayout->addWidget(headerIcon); + headerLayout->addLayout(headerCopy, 1); + layout->addWidget(header); + auto *tabs = new QTabWidget(&dialog); + const QString settingsDirectory = + QDir(QFileInfo(projectFile).absolutePath()).filePath(".atlas"); + QDir().mkpath(settingsDirectory); + QSettings settings(QDir(settingsDirectory).filePath("project-settings.ini"), + QSettings::IniFormat); + auto addPage = [tabs](const QString &name, styling::Icon icon, + const QColor &color) { + auto *page = new QWidget(tabs); + auto *form = new QFormLayout(page); + form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + tabs->addTab(page, styling::icon(icon, color), name); + return form; + }; + auto *general = addPage("General", styling::Icon::Gear, "#8498A8"); + auto *defaultScene = new QComboBox(&dialog); + QDirIterator sceneIterator(QFileInfo(projectFile).absolutePath(), + {"*.ascene"}, QDir::Files, + QDirIterator::Subdirectories); + while (sceneIterator.hasNext()) { + const QString scene = sceneIterator.next(); + defaultScene->addItem( + QDir(QFileInfo(projectFile).absolutePath()).relativeFilePath(scene), + scene); + } + const QString configuredScene = + settings.value("project/defaultScene", "main.ascene").toString(); + int defaultSceneIndex = defaultScene->findText(configuredScene); + if (defaultSceneIndex < 0) + defaultSceneIndex = 0; + defaultScene->setCurrentIndex(defaultSceneIndex); + auto *companyName = new QLineEdit( + settings.value("project/company", "Neutral Software").toString(), + &dialog); + auto *gameVersion = new QLineEdit( + settings.value("project/version", "1.0.0").toString(), &dialog); + auto *windowWidth = new QSpinBox(&dialog); + windowWidth->setRange(320, 16384); + windowWidth->setValue(settings.value("project/windowWidth", 1280).toInt()); + auto *windowHeight = new QSpinBox(&dialog); + windowHeight->setRange(240, 16384); + windowHeight->setValue(settings.value("project/windowHeight", 720).toInt()); + auto *fullscreen = new QCheckBox("Start in fullscreen", &dialog); + fullscreen->setChecked( + settings.value("project/fullscreen", false).toBool()); + general->addRow("Default scene", defaultScene); + general->addRow("Company", companyName); + general->addRow("Version", gameVersion); + general->addRow("Window width", windowWidth); + general->addRow("Window height", windowHeight); + general->addRow(QString(), fullscreen); + auto *rendering = + addPage("Rendering", styling::Icon::Aperture, "#9E897D"); + auto *renderer = new QComboBox(&dialog); + renderer->addItems({"PBR", "PBR + DDGI", "Path Tracing"}); + renderer->setCurrentText(settings.value("project/renderer", "PBR").toString()); + auto *frameLimit = new QSpinBox(&dialog); + frameLimit->setRange(0, 1000); + frameLimit->setValue(settings.value("project/frameLimit", 0).toInt()); + rendering->addRow("Renderer", renderer); + rendering->addRow("Frame limit (0 = unlimited)", frameLimit); + auto *physics = addPage("Physics", styling::Icon::Wrench, "#A1957D"); + auto *gravity = new QLineEdit(settings.value("project/gravity", "0, -9.81, 0").toString(), &dialog); + auto *fixedStep = new QLineEdit(settings.value("project/fixedStep", "0.0166667").toString(), &dialog); + physics->addRow("Gravity", gravity); + physics->addRow("Fixed timestep", fixedStep); + auto *input = + addPage("Input", styling::Icon::GameController, "#849589"); + auto *inputMap = new QLineEdit(settings.value("project/inputMap", "input.json").toString(), &dialog); + auto *controller = new QComboBox(&dialog); + controller->addItems({"Automatic", "Keyboard + Mouse", "Gamepad"}); + controller->setCurrentText(settings.value("project/controller", "Automatic").toString()); + input->addRow("Input map", inputMap); + input->addRow("Primary controller", controller); + auto *build = addPage("Build & Run", styling::Icon::Package, "#A1957D"); + auto *buildCommand = new QLineEdit(settings.value("project/buildCommand", "atlas pack --backend METAL").toString(), &dialog); + auto *runCommand = new QLineEdit(settings.value("project/runCommand", "atlas run project.atlas").toString(), &dialog); + build->addRow("Build command", buildCommand); + build->addRow("Run command", runCommand); + auto *editor = addPage("Editor", styling::Icon::Layout, "#7E929C"); + auto *autosave = new QSpinBox(&dialog); + autosave->setRange(0, 120); + autosave->setValue(settings.value("project/autosaveMinutes", 5).toInt()); + auto *snap = new QLineEdit(settings.value("project/snapIncrement", "0.5").toString(), &dialog); + editor->addRow("Autosave interval (minutes)", autosave); + editor->addRow("Transform snapping", snap); + auto *packaging = + addPage("Packaging", styling::Icon::Export, "#8498A8"); + auto *identifier = new QLineEdit( + settings.value("project/bundleIdentifier", + "org.atlasengine." + projectName.toLower().replace(' ', '-')) + .toString(), + &dialog); + auto *iconPath = new QLineEdit( + settings.value("project/icon", "none").toString(), &dialog); + auto *backend = new QComboBox(&dialog); + backend->addItems({"METAL", "VULKAN", "OPENGL"}); + backend->setCurrentText( + settings.value("project/exportBackend", "METAL").toString()); + packaging->addRow("Bundle identifier", identifier); + packaging->addRow("Application icon", iconPath); + packaging->addRow("Renderer backend", backend); + layout->addWidget(tabs); + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Cancel | QDialogButtonBox::Save, &dialog); + buttons->button(QDialogButtonBox::Save) + ->setIcon(styling::icon(styling::Icon::FloppyDisk, "#A1957D")); + layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + if (dialog.exec() != QDialog::Accepted) + return; + settings.setValue("project/defaultScene", defaultScene->currentText()); + settings.setValue("project/company", companyName->text()); + settings.setValue("project/version", gameVersion->text()); + settings.setValue("project/windowWidth", windowWidth->value()); + settings.setValue("project/windowHeight", windowHeight->value()); + settings.setValue("project/fullscreen", fullscreen->isChecked()); + settings.setValue("project/renderer", renderer->currentText()); + settings.setValue("project/frameLimit", frameLimit->value()); + settings.setValue("project/gravity", gravity->text()); + settings.setValue("project/fixedStep", fixedStep->text()); + settings.setValue("project/inputMap", inputMap->text()); + settings.setValue("project/controller", controller->currentText()); + settings.setValue("project/buildCommand", buildCommand->text()); + settings.setValue("project/runCommand", runCommand->text()); + settings.setValue("project/autosaveMinutes", autosave->value()); + settings.setValue("project/snapIncrement", snap->text()); + settings.setValue("project/bundleIdentifier", identifier->text()); + settings.setValue("project/icon", iconPath->text()); + settings.setValue("project/exportBackend", backend->currentText()); + settings.sync(); + QFile manifest(projectFile); + if (manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + QStringList lines = + QString::fromUtf8(manifest.readAll()).split('\n'); + manifest.close(); + setTomlValue(&lines, QString(), "backend", + tomlQuoted(backend->currentText())); + setTomlValue(&lines, "game", "main_scene", + tomlQuoted(defaultScene->currentText())); + setTomlValue(&lines, "pack", "identifier", + tomlQuoted(identifier->text())); + setTomlValue(&lines, "pack", "version", + tomlQuoted(gameVersion->text())); + setTomlValue(&lines, "pack", "icon", + tomlQuoted(iconPath->text())); + setTomlValue( + &lines, "window", "dimensions", + QStringLiteral("[%1, %2]") + .arg(windowWidth->value()) + .arg(windowHeight->value())); + setTomlValue(&lines, "window", "fullscreen", + fullscreen->isChecked() ? "true" : "false"); + const QString rendererName = + renderer->currentText() == "Path Tracing" ? "pathtracing" + : "deferred"; + setTomlValue(&lines, "renderer", "default", + tomlQuoted(rendererName)); + setTomlValue(&lines, "renderer", "global_illumination", + renderer->currentText() == "PBR + DDGI" ? "true" + : "false"); + QSaveFile outputFile(projectFile); + const QByteArray contents = lines.join('\n').toUtf8(); + if (!outputFile.open(QIODevice::WriteOnly) || + outputFile.write(contents) != contents.size() || + !outputFile.commit()) { + QMessageBox::warning(this, "Project Settings", + "The project manifest could not be updated."); + } + } +} + +void EditorWindow::showExportDialog() { + QDialog dialog(this); + dialog.setObjectName("exportDialog"); + dialog.setWindowTitle("Export Atlas Project"); + dialog.resize(660, 460); + auto *layout = new QVBoxLayout(&dialog); + auto *header = new QFrame(&dialog); + header->setObjectName("dialogHero"); + auto *headerLayout = new QHBoxLayout(header); + auto *headerIcon = new QLabel(header); + headerIcon->setObjectName("dialogHeroIcon"); + headerIcon->setPixmap( + styling::icon(styling::Icon::Export, "#849589").pixmap(28, 28)); + auto *headerCopy = new QVBoxLayout(); + auto *headerTitle = new QLabel("Export Project", header); + headerTitle->setObjectName("dialogHeroTitle"); + auto *headerSubtitle = new QLabel( + "Package the current project into a distributable application.", + header); + headerSubtitle->setObjectName("dialogHeroSubtitle"); + headerCopy->addWidget(headerTitle); + headerCopy->addWidget(headerSubtitle); + headerLayout->addWidget(headerIcon); + headerLayout->addLayout(headerCopy, 1); + layout->addWidget(header); + auto *form = new QFormLayout; + auto *platform = new QComboBox(&dialog); +#ifdef Q_OS_MACOS + platform->addItem("macOS"); +#elif defined(Q_OS_WIN) + platform->addItem("Windows"); +#else + platform->addItem("Linux"); +#endif + platform->setEnabled(false); + auto *configuration = new QComboBox(&dialog); + configuration->addItems({"Release", "Debug"}); + const QString settingsDirectory = + QDir(QFileInfo(projectFile).absolutePath()).filePath(".atlas"); + QDir().mkpath(settingsDirectory); + QSettings settings( + QDir(settingsDirectory).filePath("project-settings.ini"), + QSettings::IniFormat); + auto *backend = new QComboBox(&dialog); + backend->addItems({"METAL", "VULKAN", "OPENGL"}); + backend->setCurrentText( + settings.value("project/exportBackend", "METAL").toString()); + auto *output = new QLineEdit( + settings + .value("project/exportDirectory", + QDir(QFileInfo(projectFile).absolutePath()) + .filePath("Exports")) + .toString(), + &dialog); + auto *browse = new QPushButton("Choose…", &dialog); + browse->setIcon( + styling::icon(styling::Icon::FolderOpen, "#7E929C")); + auto *outputRow = new QWidget(&dialog); + auto *outputLayout = new QHBoxLayout(outputRow); + outputLayout->setContentsMargins(0, 0, 0, 0); + outputLayout->addWidget(output, 1); + outputLayout->addWidget(browse); + form->addRow("Platform", platform); + form->addRow("Configuration", configuration); + form->addRow("Backend", backend); + form->addRow("Destination", outputRow); + layout->addLayout(form); + auto *summary = new QLabel( + "Atlas will save the current scene and package the configured runtime with the project resources.", + &dialog); + summary->setObjectName("exportSummary"); + summary->setWordWrap(true); + layout->addWidget(summary); + auto *progress = new QProgressBar(&dialog); + progress->setRange(0, 0); + progress->setVisible(false); + layout->addWidget(progress); + auto *log = new QPlainTextEdit(&dialog); + log->setObjectName("exportLog"); + log->setReadOnly(true); + log->setPlaceholderText("Packaging output will appear here."); + layout->addWidget(log, 1); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, &dialog); + auto *exportButton = buttons->addButton("Export", QDialogButtonBox::AcceptRole); + exportButton->setIcon( + styling::icon(styling::Icon::RocketLaunch, "#849589")); + auto *revealButton = buttons->addButton("Reveal Export", QDialogButtonBox::ActionRole); + revealButton->setIcon( + styling::icon(styling::Icon::FolderOpen, "#7E929C")); + revealButton->setEnabled(false); + layout->addWidget(buttons); + connect(browse, &QPushButton::clicked, &dialog, [&dialog, output] { + const QString directory = QFileDialog::getExistingDirectory( + &dialog, "Export Destination", output->text()); + if (!directory.isEmpty()) + output->setText(directory); + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(revealButton, &QPushButton::clicked, &dialog, [output] { + QDesktopServices::openUrl(QUrl::fromLocalFile(output->text())); + }); + auto *process = new QProcess(&dialog); + connect(process, &QProcess::readyReadStandardOutput, &dialog, + [process, log] { + log->appendPlainText( + QString::fromUtf8(process->readAllStandardOutput()).trimmed()); + }); + connect(process, &QProcess::readyReadStandardError, &dialog, + [process, log] { + log->appendPlainText( + QString::fromUtf8(process->readAllStandardError()).trimmed()); + }); + connect(process, &QProcess::errorOccurred, &dialog, + [process, progress, exportButton, log](QProcess::ProcessError) { + progress->setVisible(false); + exportButton->setEnabled(true); + log->appendPlainText(process->errorString()); + }); + connect(process, &QProcess::finished, &dialog, + [this, process, progress, exportButton, revealButton, output, + log](int exitCode, QProcess::ExitStatus status) { + progress->setVisible(false); + exportButton->setEnabled(true); + if (status != QProcess::NormalExit || exitCode != 0) { + log->appendPlainText("Export failed."); + return; + } + const QString dist = + QDir(QFileInfo(projectFile).absolutePath()).filePath("dist"); + QDir destination(output->text()); + if (!destination.exists() && !QDir().mkpath(destination.path())) { + log->appendPlainText("Could not create the export destination."); + return; + } + const QFileInfoList packages = QDir(dist).entryInfoList( + QDir::NoDotAndDotDot | QDir::AllEntries | QDir::Hidden); + if (packages.isEmpty()) { + log->appendPlainText("Atlas Pack produced no distributable files."); + return; + } + QString error; + for (const QFileInfo &package : packages) { + const QString target = destination.filePath(package.fileName()); + if (QFileInfo(target).isDir()) + QDir(target).removeRecursively(); + else + QFile::remove(target); + if (!copyExportPath(package.absoluteFilePath(), target, + &error)) { + log->appendPlainText(error); + return; + } + } + log->appendPlainText( + QStringLiteral("Export complete: %1").arg(destination.path())); + revealButton->setEnabled(true); + }); + connect(exportButton, &QPushButton::clicked, &dialog, + [this, process, output, platform, configuration, backend, progress, + exportButton, revealButton, log] { + const QString program = atlasCliPath(); + QStringList arguments; + if (program.isEmpty()) { + QMessageBox::warning( + this, "Export Project", + "Atlas CLI was not found. Install it or place it beside Atlas Editor."); + return; + } + QSettings settings( + QDir(QFileInfo(projectFile).absolutePath()) + .filePath(".atlas/project-settings.ini"), + QSettings::IniFormat); + settings.setValue("project/exportDirectory", output->text()); + settings.setValue("project/exportPlatform", platform->currentText()); + settings.setValue("project/exportConfiguration", + configuration->currentText()); + settings.setValue("project/exportBackend", backend->currentText()); + settings.sync(); + if (viewportPanel != nullptr) + viewportPanel->saveRuntimeScene(); + log->clear(); + log->appendPlainText("Starting Atlas Pack…"); + progress->setVisible(true); + exportButton->setEnabled(false); + revealButton->setEnabled(false); + arguments << "pack" << "--backend" << backend->currentText(); + if (configuration->currentText() == "Release") + arguments << "--release" << "1"; + process->setWorkingDirectory(QFileInfo(projectFile).absolutePath()); + process->start(program, arguments); + }); + connect(&dialog, &QDialog::finished, process, [process] { + if (process->state() == QProcess::NotRunning) + return; + process->terminate(); + if (!process->waitForFinished(1000)) { + process->kill(); + process->waitForFinished(1000); + } + }); + dialog.exec(); +} + +void EditorWindow::showCommandPalette() { + QDialog dialog(this); + dialog.setObjectName("commandPaletteDialog"); + dialog.setWindowTitle("Command Palette"); + dialog.setWindowFlags(dialog.windowFlags() | Qt::FramelessWindowHint); + dialog.resize(620, 430); + auto *layout = new QVBoxLayout(&dialog); + auto *search = new QLineEdit(&dialog); + search->setObjectName("commandPaletteSearch"); + search->setPlaceholderText("Type a command…"); + auto *commands = new QListWidget(&dialog); + commands->setObjectName("commandPaletteList"); + layout->addWidget(search); + layout->addWidget(commands, 1); + const QList actions = findChildren(); + for (QAction *action : actions) { + if (action->text().isEmpty() || action->isSeparator() || + !action->isEnabled()) { + continue; + } + auto *item = new QListWidgetItem(commands); + item->setText(action->text().remove('&')); + item->setIcon(action->icon()); + item->setData(Qt::UserRole, + QVariant::fromValue( + reinterpret_cast(action))); + const QString shortcut = + !action->shortcut().isEmpty() + ? action->shortcut().toString(QKeySequence::NativeText) + : action->property("atlasShortcut").toString(); + if (!shortcut.isEmpty()) + item->setText(item->text() + "\t" + + shortcut); + } + if (commands->count() > 0) + commands->setCurrentRow(0); + auto *noCommands = new QListWidgetItem("No matching commands", commands); + noCommands->setData(Qt::UserRole, QVariant::fromValue(0)); + noCommands->setData(Qt::UserRole + 1, true); + noCommands->setHidden(true); + connect(search, &QLineEdit::textChanged, &dialog, + [commands, noCommands](const QString &text) { + int firstMatch = -1; + for (int index = 0; index < commands->count(); ++index) { + QListWidgetItem *item = commands->item(index); + if (item == noCommands) + continue; + if (firstMatch < 0 && item->text().contains( + text, Qt::CaseInsensitive)) + firstMatch = index; + } + if (firstMatch >= 0) { + commands->setCurrentRow(firstMatch); + noCommands->setHidden(true); + } else { + noCommands->setHidden(false); + commands->setCurrentItem(noCommands); + } + for (int index = 0; index < commands->count(); ++index) { + QListWidgetItem *item = commands->item(index); + if (item != noCommands) + item->setHidden(!item->text().contains( + text, Qt::CaseInsensitive)); + } + }); + connect(commands, &QListWidget::itemActivated, &dialog, + [&dialog](QListWidgetItem *item) { + if (item->data(Qt::UserRole + 1).toBool()) + return; + auto *action = reinterpret_cast( + item->data(Qt::UserRole).value()); + dialog.accept(); + if (action != nullptr) + action->trigger(); + }); + connect(search, &QLineEdit::returnPressed, &dialog, [commands] { + if (commands->currentItem() != nullptr) + emit commands->itemActivated(commands->currentItem()); + }); + auto moveSelection = [commands](int direction) { + if (commands->count() == 0) + return; + int row = commands->currentRow(); + for (int attempt = 0; attempt < commands->count(); ++attempt) { + row = (row + direction + commands->count()) % commands->count(); + if (!commands->item(row)->isHidden()) { + commands->setCurrentRow(row); + return; + } + } + }; + auto *down = new QShortcut(QKeySequence(Qt::Key_Down), &dialog); + auto *up = new QShortcut(QKeySequence(Qt::Key_Up), &dialog); + connect(down, &QShortcut::activated, &dialog, + [moveSelection] { moveSelection(1); }); + connect(up, &QShortcut::activated, &dialog, + [moveSelection] { moveSelection(-1); }); + search->setFocus(); + dialog.exec(); +} + +void EditorWindow::showGlobalSearch() { + QDialog dialog(this); + dialog.setObjectName("globalSearchDialog"); + dialog.setWindowTitle("Search Atlas Project"); + dialog.setWindowFlags(dialog.windowFlags() | Qt::FramelessWindowHint); + dialog.resize(680, 460); + auto *layout = new QVBoxLayout(&dialog); + auto *search = new QLineEdit(&dialog); + search->setObjectName("globalSearchField"); + search->setPlaceholderText("Search scenes, assets, and commands…"); + auto *results = new QListWidget(&dialog); + results->setObjectName("globalSearchResults"); + layout->addWidget(search); + layout->addWidget(results, 1); + constexpr int SearchKindRole = Qt::UserRole + 1; + constexpr int SearchValueRole = Qt::UserRole + 2; + QDirIterator iterator(QFileInfo(projectFile).absolutePath(), QDir::Files, + QDirIterator::Subdirectories); + while (iterator.hasNext()) { + const QString path = iterator.next(); + const QString relative = + QDir(QFileInfo(projectFile).absolutePath()).relativeFilePath(path); + if (relative.startsWith(".git/") || relative.startsWith("build/") || + relative.startsWith("dist/")) + continue; + auto *item = new QListWidgetItem( + QStringLiteral("Asset %1").arg(relative), results); + item->setIcon(styling::icon(styling::Icon::File, "#7E929C")); + item->setToolTip(path); + item->setData(SearchKindRole, 0); + item->setData(SearchValueRole, path); + } + const QJsonDocument snapshot = viewportPanel != nullptr + ? QJsonDocument::fromJson( + viewportPanel->currentSceneSnapshot() + .toUtf8()) + : QJsonDocument(); + std::function addObjects; + addObjects = [&addObjects, results](const QJsonArray &objects) { + for (const QJsonValue &value : objects) { + const QJsonObject object = value.toObject(); + const QString name = object.value("name").toString(); + const int id = object.value("id").toInt(-1); + if (!name.isEmpty() && id >= 0) { + auto *item = new QListWidgetItem( + QStringLiteral("Object %1").arg(name), results); + item->setIcon( + styling::icon(styling::Icon::Cube, "#8498A8")); + item->setToolTip(object.value("type").toString()); + item->setData(SearchKindRole, 1); + item->setData(SearchValueRole, id); + } + addObjects(object.value("children").toArray()); + } + }; + addObjects(snapshot.object().value("objects").toArray()); + const QList actions = findChildren(); + for (QAction *action : actions) { + if (action->text().isEmpty() || action->isSeparator() || + !action->isEnabled()) + continue; + auto *item = new QListWidgetItem( + QStringLiteral("Command %1").arg(action->text().remove('&')), + results); + item->setIcon(action->icon()); + item->setData(SearchKindRole, 2); + item->setData( + SearchValueRole, + QVariant::fromValue(reinterpret_cast(action))); + } + if (results->count() > 0) + results->setCurrentRow(0); + auto *noResults = new QListWidgetItem("No matching results", results); + noResults->setData(SearchKindRole, 3); + noResults->setHidden(true); + connect(search, &QLineEdit::textChanged, &dialog, + [results, noResults](const QString &text) { + int firstMatch = -1; + for (int index = 0; index < results->count(); ++index) { + QListWidgetItem *item = results->item(index); + if (item == noResults) + continue; + const bool matches = + item->text().contains(text, Qt::CaseInsensitive) || + item->toolTip().contains(text, Qt::CaseInsensitive); + if (firstMatch < 0 && matches) + firstMatch = index; + } + if (firstMatch >= 0) { + results->setCurrentRow(firstMatch); + noResults->setHidden(true); + } else { + noResults->setHidden(false); + results->setCurrentItem(noResults); + } + for (int index = 0; index < results->count(); ++index) { + QListWidgetItem *item = results->item(index); + if (item != noResults) + item->setHidden( + !item->text().contains(text, Qt::CaseInsensitive) && + !item->toolTip().contains(text, + Qt::CaseInsensitive)); + } + }); + connect(results, &QListWidget::itemActivated, &dialog, + [this, &dialog](QListWidgetItem *item) { + const int kind = item->data(Qt::UserRole + 1).toInt(); + if (kind == 3) + return; + dialog.accept(); + if (kind == 1 && viewportPanel != nullptr) { + const int id = item->data(Qt::UserRole + 2).toInt(); + viewportPanel->selectRuntimeObject(id, false); + viewportPanel->focusRuntimeObjects({id}); + return; + } + if (kind == 2) { + auto *action = reinterpret_cast( + item->data(Qt::UserRole + 2).value()); + if (action != nullptr) + action->trigger(); + return; + } + const QString path = + item->data(Qt::UserRole + 2).toString(); + if (path.endsWith(".ascene", Qt::CaseInsensitive) && + viewportPanel != nullptr) { + if (viewportPanel->openRuntimeScene(path) && + viewportTools != nullptr) + viewportTools->openSceneTab(path); + } else { + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); + } + }); + connect(search, &QLineEdit::returnPressed, &dialog, [results] { + if (results->currentItem() != nullptr) + emit results->itemActivated(results->currentItem()); + }); + auto moveSelection = [results](int direction) { + if (results->count() == 0) + return; + int row = results->currentRow(); + for (int attempt = 0; attempt < results->count(); ++attempt) { + row = (row + direction + results->count()) % results->count(); + if (!results->item(row)->isHidden()) { + results->setCurrentRow(row); + return; + } + } + }; + auto *down = new QShortcut(QKeySequence(Qt::Key_Down), &dialog); + auto *up = new QShortcut(QKeySequence(Qt::Key_Up), &dialog); + connect(down, &QShortcut::activated, &dialog, + [moveSelection] { moveSelection(1); }); + connect(up, &QShortcut::activated, &dialog, + [moveSelection] { moveSelection(-1); }); + search->setFocus(); + dialog.exec(); +} + +void EditorWindow::runProjectCommand(bool buildOnly) { + const QString settingsDirectory = + QDir(QFileInfo(projectFile).absolutePath()).filePath(".atlas"); + QDir().mkpath(settingsDirectory); + QSettings settings(QDir(settingsDirectory).filePath("project-settings.ini"), + QSettings::IniFormat); + const QString command = + settings.value(buildOnly ? "project/buildCommand" + : "project/runCommand", + buildOnly ? "atlas pack --backend METAL" + : "atlas run project.atlas") + .toString() + .trimmed(); + if (command.isEmpty()) + return; + if (viewportPanel != nullptr) + viewportPanel->saveRuntimeScene(); + QProcess::startDetached("/bin/zsh", {"-lc", command}, + QFileInfo(projectFile).absolutePath()); +} + +void EditorWindow::takeViewportScreenshot() { + if (viewportPanel == nullptr) + return; + const QString path = QFileDialog::getSaveFileName( + this, "Save Viewport Screenshot", + QDir(QFileInfo(projectFile).absolutePath()) + .filePath("Atlas Viewport " + + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss") + + ".png"), + "PNG Image (*.png)"); + if (!path.isEmpty()) + viewportPanel->grab().save(path, "PNG"); +} + +void EditorWindow::refreshScriptWatcher() { + if (scriptWatcher == nullptr) + return; + const QStringList existing = scriptWatcher->files(); + if (!existing.isEmpty()) + scriptWatcher->removePaths(existing); + QStringList scripts; + QDirIterator iterator(QFileInfo(projectFile).absolutePath(), + {"*.js", "*.ts"}, QDir::Files, + QDirIterator::Subdirectories); + while (iterator.hasNext()) + scripts.append(iterator.next()); + if (!scripts.isEmpty()) + scriptWatcher->addPaths(scripts); +} + +bool EditorWindow::contentBrowserHasFocus() const { + QWidget *focused = QApplication::focusWidget(); + return contentBrowser != nullptr && focused != nullptr && + (focused == contentBrowser || contentBrowser->isAncestorOf(focused)); +} + +bool EditorWindow::eventFilter(QObject *watched, QEvent *event) { + if (event->type() == QEvent::KeyPress) { + auto *key = static_cast(event); + QWidget *focused = QApplication::focusWidget(); + const bool typing = qobject_cast(focused) != nullptr; + if (!typing && key->key() == Qt::Key_Tab && + key->modifiers() == Qt::NoModifier && !key->isAutoRepeat() && + hierarchyPanel != nullptr) { + hierarchyPanel->focusSelectedObject(); + return true; + } + if (!typing && key->modifiers() == Qt::ShiftModifier && + !key->isAutoRepeat()) { + if (key->key() == Qt::Key_N && hierarchyPanel != nullptr) + hierarchyPanel->createObject("group", "Empty Object"); + else if (key->key() == Qt::Key_C && hierarchyPanel != nullptr) + hierarchyPanel->createObject("camera", "Camera"); + else if (key->key() == Qt::Key_L && hierarchyPanel != nullptr) + hierarchyPanel->createObject("pointLight", "Point Light"); + else if (key->key() == Qt::Key_A && hierarchyPanel != nullptr) + hierarchyPanel->showCreationPopup(); + else if (key->key() == Qt::Key_T && viewportPanel != nullptr) + viewportPanel->toggleTransformSpace(); + else if (key->key() == Qt::Key_R && viewportPanel != nullptr) { + const int child = viewportPanel->selectedRuntimeObjectId(); + bool accepted = false; + const int parent = QInputDialog::getInt( + this, "Reparent Object", + "Parent object runtime ID (-1 for root)", -1, -1, + std::numeric_limits::max(), 1, &accepted); + if (accepted && child >= 0) + viewportPanel->setRuntimeObjectParent(child, parent); + } else { + return QMainWindow::eventFilter(watched, event); + } + return true; + } + } + return QMainWindow::eventFilter(watched, event); +} + +void EditorWindow::saveLayout() { + if (coreManager == nullptr) + return; + QSettings settings("Neutral Software", "Atlas Engine"); + + settings.setValue("window/geometry", saveGeometry()); + settings.setValue(DockStateKey, + coreManager->saveState(DockStateVersion)); + settings.sync(); +} + +void EditorWindow::restoreLayout() { + QSettings settings("Neutral Software", "Atlas Engine"); + + const QByteArray geometry = settings.value("window/geometry").toByteArray(); + if (!geometry.isEmpty()) + restoreGeometry(geometry); + const QByteArray dockState = settings.value(DockStateKey).toByteArray(); + + restoringLayout = true; + const bool restored = !dockState.isEmpty() && + coreManager->restoreState(dockState, + DockStateVersion); + if (!restored && !defaultDockState.isEmpty()) + coreManager->restoreState(defaultDockState, DockStateVersion); + restoringLayout = false; + configureDockSplitters(); +} + +void EditorWindow::configureDockSplitters() { + if (coreManager == nullptr) + return; + for (QSplitter *splitter : coreManager->findChildren()) { + splitter->setHandleWidth(4); + splitter->setOpaqueResize(true); + splitter->setChildrenCollapsible(false); + if (!splitter->property("atlasLayoutTracking").toBool()) { + splitter->setProperty("atlasLayoutTracking", true); + connect(splitter, &QSplitter::splitterMoved, this, + [this](int, int) { scheduleLayoutSave(); }); + } + } +} + +void EditorWindow::scheduleLayoutSave() { + if (!restoringLayout && !closing && layoutSaveTimer != nullptr) + layoutSaveTimer->start(); +} + +void EditorWindow::updateWindowTitle(bool dirty) { + const QString name = projectName + (dirty ? "*" : ""); +#ifdef ATLAS_DEBUG_BUILD + const QString build = QStringLiteral(ATLAS_BUILD_STRING); + setWindowTitle(build.isEmpty() + ? QStringLiteral("%1 - Atlas Engine (Development)") + .arg(name) + : QStringLiteral("%1 - Atlas Engine (Development) + %2") + .arg(name, build)); +#else + setWindowTitle(QStringLiteral("%1 - Atlas Engine %2") + .arg(name, QStringLiteral(ATLAS_VERSION))); +#endif +} + +void EditorWindow::showEvent(QShowEvent *event) { + QMainWindow::showEvent(event); + if (startupQueued || startupComplete) + return; + startupQueued = true; + emit startupStatusChanged("Restoring editor workspace..."); + QTimer::singleShot(0, this, [this] { + configureDockSplitters(); + emit startupStatusChanged("Loading the project runtime..."); + if (viewportPanel != nullptr) { + viewportPanel->setRuntimeStartupEnabled(true); + } else { + startupComplete = true; + emit startupReady(false, "Viewport is unavailable"); + } + }); +} + +void EditorWindow::closeEvent(QCloseEvent *event) { + if (closing) { + event->accept(); + return; + } + closing = true; + if (layoutSaveTimer != nullptr) + layoutSaveTimer->stop(); + saveLayout(); + for (auto *viewport : findChildren()) { + viewport->shutdownRuntime(); + } + delete dockManager; + dockManager = nullptr; + if (coreManager != nullptr) { + coreManager->deleteLater(); + coreManager = nullptr; + } + QMainWindow::closeEvent(event); + event->accept(); +} diff --git a/editor/views/editor/hierarchy.cpp b/editor/views/editor/hierarchy.cpp new file mode 100644 index 00000000..27c08682 --- /dev/null +++ b/editor/views/editor/hierarchy.cpp @@ -0,0 +1,567 @@ +/* + * hierarchy.cpp + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Hierarchy panel definition + * 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 "editor/views/viewport.h" + +namespace { +constexpr int ObjectIdRole = Qt::UserRole + 1; +constexpr int ObjectTypeRole = Qt::UserRole + 2; + +QIcon hierarchyIcon(QWidget *, const QString &type) { + const QString normalized = type.toLower(); + if (normalized == "scene") + return styling::icon(styling::Icon::CubeFocus, "#7E929C"); + if (normalized == "compound" || normalized == "group") + return styling::icon(styling::Icon::Folder, "#8490A4"); + if (normalized == "camera") + return styling::icon(styling::Icon::Camera, "#9E897D"); + if (normalized == "environment") + return styling::icon(styling::Icon::Globe, "#7E929C"); + if (normalized.contains("light") || normalized == "sun") + return styling::icon(styling::Icon::Lightbulb, "#A1957D"); + if (normalized == "terrain" || normalized == "landscape") + return styling::icon(styling::Icon::Mountains, "#849589"); + if (normalized == "particleemitter" || normalized == "particles") + return styling::icon(styling::Icon::Sparkle, "#8498A8"); + if (normalized == "model") + return styling::icon(styling::Icon::Cube, "#7E929C"); + if (normalized == "sphere") + return styling::icon(styling::Icon::Sphere, "#8498A8"); + return styling::icon(styling::Icon::Cube, "#8498A8"); +} + +QString objectSignature(const QJsonArray &objects) { + QString signature; + for (const QJsonValue &value : objects) { + const QJsonObject object = value.toObject(); + signature += QStringLiteral("%1:%2:%3[") + .arg(object.value("id").toInt()) + .arg(object.value("name").toString(), + object.value("type").toString()); + signature += objectSignature(object.value("children").toArray()); + signature += ']'; + } + return signature; +} +} // namespace + +HierarchyPanel::HierarchyPanel(ViewportPanel *viewport, QWidget *parent) + : QWidget(parent), viewport(viewport) { + setObjectName("hierarchyPanel"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + layout->setSpacing(4); + + auto *toolbar = new QWidget(this); + toolbar->setObjectName("panelToolbar"); + auto *toolbarLayout = new QHBoxLayout(toolbar); + toolbarLayout->setContentsMargins(0, 0, 0, 0); + toolbarLayout->setSpacing(4); + + addButton = new QToolButton(toolbar); + addButton->setObjectName("panelAddButton"); + addButton->setIcon(styling::icon(styling::Icon::Plus, "#8498A8")); + addButton->setText("Add"); + addButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + addButton->setPopupMode(QToolButton::InstantPopup); + addButton->setToolTip("Add Object"); + + moreButton = new QToolButton(toolbar); + moreButton->setObjectName("panelMoreButton"); + moreButton->setIcon( + styling::icon(styling::Icon::DotsVertical, "#8490A4")); + moreButton->setPopupMode(QToolButton::InstantPopup); + moreButton->setToolTip("Hierarchy actions"); + + toolbarLayout->addWidget(addButton); + searchField = new QLineEdit(toolbar); + searchField->setPlaceholderText("Search hierarchy"); + searchField->setClearButtonEnabled(true); + searchField->setMaximumWidth(180); + toolbarLayout->addWidget(searchField, 1); + toolbarLayout->addWidget(moreButton); + layout->addWidget(toolbar); + + treeView = new QTreeView(this); + treeView->setObjectName("hierarchyTree"); + model = new QStandardItemModel(this); + treeView->setModel(model); + treeView->setHeaderHidden(true); + treeView->setAnimated(true); + treeView->setEditTriggers(QAbstractItemView::NoEditTriggers); + treeView->setSelectionMode(QAbstractItemView::ExtendedSelection); + treeView->setContextMenuPolicy(Qt::CustomContextMenu); + treeView->setUniformRowHeights(true); + treeView->setAcceptDrops(true); + treeView->setDragEnabled(true); + treeView->setDragDropMode(QAbstractItemView::DragDrop); + treeView->setDefaultDropAction(Qt::MoveAction); + treeView->setDropIndicatorShown(true); + treeView->viewport()->setAcceptDrops(true); + treeView->viewport()->installEventFilter(this); + layout->addWidget(treeView); + + auto *addMenu = new QMenu(addButton); + auto *objectsMenu = addMenu->addMenu("3D Object"); + const QList> objects = { + {"Cube", "cube"}, {"Sphere", "sphere"}, {"Plane", "plane"}, + {"Pyramid", "pyramid"}, {"Capsule", "capsule"}, {"Terrain", "terrain"}}; + for (const auto &[label, type] : objects) { + objectsMenu->addAction( + hierarchyIcon(this, type), label, this, + [this, type, label] { createObject(type, label); }); + } + + auto *lightsMenu = addMenu->addMenu("Light"); + const QList> lights = { + {"Point Light", "pointLight"}, + {"Spot Light", "spotLight"}, + {"Directional Light", "directionalLight"}, + {"Area Light", "areaLight"}, + {"Ambient Light", "ambientLight"}}; + for (const auto &[label, type] : lights) { + lightsMenu->addAction( + hierarchyIcon(this, type), label, this, + [this, type, label] { createObject(type, label); }); + } + addMenu->addSeparator(); + addMenu->addAction(hierarchyIcon(this, "group"), "Empty Object", this, + [this] { createObject("group", "Empty Object"); }); + addMenu->addAction(hierarchyIcon(this, "camera"), "Camera", this, + [this] { createObject("camera", "Camera"); }); + addMenu->addAction( + hierarchyIcon(this, "particleEmitter"), "Particle Emitter", this, + [this] { createObject("particleEmitter", "Particle Emitter"); }); + addButton->setMenu(addMenu); + + auto *moreMenu = new QMenu(moreButton); + moreMenu->addAction("Focus Selection", this, + &HierarchyPanel::focusSelectedObject); + moreMenu->addAction("Rename", this, &HierarchyPanel::renameSelectedObject); + moreMenu->addAction("Move to Scene Root", this, + &HierarchyPanel::moveSelectedObjectToRoot); + moreMenu->addSeparator(); + moreMenu->addAction("Save Scene", this, [this] { + if (this->viewport != nullptr) { + this->viewport->saveRuntimeScene(); + } + }); + moreButton->setMenu(moreMenu); + + connect(treeView, &QTreeView::clicked, this, + [this](const QModelIndex &) { focusSelectedObject(); }); + connect(treeView, &QTreeView::doubleClicked, this, + [this](const QModelIndex &) { renameSelectedObject(); }); + connect(treeView, &QTreeView::customContextMenuRequested, this, + &HierarchyPanel::showContextMenu); + connect(searchField, &QLineEdit::textChanged, this, + [this](const QString &query) { + const QString normalized = query.trimmed(); + for (auto item = itemsById.begin(); item != itemsById.end(); + ++item) { + QStandardItem *entry = item.value(); + const QModelIndex parentIndex = + entry->parent() != nullptr ? entry->parent()->index() + : QModelIndex(); + treeView->setRowHidden( + entry->row(), parentIndex, + !normalized.isEmpty() && + !entry->text().contains(normalized, + Qt::CaseInsensitive)); + } + }); + + auto *deleteAction = new QAction(this); + deleteAction->setShortcuts( + {QKeySequence::Delete, + QKeySequence(Qt::META | Qt::Key_Backspace)}); + deleteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(deleteAction, &QAction::triggered, this, + &HierarchyPanel::deleteSelectedObject); + addAction(deleteAction); + + auto *renameAction = new QAction(this); + renameAction->setShortcuts({QKeySequence(Qt::Key_Return), + QKeySequence(Qt::Key_Enter), + QKeySequence(Qt::Key_F2)}); + renameAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(renameAction, &QAction::triggered, this, + &HierarchyPanel::renameSelectedObject); + addAction(renameAction); + + auto *focusAction = new QAction(this); + focusAction->setShortcut(Qt::Key_F); + focusAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(focusAction, &QAction::triggered, this, + &HierarchyPanel::focusSelectedObject); + addAction(focusAction); + + auto *createEmptyAction = new QAction(this); + createEmptyAction->setShortcut( + QKeySequence(Qt::META | Qt::SHIFT | Qt::Key_N)); + createEmptyAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(createEmptyAction, &QAction::triggered, this, + [this] { createObject("group", "Empty Object"); }); + addAction(createEmptyAction); + + if (viewport != nullptr) { + addButton->setEnabled(false); + moreButton->setEnabled(false); + connect(viewport, &ViewportPanel::sceneSnapshotChanged, this, + &HierarchyPanel::applySceneSnapshot); + connect(viewport, &ViewportPanel::runtimeAvailabilityChanged, this, + [this](bool available) { + addButton->setEnabled(available); + moreButton->setEnabled(available); + treeView->setEnabled(available); + }); + } +} + +void HierarchyPanel::applySceneSnapshot(const QString &snapshot) { + QJsonParseError error; + const QJsonDocument document = + QJsonDocument::fromJson(snapshot.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return; + } + + const QJsonObject scene = document.object(); + const QString sceneName = scene.value("name").toString("Scene"); + const QJsonArray objects = scene.value("objects").toArray(); + const int selectedId = scene.value("selectedId").toInt(-1); + const QString signature = sceneSignature(sceneName, objects); + + if (signature != lastStructureSignature) { + rebuildScene(sceneName, objects, selectedId); + lastStructureSignature = signature; + return; + } + + if (treeView->selectionModel()->selectedRows().size() > 1) + return; + + applyingSnapshot = true; + const QSignalBlocker blocker(treeView->selectionModel()); + treeView->clearSelection(); + treeView->setCurrentIndex(QModelIndex()); + if (itemsById.contains(selectedId)) { + const QModelIndex index = itemsById.value(selectedId)->index(); + treeView->setCurrentIndex(index); + treeView->scrollTo(index, QAbstractItemView::EnsureVisible); + selectedSpecialType.clear(); + } else if (specialItems.contains(selectedSpecialType)) { + const QModelIndex index = specialItems.value(selectedSpecialType)->index(); + treeView->setCurrentIndex(index); + treeView->scrollTo(index, QAbstractItemView::EnsureVisible); + } else { + treeView->setCurrentIndex(QModelIndex()); + } + applyingSnapshot = false; +} + +void HierarchyPanel::rebuildScene(const QString &sceneName, + const QJsonArray &objects, int selectedId) { + applyingSnapshot = true; + model->clear(); + itemsById.clear(); + specialItems.clear(); + + auto *root = new QStandardItem(hierarchyIcon(this, "scene"), sceneName); + root->setData(-1, ObjectIdRole); + root->setData("scene", ObjectTypeRole); + root->setEditable(false); + appendObjects(root, objects); + + auto *mainCamera = + new QStandardItem(hierarchyIcon(this, "camera"), "Main Camera"); + mainCamera->setData(-1, ObjectIdRole); + mainCamera->setData("camera", ObjectTypeRole); + mainCamera->setToolTip("Scene camera"); + mainCamera->setEditable(false); + specialItems.insert("camera", mainCamera); + root->appendRow(mainCamera); + + auto *environment = new QStandardItem( + hierarchyIcon(this, "environment"), "Environment"); + environment->setData(-1, ObjectIdRole); + environment->setData("environment", ObjectTypeRole); + environment->setToolTip("Scene atmosphere and environment"); + environment->setEditable(false); + specialItems.insert("environment", environment); + root->appendRow(environment); + model->appendRow(root); + treeView->expandAll(); + + if (itemsById.contains(selectedId)) { + const QModelIndex index = itemsById.value(selectedId)->index(); + treeView->setCurrentIndex(index); + treeView->scrollTo(index, QAbstractItemView::EnsureVisible); + selectedSpecialType.clear(); + } else if (specialItems.contains(selectedSpecialType)) { + treeView->setCurrentIndex( + specialItems.value(selectedSpecialType)->index()); + } + applyingSnapshot = false; +} + +bool HierarchyPanel::eventFilter(QObject *watched, QEvent *event) { + if (treeView != nullptr && watched == treeView->viewport() && + (event->type() == QEvent::DragEnter || + event->type() == QEvent::DragMove || event->type() == QEvent::Drop)) { + auto *drop = static_cast(event); + const QModelIndex index = treeView->indexAt(drop->position().toPoint()); + const int objectId = index.data(ObjectIdRole).toInt(); + if (drop->mimeData()->hasUrls() && objectId >= 0) { + const QString suffix = + QFileInfo(drop->mimeData()->urls().constFirst().toLocalFile()) + .suffix() + .toLower(); + const bool supported = + suffix == "amat" || suffix == "material" || suffix == "ts" || + suffix == "js" || suffix == "wav" || suffix == "mp3" || + suffix == "ogg" || suffix == "flac" || suffix == "m4a" || + suffix == "aac"; + if (supported && event->type() == QEvent::Drop && + viewport != nullptr && + viewport->attachRuntimeAsset( + objectId, + drop->mimeData()->urls().constFirst().toLocalFile())) { + treeView->setCurrentIndex(index); + viewport->selectRuntimeObject(objectId, false); + emit objectActivated(objectId); + drop->acceptProposedAction(); + return true; + } + if (supported && event->type() != QEvent::Drop) { + drop->acceptProposedAction(); + return true; + } + } + if (drop->mimeData()->hasFormat( + "application/x-qstandarditemmodeldatalist")) { + const int childId = selectedObjectId(); + const int parentId = index.isValid() ? objectId : -1; + const bool valid = childId >= 0 && childId != parentId; + if (valid && event->type() == QEvent::Drop && viewport != nullptr) { + if (viewport->setRuntimeObjectParent(childId, parentId)) { + drop->setDropAction(Qt::MoveAction); + drop->accept(); + return true; + } + } else if (valid && event->type() != QEvent::Drop) { + drop->setDropAction(Qt::MoveAction); + drop->accept(); + return true; + } + } + drop->ignore(); + return true; + } + return QWidget::eventFilter(watched, event); +} + +void HierarchyPanel::appendObjects(QStandardItem *parent, + const QJsonArray &objects) { + for (const QJsonValue &value : objects) { + const QJsonObject object = value.toObject(); + const int id = object.value("id").toInt(-1); + const QString name = object.value("name").toString("Object"); + const QString type = object.value("type").toString("gameObject"); + auto *item = new QStandardItem(hierarchyIcon(this, type), name); + item->setData(id, ObjectIdRole); + item->setData(type, ObjectTypeRole); + item->setToolTip(type); + item->setEditable(false); + itemsById.insert(id, item); + parent->appendRow(item); + appendObjects(item, object.value("children").toArray()); + } +} + +void HierarchyPanel::showAddObjectMenu(const QPoint &position) { + if (addButton->menu() != nullptr) { + addButton->menu()->popup(position); + } +} + +void HierarchyPanel::showContextMenu(const QPoint &position) { + const QModelIndex index = treeView->indexAt(position); + if (index.isValid()) { + treeView->setCurrentIndex(index); + } + + QMenu menu(this); + auto *addMenu = menu.addMenu("Add Object"); + for (QAction *action : addButton->menu()->actions()) { + addMenu->addAction(action); + } + if (index.isValid() && selectedObjectId() >= 0) { + menu.addSeparator(); + menu.addAction(styling::icon(styling::Icon::Crosshair, "#7E929C"), + "Focus", this, &HierarchyPanel::focusSelectedObject); + menu.addAction(styling::icon(styling::Icon::File, "#8498A8"), + "Rename", this, &HierarchyPanel::renameSelectedObject); + menu.addAction(styling::icon(styling::Icon::TreeStructure, "#849589"), + "Move to Scene Root", this, + &HierarchyPanel::moveSelectedObjectToRoot); + menu.addSeparator(); + menu.addAction(styling::icon(styling::Icon::Trash, "#A17F7F"), + "Delete", this, &HierarchyPanel::deleteSelectedObject); + } + menu.exec(treeView->viewport()->mapToGlobal(position)); +} + +void HierarchyPanel::createObject(const QString &type, + const QString &displayName) { + if (viewport == nullptr) { + return; + } + const int parentId = selectedObjectId(); + const int id = viewport->createRuntimeObject(type, displayName); + if (id >= 0 && parentId >= 0) { + viewport->setRuntimeObjectParent(id, parentId); + } +} + +void HierarchyPanel::renameSelectedObject() { + const int id = selectedObjectId(); + if (id < 0 || viewport == nullptr) { + return; + } + QStandardItem *item = itemsById.value(id, nullptr); + if (item == nullptr) { + return; + } + bool accepted = false; + const QString name = + QInputDialog::getText(this, "Rename Object", "Name", QLineEdit::Normal, + item->text(), &accepted); + if (accepted && !name.trimmed().isEmpty()) { + viewport->renameRuntimeObject(id, name.trimmed()); + } +} + +void HierarchyPanel::deleteSelectedObject() { + if (viewport == nullptr) { + return; + } + const QList ids = selectedObjectIds(); + for (int id : ids) + viewport->deleteRuntimeObject(id); +} + +void HierarchyPanel::focusSelectedObject() { + if (applyingSnapshot || viewport == nullptr) { + return; + } + const int id = selectedObjectId(); + if (id >= 0) { + selectedSpecialType.clear(); + const QList ids = selectedObjectIds(); + if (ids.size() > 1) + viewport->focusRuntimeObjects(ids); + else + viewport->selectRuntimeObject(id, true); + emit objectActivated(id); + return; + } + const QString type = + treeView->currentIndex().data(ObjectTypeRole).toString(); + if (type == "camera") { + selectedSpecialType = type; + viewport->selectRuntimeObject(-1, false); + emit cameraActivated(); + } else if (type == "environment") { + selectedSpecialType = type; + viewport->selectRuntimeObject(-1, false); + emit environmentActivated(); + } +} + +void HierarchyPanel::moveSelectedObjectToRoot() { + const int id = selectedObjectId(); + if (id >= 0 && viewport != nullptr) { + viewport->setRuntimeObjectParent(id, -1); + } +} + +int HierarchyPanel::selectedObjectId() const { + if (treeView == nullptr || !treeView->currentIndex().isValid()) { + return -1; + } + return treeView->currentIndex().data(ObjectIdRole).toInt(); +} + +QList HierarchyPanel::selectedObjectIds() const { + QList ids; + if (treeView == nullptr || treeView->selectionModel() == nullptr) + return ids; + for (const QModelIndex &index : treeView->selectionModel()->selectedRows()) { + bool valid = false; + const int id = index.data(ObjectIdRole).toInt(&valid); + if (valid && id >= 0 && !ids.contains(id)) + ids.append(id); + } + return ids; +} + +void HierarchyPanel::selectAllObjects() { + if (treeView != nullptr) + treeView->selectAll(); +} + +void HierarchyPanel::deselectAllObjects() { + if (treeView != nullptr) + treeView->clearSelection(); + if (viewport != nullptr) + viewport->selectRuntimeObject(-1, false); +} + +void HierarchyPanel::focusSearch() { + searchField->setFocus(); + searchField->selectAll(); +} + +void HierarchyPanel::showCreationPopup() { + showAddObjectMenu(addButton->mapToGlobal(QPoint(0, addButton->height()))); +} + +QString HierarchyPanel::sceneSignature(const QString &sceneName, + const QJsonArray &objects) const { + return sceneName + ':' + objectSignature(objects); +} diff --git a/editor/views/editor/inspector.cpp b/editor/views/editor/inspector.cpp new file mode 100644 index 00000000..c5294485 --- /dev/null +++ b/editor/views/editor/inspector.cpp @@ -0,0 +1,1953 @@ +/* + * 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 +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "editor/views/viewport.h" +#include "editor/widgets/scrubbableSpinBox.h" + +namespace { +using PropertyChanged = + std::function; +struct SyncOption { + QString label; + QJsonValue value; + QJsonObject source; +}; +using SyncOptions = QList; +struct SyncProvider { + std::function options; + std::function matchedName; + std::function setMatch; + std::function clearMatch; + + explicit operator bool() const { return static_cast(options); } + + SyncOptions operator()(const QJsonValue &target) const { + return options ? options(target) : SyncOptions{}; + } +}; + +class PickerSearchField : public QLineEdit { + public: + explicit PickerSearchField(QMenu *menu) : QLineEdit(menu), menu(menu) {} + + protected: + void keyPressEvent(QKeyEvent *event) override { + if (event->key() == Qt::Key_Down || event->key() == Qt::Key_Up) { + const QList actions = selectableActions(); + if (!actions.isEmpty()) { + int index = actions.indexOf(menu->activeAction()); + if (event->key() == Qt::Key_Down) + index = (index + 1) % actions.size(); + else + index = index <= 0 ? actions.size() - 1 : index - 1; + menu->setActiveAction(actions.at(index)); + } + event->accept(); + return; + } + if (event->key() == Qt::Key_Return || + event->key() == Qt::Key_Enter) { + const QList actions = selectableActions(); + QAction *action = menu->activeAction(); + if ((action == nullptr || !actions.contains(action)) && + !actions.isEmpty()) { + action = actions.first(); + } + if (action != nullptr) { + action->trigger(); + menu->close(); + } + event->accept(); + return; + } + if (event->key() == Qt::Key_Escape) { + menu->close(); + event->accept(); + return; + } + QLineEdit::keyPressEvent(event); + } + + private: + QList selectableActions() const { + QList result; + for (QAction *action : menu->actions()) { + auto *widgetAction = qobject_cast(action); + if (action->isVisible() && action->isEnabled() && + !action->isSeparator() && + (widgetAction == nullptr || + widgetAction->defaultWidget() == nullptr)) { + result.append(action); + } + } + return result; + } + + QMenu *menu = nullptr; +}; + +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 *, const QString &type) { + const QString normalized = type.toLower(); + if (normalized == "folder") + return styling::icon(styling::Icon::Folder, "#7E929C"); + if (normalized.contains("camera")) + return styling::icon(styling::Icon::Camera, "#9E897D"); + if (normalized.contains("environment") || + normalized.contains("atmosphere")) + return styling::icon(styling::Icon::Globe, "#7E929C"); + if (normalized.contains("light") || normalized == "sun") + return styling::icon(styling::Icon::Lightbulb, "#A1957D"); + if (normalized.contains("terrain")) + return styling::icon(styling::Icon::Mountains, "#849589"); + if (normalized.contains("particle")) + return styling::icon(styling::Icon::Sparkle, "#8498A8"); + if (normalized.contains("audio") || normalized == "wav" || + normalized == "mp3" || normalized == "ogg" || + normalized == "flac") + return styling::icon(styling::Icon::MusicNote, "#849589"); + if (normalized.contains("material")) + return styling::icon(styling::Icon::Material, "#9E897D"); + if (normalized.contains("script") || normalized == "ts" || + normalized == "js") + return styling::icon(styling::Icon::FileCode, "#7E929C"); + if (normalized.contains("rigidbody") || normalized.contains("joint")) + return styling::icon(styling::Icon::Wrench, "#A1957D"); + if (normalized == "sphere") + return styling::icon(styling::Icon::Sphere, "#8498A8"); + return styling::icon(styling::Icon::Cube, "#8498A8"); +} + +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 environmentSchema() { + return { + {"automaticAmbient", true}, + {"atmosphereSky", true}, + {"lookupTexture", ""}, + {"fog", QJsonObject{{"color", QJsonArray{0.7, 0.78, 1.0}}, + {"intensity", 0.0}}}, + {"volumetricLighting", + QJsonObject{{"enabled", false}, + {"density", 0.35}, + {"weight", 0.02}, + {"decay", 0.95}, + {"exposure", 0.7}}}, + {"lightBloom", QJsonObject{{"radius", 0.01}, {"maxSamples", 6}}}, + {"rimLight", QJsonObject{{"intensity", 0.0}, + {"color", QJsonArray{1.0, 0.96, 0.86}}}}, + {"atmosphere", + QJsonObject{ + {"enabled", true}, + {"cycle", false}, + {"timeOfDay", 12.0}, + {"secondsPerHour", 180.0}, + {"wind", QJsonArray{0.0, 0.0, 0.0}}, + {"sunColor", QJsonArray{1.0, 0.95, 0.84}}, + {"moonColor", QJsonArray{0.59, 0.59, 0.82}}, + {"sunSize", 1.0}, + {"moonSize", 1.0}, + {"sunTintStrength", 0.35}, + {"moonTintStrength", 0.8}, + {"starIntensity", 2.5}, + {"globalLight", + QJsonObject{{"enabled", true}, + {"castsShadows", true}, + {"shadowResolution", 4096}}}, + {"clouds", + QJsonObject{{"enabled", false}, + {"frequency", 4}, + {"divisions", 6}, + {"position", QJsonArray{0.0, 100.0, 0.0}}, + {"size", QJsonArray{500.0, 80.0, 500.0}}, + {"scale", 1.5}, + {"offset", QJsonArray{0.0, 0.0, 0.0}}, + {"density", 0.45}, + {"densityMultiplier", 1.5}, + {"absorption", 1.1}, + {"scattering", 0.85}, + {"phase", 0.55}, + {"clusterStrength", 0.5}, + {"primaryStepCount", 12}, + {"lightStepCount", 6}, + {"lightStepMultiplier", 1.6}, + {"minStepLength", 0.05}, + {"wind", QJsonArray{0.03, 0.0, 0.02}}}}, + {"weather", + QJsonObject{{"enabled", false}, + {"condition", "clear"}, + {"intensity", 0.0}, + {"wind", QJsonArray{0.0, -0.4, 0.0}}}}}}}; +} + +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", ""}, + {"useSpatialization", true}, + {"volume", 1.0}, + {"loop", false}, + {"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 {}; +} + +QJsonObject lightSchema(const QString &type) { + const QString normalized = type.toLower().remove('_').remove('-'); + QJsonObject common{{"color", QJsonArray{1.0, 1.0, 1.0, 1.0}}, + {"intensity", 1.0}}; + if (normalized == "ambientlight" || normalized == "ambient") { + common.insert("intensity", 0.5); + return common; + } + common.insert("shineColor", QJsonArray{1.0, 1.0, 1.0, 1.0}); + common.insert("castsShadows", false); + common.insert("shadowResolution", 2048); + if (normalized == "directionallight" || normalized == "sun") { + common.insert("direction", QJsonArray{0.0, -1.0, 0.0}); + common.insert("shadowResolution", 4096); + } else if (normalized == "pointlight") { + common.insert("distance", 50.0); + } else if (normalized == "spotlight") { + common.insert("direction", QJsonArray{0.0, -1.0, 0.0}); + common.insert("range", 50.0); + common.insert("cutoff", 35.0); + common.insert("outerCutoff", 40.0); + } else if (normalized == "arealight") { + common.insert("right", QJsonArray{1.0, 0.0, 0.0}); + common.insert("up", QJsonArray{0.0, 1.0, 0.0}); + common.insert("size", QJsonArray{1.0, 1.0}); + common.insert("range", 50.0); + common.insert("angle", 90.0); + common.insert("castsBothSides", false); + } + return common; +} + +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; +} + +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") + 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"}; + } + if (key == "condition" && path.contains("weather")) + return {"clear", "rain", "snow", "storm"}; + return {}; +} + +QDoubleSpinBox *numberField(double value, QWidget *parent) { + auto *field = new ScrubbableDoubleSpinBox(parent); + field->setObjectName("inspectorNumberField"); + field->setRange(-1000000000.0, 1000000000.0); + field->setDecimals(4); + field->setSingleStep(0.1); + field->setButtonSymbols(QAbstractSpinBox::NoButtons); + field->setValue(value); + field->setKeyboardTracking(true); + return field; +} + +QJsonValue adaptedSyncValue(const QJsonValue &source, + const QJsonValue &target, + const QString &path) { + if (target.isDouble()) { + if (source.isDouble()) + return source; + const QJsonArray values = source.toArray(); + if (values.isEmpty()) + return target; + if (path.endsWith("/radius", Qt::CaseInsensitive)) { + double maximum = 0.0; + for (const QJsonValue &value : values) + maximum = std::max(maximum, std::abs(value.toDouble())); + return maximum * 0.5; + } + if (path.endsWith("/height", Qt::CaseInsensitive) && + values.size() > 1) { + return values.at(1); + } + return values.first(); + } + if (!target.isArray()) + return source; + const int dimensions = target.toArray().size(); + QJsonArray result; + if (source.isDouble()) { + while (result.size() < dimensions) + result.append(source); + return result; + } + const QJsonArray values = source.toArray(); + for (int index = 0; index < dimensions; ++index) { + result.append(index < values.size() ? values.at(index) + : values.isEmpty() + ? QJsonValue(0.0) + : values.last()); + } + return result; +} + +void setNumericEditorValue(QWidget *editor, const QJsonValue &value) { + QList fields = editor->findChildren(); + if (auto *field = qobject_cast(editor)) + fields.prepend(field); + const QJsonArray values = value.toArray(); + for (int index = 0; index < fields.size(); ++index) { + QSignalBlocker blocker(fields.at(index)); + fields.at(index)->setValue(value.isDouble() + ? value.toDouble() + : index < values.size() + ? values.at(index).toDouble() + : 0.0); + } +} + +void addSyncPicker(QHBoxLayout *layout, const QString &path, + const QJsonValue ¤t, const PropertyChanged &changed, + const SyncProvider &provider, QWidget *valueEditor, + QWidget *parent) { + auto *button = new QToolButton(parent); + button->setObjectName("inspectorSyncButton"); + button->setIcon( + styling::icon(styling::Icon::ArrowCounterClockwise, "#849589")); + button->setToolTip("Match this value with another property"); + button->setPopupMode(QToolButton::InstantPopup); + auto showMatch = [button, valueEditor](const QString &name) { + const bool matched = !name.isEmpty(); + valueEditor->setVisible(!matched); + button->setText(name); + button->setToolButtonStyle(matched ? Qt::ToolButtonTextBesideIcon + : Qt::ToolButtonIconOnly); + button->setProperty("matched", matched); + button->setSizePolicy(matched ? QSizePolicy::Expanding + : QSizePolicy::Fixed, + QSizePolicy::Preferred); + button->setToolTip(matched + ? QStringLiteral("Matched to %1. Click to change") + .arg(name) + : "Match this value with another property"); + button->style()->unpolish(button); + button->style()->polish(button); + }; + showMatch(provider.matchedName ? provider.matchedName(path) : QString()); + auto *menu = new QMenu(button); + auto *searchAction = new QWidgetAction(menu); + auto *search = new PickerSearchField(menu); + search->setPlaceholderText("Search properties"); + search->setClearButtonEnabled(true); + search->setMinimumWidth(240); + searchAction->setDefaultWidget(search); + menu->addAction(searchAction); + menu->addSeparator(); + auto searchable = std::make_shared>(); + auto generated = std::make_shared>(); + QObject::connect(search, &QLineEdit::textChanged, menu, + [searchable](const QString &text) { + const QString query = text.trimmed().toLower(); + for (QAction *action : *searchable) { + action->setVisible( + query.isEmpty() || + action->property("searchText") + .toString() + .contains(query)); + } + }); + QObject::connect( + menu, &QMenu::aboutToShow, search, + [menu, search, searchable, generated, provider, current, path, changed, + showMatch, valueEditor] { + for (QAction *action : *generated) { + menu->removeAction(action); + action->deleteLater(); + } + generated->clear(); + searchable->clear(); + QAction *manual = menu->addAction( + "Enter value manually", menu, + [provider, path, showMatch] { + if (provider.clearMatch) + provider.clearMatch(path); + showMatch(QString()); + }); + manual->setProperty("searchText", "enter value manually unlink"); + searchable->append(manual); + generated->append(manual); + QAction *section = menu->addSeparator(); + generated->append(section); + const SyncOptions options = + provider ? provider(current) : SyncOptions{}; + for (const SyncOption &option : options) { + QAction *action = menu->addAction( + option.label, menu, + [current, option, path, changed, provider, showMatch, + valueEditor] { + const QJsonValue matched = + adaptedSyncValue(option.value, current, path); + changed(path, matched); + setNumericEditorValue(valueEditor, matched); + if (provider.setMatch) + provider.setMatch(path, option.source); + showMatch(option.label); + }); + action->setProperty("searchText", option.label.toLower()); + searchable->append(action); + generated->append(action); + } + if (options.isEmpty()) { + QAction *empty = + menu->addAction("No compatible properties"); + empty->setEnabled(false); + generated->append(empty); + } + search->clear(); + search->setFocus(); + }); + button->setMenu(menu); + layout->addWidget(button); +} + +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, const SyncProvider &syncProvider, + 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 *valueEditor = new QWidget(field); + auto *valueLayout = new QHBoxLayout(valueEditor); + valueLayout->setContentsMargins(0, 0, 0, 0); + valueLayout->setSpacing(2); + layout->addWidget(valueEditor, 1); + auto values = value; + const int dimensions = std::clamp(static_cast(value.size()), 2, 3); + while (values.size() < dimensions) + values.append(0.0); + const QStringList axes{"X", "Y", "Z"}; + QList boxes; + for (int index = 0; index < dimensions; ++index) { + auto *axis = new QLabel(axes.at(index), valueEditor); + axis->setObjectName("inspectorAxisLabel"); + auto *box = numberField(values.at(index).toDouble(), valueEditor); + box->setButtonSymbols(QAbstractSpinBox::NoButtons); + box->setMinimumWidth(38); + boxes.append(box); + valueLayout->addWidget(axis); + valueLayout->addWidget(box, 1); + } + auto commit = [boxes, changed, path] { + QJsonArray result; + for (QDoubleSpinBox *box : boxes) + result.append(box->value()); + changed(path, result); + }; + for (QDoubleSpinBox *box : boxes) { + QObject::connect(box, &QDoubleSpinBox::valueChanged, field, + [commit](double) { commit(); }); + } + addSyncPicker(layout, path, value, changed, syncProvider, valueEditor, + field); + 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->setIcon(styling::colorSwatch(next, QSize(22, 14))); + swatch->setIconSize(QSize(22, 14)); + 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, + const SyncProvider &syncProvider, QWidget *parent); + +QWidget *primitiveField(const QString &name, const QString &path, + const QJsonValue &value, const PropertyChanged &changed, + const SyncProvider &syncProvider, 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 *container = new QFrame(parent); + container->setObjectName("inspectorNumericField"); + auto *layout = new QHBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(2); + auto *field = numberField(value.toDouble(), container); + layout->addWidget(field, 1); + addSyncPicker(layout, path, value, changed, syncProvider, field, + container); + QObject::connect(field, &QDoubleSpinBox::valueChanged, container, + [field, changed, path](double) { + changed(path, field->value()); + }); + return container; + } + if (value.isArray()) { + const QJsonArray array = value.toArray(); + if (isColorProperty(name, array)) { + return colorField(array, changed, path, parent); + } + if ((array.size() == 2 || array.size() == 3) && + isNumericArray(array)) { + return vectorField(array, changed, path, syncProvider, 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"); + 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); + 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); + connectLiveText(field, + [field, changed, path] { changed(path, field->text()); }); + return field; +} + +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; +} + +void addPropertyRows(QVBoxLayout *layout, const QJsonObject &properties, + const QString &path, const PropertyChanged &changed, + const SyncProvider &syncProvider, 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, syncProvider, group); + layout->addWidget(group); + continue; + } + if (iterator.value().isArray()) { + const QJsonArray array = iterator.value().toArray(); + 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 *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"); + auto *add = new QToolButton(heading); + add->setObjectName("inspectorArrayButton"); + add->setIcon( + styling::icon(styling::Icon::Plus, "#8498A8")); + add->setToolTip("Add item"); + 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 *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"); + auto *remove = new QToolButton(itemHeading); + remove->setObjectName("inspectorArrayButton"); + remove->setIcon( + styling::icon(styling::Icon::Trash, "#A17F7F")); + remove->setToolTip("Remove item"); + 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, syncProvider, group); + } + layout->addWidget(group); + continue; + } + } + QWidget *editor = + primitiveField(key, nextPath, iterator.value(), changed, + syncProvider, parent); + layout->addWidget(propertyRow(humanize(key), editor, parent)); + } +} + +QString syncPointerSegment(QString value) { + return value.replace('~', "~0").replace('/', "~1"); +} + +void collectSyncOptions(const QString &label, const QJsonValue &value, + const QJsonObject &source, const QString &path, + SyncOptions &options) { + if (value.isDouble()) { + QJsonObject endpoint = source; + endpoint.insert("path", path); + endpoint.insert("fallback", value); + options.append({label, value, endpoint}); + return; + } + if (value.isArray()) { + const QJsonArray array = value.toArray(); + if (!array.isEmpty() && isNumericArray(array)) { + QJsonObject endpoint = source; + endpoint.insert("path", path); + endpoint.insert("fallback", value); + options.append({label, value, endpoint}); + return; + } + for (int index = 0; index < array.size(); ++index) { + if (array.at(index).isObject()) { + collectSyncOptions( + QStringLiteral("%1 %2").arg(label).arg(index + 1), + array.at(index), source, + path + '/' + QString::number(index), options); + } + } + return; + } + if (!value.isObject()) + return; + const QJsonObject object = value.toObject(); + for (auto iterator = object.begin(); iterator != object.end(); ++iterator) { + const QString childLabel = + label.isEmpty() ? humanize(iterator.key()) + : label + " · " + humanize(iterator.key()); + collectSyncOptions(childLabel, iterator.value(), source, + path + '/' + syncPointerSegment(iterator.key()), + options); + } +} + +SyncProvider makeSyncProvider(const SyncOptions &options) { + SyncProvider provider; + provider.options = [options](const QJsonValue &target) { + SyncOptions compatible; + for (const auto &option : options) { + if ((target.isDouble() && + (option.value.isDouble() || option.value.isArray())) || + (target.isArray() && + (option.value.isDouble() || option.value.isArray()))) { + compatible.append(option); + } + } + return compatible; + }; + return provider; +} + +QJsonObject syncTargetAtPath(QJsonObject target, const QString &path) { + target.insert("path", target.value("path").toString() + path); + return target; +} + +SyncProvider bindSyncProvider(const SyncProvider &provider, + ViewportPanel *viewport, QJsonObject *scene, + const QJsonObject &target) { + SyncProvider bound = provider; + bound.matchedName = [provider, scene, target](const QString &path) { + const QJsonObject endpoint = syncTargetAtPath(target, path); + const QJsonArray bindings = scene->value("propertySyncs").toArray(); + for (const QJsonValue &entry : bindings) { + const QJsonObject binding = entry.toObject(); + if (binding.value("target").toObject() != endpoint) + continue; + const QJsonObject source = binding.value("source").toObject(); + for (const SyncOption &option : provider(QJsonValue(0.0))) { + if (option.source == source) + return option.label; + } + } + return QString(); + }; + bound.setMatch = [viewport, target](const QString &path, + const QJsonObject &source) { + if (viewport != nullptr) + viewport->setRuntimePropertySync(syncTargetAtPath(target, path), + source); + }; + bound.clearMatch = [viewport, target](const QString &path) { + if (viewport != nullptr) + viewport->clearRuntimePropertySync(syncTargetAtPath(target, path)); + }; + return bound; +} + +QJsonValue objectSyncReference(const QJsonObject &object) { + const QJsonValue id = object.value("properties").toObject().value("id"); + if (id.isString() && !id.toString().isEmpty()) + return id; + if (id.isDouble()) + return QString::number(id.toInt()); + return object.value("name"); +} + +QFrame *componentCard(const QString &title, const QJsonObject &properties, + const QString &path, const PropertyChanged &changed, + QWidget *parent, const SyncProvider &syncProvider = {}, + const std::function &remove = {}) { + auto *card = new QFrame(parent); + card->setObjectName("inspectorComponent"); + auto *layout = new QVBoxLayout(card); + layout->setContentsMargins(0, 0, 0, 7); + layout->setSpacing(2); + auto *headerRow = new QWidget(card); + headerRow->setObjectName("inspectorComponentHeaderRow"); + auto *headerLayout = new QHBoxLayout(headerRow); + headerLayout->setContentsMargins(0, 0, 3, 0); + headerLayout->setSpacing(2); + auto *header = new QToolButton(headerRow); + header->setObjectName("inspectorComponentHeader"); + header->setText(title); + header->setCheckable(true); + header->setChecked(true); + header->setIcon(styling::icon(styling::Icon::CaretDown, "#8490A4")); + header->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + headerLayout->addWidget(header, 1); + if (remove) { + auto *removeButton = new QToolButton(headerRow); + removeButton->setObjectName("inspectorComponentRemoveButton"); + removeButton->setIcon( + styling::icon(styling::Icon::Trash, "#A17F7F")); + removeButton->setToolTip(QStringLiteral("Remove %1").arg(title)); + headerLayout->addWidget(removeButton); + QObject::connect(removeButton, &QToolButton::clicked, card, remove); + } + layout->addWidget(headerRow); + 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, syncProvider, body); + layout->addWidget(body); + QObject::connect( + header, &QToolButton::toggled, card, [header, body](bool expanded) { + body->setVisible(expanded); + header->setIcon(styling::icon( + expanded ? styling::Icon::CaretDown : styling::Icon::CaretRight, + "#8490A4")); + }); + return card; +} + +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 + +InspectorPanel::InspectorPanel(ViewportPanel *viewport, + const QString &projectFile, QWidget *parent) + : QWidget(parent), viewport(viewport) { + setObjectName("inspectorPanel"); + setAcceptDrops(true); + const QFileInfo projectInfo(projectFile); + projectRoot = projectInfo.absoluteDir().absolutePath(); + 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(); +} + +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(); + if (environmentTarget) + return; + if (cameraTarget) { + inspectedCamera = scene.value("camera").toObject(); + return; + } + 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").toObject().value("material") != + inspectedObject.value("properties").toObject().value( + "material") || + componentShape(updated.value("components").toArray()) != + componentShape(inspectedObject.value("components").toArray()); + inspectedObject = updated; + if (contentChanged) { + showObject(inspectedObject); + } + } +} + +void InspectorPanel::inspectRuntimeObject(int id) { + fileTarget = false; + cameraTarget = false; + environmentTarget = false; + inspectedFile.clear(); + inspectedCamera = {}; + inspectedObjectId = id; + inspectedObject = findObject(id); + if (inspectedObject.isEmpty()) { + showEmptyState(); + } else { + showObject(inspectedObject); + } +} + +void InspectorPanel::inspectCamera() { + fileTarget = false; + cameraTarget = true; + environmentTarget = false; + inspectedFile.clear(); + inspectedObjectId = -1; + inspectedObject = {}; + inspectedCamera = scene.value("camera").toObject(); + showCamera(); +} + +void InspectorPanel::inspectEnvironment() { + fileTarget = false; + cameraTarget = false; + environmentTarget = true; + inspectedFile.clear(); + inspectedObjectId = -1; + inspectedObject = {}; + inspectedCamera = {}; + showEnvironment(); +} + +void InspectorPanel::inspectFile(const QString &path) { + if (path.isEmpty()) { + fileTarget = false; + inspectRuntimeObject(lastRuntimeSelection); + return; + } + fileTarget = true; + cameraTarget = false; + environmentTarget = false; + 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")}}; + const QJsonValue objectReference = objectSyncReference(object); + const QJsonArray components = object.value("components").toArray(); + SyncOptions syncOptions; + syncOptions.append( + {"Object Size", object.value("boundsSize"), + QJsonObject{{"section", "object"}, + {"object", objectReference}, + {"component", "bounds"}, + {"componentIndex", -1}, + {"path", QString()}}}); + collectSyncOptions( + "Transform", transform, + QJsonObject{{"section", "object"}, + {"object", objectReference}, + {"component", "transform"}, + {"componentIndex", -1}}, + QString(), syncOptions); + collectSyncOptions( + "Object", object.value("properties"), + QJsonObject{{"section", "object"}, + {"object", objectReference}, + {"component", "object"}, + {"componentIndex", -1}}, + QString(), syncOptions); + for (int index = 0; index < components.size(); ++index) { + const QJsonObject raw = components.at(index).toObject(); + const QString componentType = raw.value("type").toString("component"); + collectSyncOptions( + componentTitle(componentType), componentValues(componentType, raw), + QJsonObject{{"section", "object"}, + {"object", objectReference}, + {"component", componentType}, + {"componentIndex", index}}, + QString(), syncOptions); + } + const SyncProvider syncProvider = makeSyncProvider(syncOptions); + const QJsonObject transformTarget{{"section", "object"}, + {"object", objectReference}, + {"component", "transform"}, + {"componentIndex", -1}}; + contentLayout->addWidget(componentCard( + "Transform", transform, QString(), + [update](const QString &path, const QJsonValue &value) { + update("transform", -1, path, value); + }, + content, bindSyncProvider(syncProvider, viewport, &scene, + transformTarget))); + + QJsonObject objectProperties = object.value("properties").toObject(); + if (type.contains("light", Qt::CaseInsensitive) || + type.compare("sun", Qt::CaseInsensitive) == 0) { + objectProperties = mergeObjects(lightSchema(type), objectProperties); + } + const QString materialPath = objectProperties.value("material").toString(); + objectProperties.remove("material"); + 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, + bindSyncProvider( + syncProvider, viewport, &scene, + QJsonObject{{"section", "object"}, + {"object", objectReference}, + {"component", "object"}, + {"componentIndex", -1}}))); + } + + if (!materialPath.isEmpty()) { + contentLayout->addWidget(componentCard( + "Material", QJsonObject{{"source", materialPath}}, QString(), + [this, objectId](const QString &path, const QJsonValue &value) { + if (path == "/source" && value.isString() && + viewport != nullptr) { + viewport->applyRuntimeMaterial(objectId, value.toString()); + } + }, + content)); + } + + 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, + bindSyncProvider( + syncProvider, viewport, &scene, + QJsonObject{{"section", "object"}, + {"object", objectReference}, + {"component", componentType}, + {"componentIndex", index}}), + [this, objectId, index, componentType] { + if (QMessageBox::question( + this, "Remove Component", + QStringLiteral("Remove %1 from this object?") + .arg(componentTitle(componentType))) != + QMessageBox::Yes) { + return; + } + if (viewport == nullptr || + !viewport->removeRuntimeObjectComponent(objectId, + index)) { + QMessageBox::warning(this, "Remove Component", + "The component could not be removed."); + } + })); + if (componentType.toLower().remove('_').remove('-') == "audioplayer") { + auto *controls = new QFrame(content); + controls->setObjectName("inspectorAudioControls"); + auto *controlsLayout = new QHBoxLayout(controls); + controlsLayout->setContentsMargins(8, 4, 8, 6); + controlsLayout->setSpacing(5); + const QStringList audioActions{"Play", "Pause", "Stop"}; + const QList audioIcons{ + styling::Icon::Play, styling::Icon::Pause, + styling::Icon::Stop}; + const QList audioColors{ + QColor("#849589"), QColor("#A1957D"), QColor("#A17F7F")}; + for (int actionIndex = 0; actionIndex < audioActions.size(); + ++actionIndex) { + const QString &action = audioActions.at(actionIndex); + auto *button = new QToolButton(controls); + button->setIcon(styling::icon( + audioIcons.at(actionIndex), audioColors.at(actionIndex))); + button->setToolTip(action); + controlsLayout->addWidget(button); + connect(button, &QToolButton::clicked, this, + [this, objectId, index, action] { + if (viewport != nullptr) { + viewport->controlRuntimeAudio( + objectId, index, action.toLower()); + } + }); + } + controlsLayout->addStretch(); + contentLayout->addWidget(controls); + } + } + auto *addComponent = new QToolButton(content); + addComponent->setObjectName("inspectorAddComponentButton"); + addComponent->setIcon( + styling::icon(styling::Icon::Plus, "#8498A8")); + addComponent->setText("Add Component"); + addComponent->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + addComponent->setPopupMode(QToolButton::InstantPopup); + auto *componentMenu = new QMenu(addComponent); + auto *searchAction = new QWidgetAction(componentMenu); + auto *componentSearch = new PickerSearchField(componentMenu); + componentSearch->setPlaceholderText("Search components, scripts, materials"); + componentSearch->setClearButtonEnabled(true); + componentSearch->setMinimumWidth(280); + searchAction->setDefaultWidget(componentSearch); + componentMenu->addAction(searchAction); + componentMenu->addSeparator(); + QList searchableActions; + 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) { + QAction *action = 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."); + } + }); + }); + action->setProperty("searchText", label.toLower()); + searchableActions.append(action); + } + QDirIterator assets(projectRoot, + {"*.ts", "*.js", "*.amat", "*.material", + "*.wav", "*.mp3", "*.ogg", "*.flac", + "*.m4a", "*.aac"}, + QDir::Files, QDirIterator::Subdirectories); + while (assets.hasNext()) { + const QFileInfo info(assets.next()); + const QString suffix = info.suffix().toLower(); + const bool material = suffix == "amat" || suffix == "material"; + const bool audio = suffix == "wav" || suffix == "mp3" || + suffix == "ogg" || suffix == "flac" || + suffix == "m4a" || suffix == "aac"; + const QString label = + QStringLiteral("%1 · %2") + .arg(material ? "Material" : audio ? "Audio" : "Script", + info.completeBaseName()); + QAction *action = componentMenu->addAction( + label, this, [this, objectId, path = info.absoluteFilePath()] { + attachAsset(path, objectId); + }); + action->setIcon(inspectorIcon( + this, material ? "material" : audio ? "audio" : "script")); + action->setProperty("searchText", + (label + ' ' + info.absoluteFilePath()).toLower()); + searchableActions.append(action); + } + connect(componentSearch, &QLineEdit::textChanged, componentMenu, + [searchableActions](const QString &text) { + const QString query = text.trimmed().toLower(); + for (QAction *action : searchableActions) { + action->setVisible( + query.isEmpty() || + action->property("searchText").toString().contains(query)); + } + }); + connect(componentMenu, &QMenu::aboutToShow, componentSearch, + [componentSearch] { + componentSearch->clear(); + componentSearch->setFocus(); + }); + addComponent->setMenu(componentMenu); + contentLayout->addWidget(addComponent); + contentLayout->addStretch(); +} + +void InspectorPanel::showCamera() { + rebuildBody(); + auto *header = new QFrame(content); + header->setObjectName("inspectorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(10, 10, 10, 10); + headerLayout->setSpacing(10); + auto *cameraIcon = new QLabel(header); + cameraIcon->setObjectName("inspectorObjectIcon"); + cameraIcon->setPixmap(inspectorIcon(this, "camera").pixmap(42, 42)); + cameraIcon->setFixedSize(46, 46); + auto *identity = new QWidget(header); + auto *identityLayout = new QVBoxLayout(identity); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->setSpacing(2); + auto *title = new QLabel("Main Camera", identity); + title->setObjectName("inspectorCameraTitle"); + auto *kind = new QLabel("Scene Camera", identity); + kind->setObjectName("inspectorTypeLabel"); + identityLayout->addWidget(title); + identityLayout->addWidget(kind); + headerLayout->addWidget(cameraIcon); + headerLayout->addWidget(identity, 1); + contentLayout->addWidget(header); + + auto update = [this](const QString &path, const QJsonValue &value) { + if (viewport != nullptr) { + viewport->setRuntimeSceneProperty("camera", -1, path, value); + } + }; + QJsonObject transform{{"position", inspectedCamera.value("position")}, + {"target", inspectedCamera.value("target")}}; + QJsonObject projection{ + {"orthographic", inspectedCamera.value("orthographic")}, + {"fov", inspectedCamera.value("fov")}, + {"orthoSize", inspectedCamera.value("orthoSize")}, + {"nearClip", inspectedCamera.value("nearClip")}, + {"farClip", inspectedCamera.value("farClip")}}; + QJsonObject focus{{"focusDepth", inspectedCamera.value("focusDepth")}, + {"focusRange", inspectedCamera.value("focusRange")}}; + QJsonObject controls{ + {"movementSpeed", inspectedCamera.value("movementSpeed")}, + {"mouseSensitivity", inspectedCamera.value("mouseSensitivity")}, + {"controllerLookSensitivity", + inspectedCamera.value("controllerLookSensitivity")}, + {"lookSmoothness", inspectedCamera.value("lookSmoothness")}, + {"automaticMoving", inspectedCamera.value("automaticMoving")}, + {"actions", inspectedCamera.value("actions").isArray() + ? inspectedCamera.value("actions") + : QJsonValue(QJsonArray{})}}; + SyncOptions syncOptions; + collectSyncOptions("Camera", inspectedCamera, + QJsonObject{{"section", "camera"}}, QString(), + syncOptions); + const SyncProvider syncProvider = makeSyncProvider(syncOptions); + contentLayout->addWidget(componentCard("Transform", transform, QString(), + update, content, + bindSyncProvider( + syncProvider, viewport, &scene, + QJsonObject{{"section", + "camera"}}))); + contentLayout->addWidget(componentCard( + "Projection", projection, QString(), + update, + content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "camera"}}))); + contentLayout->addWidget( + componentCard("Depth of Field", focus, QString(), update, content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "camera"}}))); + contentLayout->addWidget(componentCard("Camera Controls", controls, + QString(), update, content, + bindSyncProvider( + syncProvider, viewport, &scene, + QJsonObject{{"section", + "camera"}}))); + contentLayout->addStretch(); +} + +void InspectorPanel::showEnvironment() { + rebuildBody(); + auto *header = new QFrame(content); + header->setObjectName("inspectorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(10, 10, 10, 10); + headerLayout->setSpacing(10); + auto *environmentIcon = new QLabel(header); + environmentIcon->setObjectName("inspectorObjectIcon"); + environmentIcon->setPixmap( + inspectorIcon(this, "environment").pixmap(42, 42)); + environmentIcon->setFixedSize(46, 46); + auto *identity = new QWidget(header); + auto *identityLayout = new QVBoxLayout(identity); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->setSpacing(2); + auto *title = new QLabel("Environment", identity); + title->setObjectName("inspectorCameraTitle"); + auto *kind = new QLabel("Live Scene Atmosphere", identity); + kind->setObjectName("inspectorTypeLabel"); + identityLayout->addWidget(title); + identityLayout->addWidget(kind); + headerLayout->addWidget(environmentIcon); + headerLayout->addWidget(identity, 1); + contentLayout->addWidget(header); + + QJsonObject values = mergeObjects( + environmentSchema(), scene.value("environment").toObject()); + SyncOptions syncOptions; + collectSyncOptions("Environment", values, + QJsonObject{{"section", "environment"}}, QString(), + syncOptions); + const SyncProvider syncProvider = makeSyncProvider(syncOptions); + QJsonObject atmosphere = values.take("atmosphere").toObject(); + QJsonObject globalLight = atmosphere.take("globalLight").toObject(); + QJsonObject clouds = atmosphere.take("clouds").toObject(); + QJsonObject weather = atmosphere.take("weather").toObject(); + auto update = [this](const QString &prefix, const QString &path, + const QJsonValue &value) { + if (viewport != nullptr) + viewport->setRuntimeSceneProperty("environment", -1, + prefix + path, value); + }; + contentLayout->addWidget(componentCard( + "Environment", values, QString(), + [update](const QString &path, const QJsonValue &value) { + update(QString(), path, value); + }, + content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "environment"}}))); + contentLayout->addWidget(componentCard( + "Atmosphere", atmosphere, QString(), + [update](const QString &path, const QJsonValue &value) { + update("/atmosphere", path, value); + }, + content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "environment"}, + {"path", "/atmosphere"}}))); + contentLayout->addWidget(componentCard( + "Global Light", globalLight, QString(), + [update](const QString &path, const QJsonValue &value) { + update("/atmosphere/globalLight", path, value); + }, + content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "environment"}, + {"path", "/atmosphere/globalLight"}}))); + contentLayout->addWidget(componentCard( + "Clouds", clouds, QString(), + [update](const QString &path, const QJsonValue &value) { + update("/atmosphere/clouds", path, value); + }, + content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "environment"}, + {"path", "/atmosphere/clouds"}}))); + contentLayout->addWidget(componentCard( + "Weather", weather, QString(), + [update](const QString &path, const QJsonValue &value) { + update("/atmosphere/weather", path, value); + }, + content, + bindSyncProvider(syncProvider, viewport, &scene, + QJsonObject{{"section", "environment"}, + {"path", "/atmosphere/weather"}}))); + contentLayout->addStretch(); +} + +bool InspectorPanel::attachAsset(const QString &path, int objectId) { + return viewport != nullptr && objectId >= 0 && + viewport->attachRuntimeAsset(objectId, path); +} + +void InspectorPanel::dragEnterEvent(QDragEnterEvent *event) { + if (inspectedObjectId >= 0 && event->mimeData()->hasUrls()) { + const QString suffix = + QFileInfo(event->mimeData()->urls().constFirst().toLocalFile()) + .suffix() + .toLower(); + if (suffix == "amat" || suffix == "material" || suffix == "ts" || + suffix == "js" || suffix == "wav" || suffix == "mp3" || + suffix == "ogg" || suffix == "flac" || suffix == "m4a" || + suffix == "aac") { + event->acceptProposedAction(); + return; + } + } + event->ignore(); +} + +void InspectorPanel::dropEvent(QDropEvent *event) { + if (event->mimeData()->hasUrls() && inspectedObjectId >= 0 && + attachAsset(event->mimeData()->urls().constFirst().toLocalFile(), + inspectedObjectId)) { + event->acceptProposedAction(); + return; + } + event->ignore(); +} + +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; +} + +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(); +} + +QJsonObject InspectorPanel::findObject(int id) const { + if (id < 0) + return {}; + return findObjectInArray(scene.value("objects").toArray(), id); +} diff --git a/editor/views/editor/materialEditor.cpp b/editor/views/editor/materialEditor.cpp new file mode 100644 index 00000000..4bae91be --- /dev/null +++ b/editor/views/editor/materialEditor.cpp @@ -0,0 +1,807 @@ +#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 +#include + +namespace { +QColor jsonColor(const QJsonValue &value, const QColor &fallback) { + const QJsonArray array = value.toArray(); + if (array.size() < 3) { + return fallback; + } + return QColor::fromRgbF(std::clamp(array.at(0).toDouble(), 0.0, 1.0), + std::clamp(array.at(1).toDouble(), 0.0, 1.0), + std::clamp(array.at(2).toDouble(), 0.0, 1.0), + array.size() > 3 + ? std::clamp(array.at(3).toDouble(), 0.0, 1.0) + : 1.0); +} + +QJsonArray colorJson(const QColor &color) { + return {color.redF(), color.greenF(), color.blueF(), color.alphaF()}; +} + +void displayColor(QPushButton *button, const QColor &color) { + button->setProperty("materialColor", color); + button->setObjectName("materialColorButton"); + button->setText(color.name(color.alpha() < 255 ? QColor::HexArgb + : QColor::HexRgb) + .toUpper()); + button->setIcon(styling::colorSwatch(color, QSize(18, 18))); + button->setIconSize(QSize(18, 18)); +} + +QDoubleSpinBox *scalarField(double minimum, double maximum, double step, + QWidget *parent) { + auto *field = new ScrubbableDoubleSpinBox(parent); + field->setObjectName("materialScalarField"); + field->setRange(minimum, maximum); + field->setSingleStep(step); + field->setDecimals(3); + field->setKeyboardTracking(true); + return field; +} + +QString texturePath(const QJsonValue &value) { + if (value.isString()) { + return value.toString(); + } + if (value.isObject()) { + const QJsonObject object = value.toObject(); + return object.value("path").toString( + object.value("source").toString()); + } + return {}; +} + +QString resolvedTexturePath(const QString &baseDir, const QJsonValue &value) { + const QString path = texturePath(value); + if (path.isEmpty() || QFileInfo(path).isAbsolute()) { + return path; + } + return QDir(baseDir).absoluteFilePath(path); +} + +QImage loadTextureImage(const QString &baseDir, const QJsonValue &value) { + const QString path = resolvedTexturePath(baseDir, value); + return path.isEmpty() ? QImage() : QImage(path); +} + +double channelAt(const QImage &image, double u, double v) { + if (image.isNull()) { + return 1.0; + } + const int x = std::clamp(static_cast(u * image.width()), 0, + image.width() - 1); + const int y = std::clamp(static_cast(v * image.height()), 0, + image.height() - 1); + return QColor::fromRgba(image.pixel(x, y)).lightnessF(); +} + +QColor imageAt(const QImage &image, double u, double v, + const QColor &fallback) { + if (image.isNull()) { + return fallback; + } + const int x = std::clamp(static_cast(u * image.width()), 0, + image.width() - 1); + const int y = std::clamp(static_cast(v * image.height()), 0, + image.height() - 1); + return QColor::fromRgba(image.pixel(x, y)); +} +} + +class MaterialPreviewWidget : public QWidget { + public: + explicit MaterialPreviewWidget(QWidget *parent = nullptr) + : QWidget(parent) { + setObjectName("materialPreview"); + setMinimumSize(80, 80); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + void setMaterial(const QJsonObject &next, const QString &nextBaseDir) { + material = next; + baseDir = nextBaseDir; + albedoImage = + loadTextureImage(baseDir, material.value("albedoTexture")); + normalImage = + loadTextureImage(baseDir, material.value("normalTexture")); + metallicImage = + loadTextureImage(baseDir, material.value("metallicTexture")); + roughnessImage = + loadTextureImage(baseDir, material.value("roughnessTexture")); + aoImage = loadTextureImage(baseDir, material.value("aoTexture")); + update(); + } + + void setEnvironmentMode(int mode) { + environmentMode = mode; + update(); + } + + protected: + void paintEvent(QPaintEvent *) override { + const qreal scale = devicePixelRatioF(); + const int widthPixels = std::max(1, static_cast(width() * scale)); + const int heightPixels = + std::max(1, static_cast(height() * scale)); + QImage rendered(widthPixels, heightPixels, QImage::Format_ARGB32); + rendered.setDevicePixelRatio(scale); + + const QColor albedo = + jsonColor(material.value("albedo"), QColor::fromRgbF(.8, .8, .8)); + const QColor emission = jsonColor(material.value("emissiveColor"), + QColor::fromRgbF(0, 0, 0)); + const double metallic = + std::clamp(material.value("metallic").toDouble(0.0), 0.0, 1.0); + const double roughness = + std::clamp(material.value("roughness").toDouble(0.5), 0.02, 1.0); + const double ao = + std::clamp(material.value("ao").toDouble(1.0), 0.0, 1.0); + const double reflectivity = std::clamp( + material.value("reflectivity").toDouble(0.5), 0.0, 1.0); + const double emissionStrength = std::max( + 0.0, material.value("emissiveIntensity").toDouble(0.0)); + const double transmission = std::clamp( + material.value("transmittance").toDouble(0.0), 0.0, 1.0); + const double normalStrength = std::clamp( + material.value("normalMapStrength").toDouble(1.0), 0.0, 4.0); + const bool useNormal = + material.value("useNormalMap").toBool(true) && + !normalImage.isNull(); + const double cx = widthPixels * 0.5; + const double cy = heightPixels * 0.5; + const double radius = std::min(widthPixels, heightPixels) * 0.39; + const double lx = -0.42; + const double ly = -0.55; + const double lz = 0.72; + + for (int y = 0; y < heightPixels; ++y) { + QRgb *line = reinterpret_cast(rendered.scanLine(y)); + for (int x = 0; x < widthPixels; ++x) { + const QColor background = environmentAt( + (static_cast(x) / widthPixels) * 2.0 - 1.0, + 1.0 - (static_cast(y) / heightPixels) * 2.0); + const double px = (x - cx) / radius; + const double py = (cy - y) / radius; + const double rr = px * px + py * py; + if (rr > 1.0) { + line[x] = background.rgba(); + continue; + } + + double nx = px; + double ny = py; + double nz = std::sqrt(std::max(0.0, 1.0 - rr)); + double u = std::atan2(nx, nz) / + (2.0 * std::numbers::pi_v) + + 0.5; + double v = 0.5 - + std::asin(std::clamp(ny, -1.0, 1.0)) / + std::numbers::pi_v; + if (useNormal) { + const QColor sampled = + imageAt(normalImage, u, v, QColor(128, 128, 255)); + const double tx = sampled.redF() * 2.0 - 1.0; + const double ty = sampled.greenF() * 2.0 - 1.0; + nx += tx * normalStrength * 0.28; + ny += ty * normalStrength * 0.28; + const double length = std::sqrt(nx * nx + ny * ny + nz * nz); + nx /= length; + ny /= length; + nz /= length; + } + + const QColor sampledAlbedo = + imageAt(albedoImage, u, v, QColor(255, 255, 255)); + const double localMetallic = std::clamp( + metallic * channelAt(metallicImage, u, v), 0.0, 1.0); + const double localRoughness = std::clamp( + roughness * channelAt(roughnessImage, u, v), 0.02, 1.0); + const double localAo = + std::clamp(ao * channelAt(aoImage, u, v), 0.0, 1.0); + const double diffuse = std::max(0.0, nx * lx + ny * ly + nz * lz); + const double hx = lx; + const double hy = ly; + const double hz = lz + 1.0; + const double hlen = std::sqrt(hx * hx + hy * hy + hz * hz); + const double ndh = std::max( + 0.0, (nx * hx + ny * hy + nz * hz) / hlen); + const double exponent = 4.0 + + (1.0 - localRoughness) * + (1.0 - localRoughness) * 252.0; + const double specular = + std::pow(ndh, exponent) * + (0.12 + reflectivity * 0.88) * + (0.35 + localMetallic * 0.65); + const double fresnel = + std::pow(1.0 - std::clamp(nz, 0.0, 1.0), 5.0); + const double light = localAo * 0.17 + + diffuse * (0.83 - localMetallic * 0.38); + const double edgeTransmission = + transmission * (0.2 + fresnel * 0.55); + const double rx = 2.0 * nx * nz; + const double ry = 2.0 * ny * nz; + const QColor reflected = environmentAt(rx, ry); + const double reflectionWeight = std::clamp( + reflectivity * (0.12 + localMetallic * 0.88) * + (1.0 - localRoughness * 0.72) + + fresnel * 0.24, + 0.0, 0.92); + auto output = [&](double base, double texture, + double emitted, double environment, + double behind) { + double surface = base * texture * light + specular + + fresnel * reflectivity * 0.18; + surface = surface * (1.0 - reflectionWeight) + + environment * reflectionWeight; + return std::clamp(surface * (1.0 - edgeTransmission) + + behind * edgeTransmission + + emitted * emissionStrength, + 0.0, 1.0); + }; + line[x] = qRgba( + static_cast(output(albedo.redF(), + sampledAlbedo.redF(), + emission.redF(), reflected.redF(), + background.redF()) * + 255.0), + static_cast(output(albedo.greenF(), + sampledAlbedo.greenF(), + emission.greenF(), + reflected.greenF(), + background.greenF()) * + 255.0), + static_cast(output(albedo.blueF(), + sampledAlbedo.blueF(), + emission.blueF(), + reflected.blueF(), + background.blueF()) * + 255.0), + 255); + } + } + + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.drawImage(rect(), rendered); + } + + private: + QColor environmentAt(double x, double y) const { + const double horizon = std::clamp((y + 1.0) * 0.5, 0.0, 1.0); + if (environmentMode == 1) { + const double sun = std::pow( + std::max(0.0, 1.0 - std::hypot(x + 0.38, y - 0.08)), 12.0); + return QColor::fromRgbF( + std::clamp(0.16 + horizon * 0.58 + sun, 0.0, 1.0), + std::clamp(0.07 + horizon * 0.27 + sun * 0.55, 0.0, 1.0), + std::clamp(0.12 + horizon * 0.24 + sun * 0.18, 0.0, 1.0)); + } + if (environmentMode == 2) { + const double cloud = + std::pow(std::max(0.0, std::sin(x * 8.0 + y * 3.0)), 6.0) * + 0.22; + return QColor::fromRgbF( + std::clamp(0.12 + horizon * 0.3 + cloud, 0.0, 1.0), + std::clamp(0.24 + horizon * 0.42 + cloud, 0.0, 1.0), + std::clamp(0.39 + horizon * 0.48 + cloud, 0.0, 1.0)); + } + const double strip = std::pow(std::max(0.0, 1.0 - std::abs(y)), 24.0); + const double panel = + std::pow(std::max(0.0, std::cos(x * 5.5)), 18.0) * 0.58; + const double value = 0.055 + horizon * 0.12 + strip * (0.34 + panel); + return QColor::fromRgbF(std::clamp(value * 0.92, 0.0, 1.0), + std::clamp(value * 0.98, 0.0, 1.0), + std::clamp(value, 0.0, 1.0)); + } + + QJsonObject material; + QString baseDir; + QImage albedoImage; + QImage normalImage; + QImage metallicImage; + QImage roughnessImage; + QImage aoImage; + int environmentMode = 0; +}; + +MaterialEditorPanel::MaterialEditorPanel(ViewportPanel *viewport, + QWidget *parent) + : QWidget(parent), viewport(viewport) { + setObjectName("materialEditorPanel"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *header = new QWidget(this); + header->setObjectName("materialEditorHeader"); + auto *headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(8, 4, 8, 4); + titleLabel = new QLabel("Material Editor", header); + titleLabel->setObjectName("materialEditorTitle"); + statusLabel = new QLabel(header); + statusLabel->setObjectName("materialEditorStatus"); + auto *saveButton = new QPushButton("Save", header); + saveButton->setObjectName("materialSaveButton"); + saveButton->setIcon( + styling::icon(styling::Icon::FloppyDisk, "#A1957D")); + auto *assignButton = new QPushButton("Assign to Selected", header); + assignButton->setObjectName("materialAssignButton"); + assignButton->setIcon( + styling::icon(styling::Icon::Assign, "#9E897D")); + headerLayout->addWidget(titleLabel, 1); + headerLayout->addWidget(statusLabel); + headerLayout->addWidget(assignButton); + headerLayout->addWidget(saveButton); + layout->addWidget(header); + + auto *scroll = new QScrollArea(this); + scroll->setObjectName("materialEditorScroll"); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + body = new QWidget(scroll); + body->setObjectName("materialEditorBody"); + bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(10, 10, 10, 12); + bodyLayout->setSpacing(9); + scroll->setWidget(body); + layout->addWidget(scroll, 1); + + saveTimer = new QTimer(this); + saveTimer->setSingleShot(true); + saveTimer->setInterval(260); + connect(saveTimer, &QTimer::timeout, this, + &MaterialEditorPanel::saveMaterial); + connect(saveButton, &QPushButton::clicked, this, + &MaterialEditorPanel::saveMaterial); + connect(assignButton, &QPushButton::clicked, this, + &MaterialEditorPanel::assignToSelectedObject); + showEmptyState(); +} + +MaterialEditorPanel::~MaterialEditorPanel() { + if (saveTimer->isActive()) { + saveMaterial(); + } +} + +QJsonObject +MaterialEditorPanel::normalizedMaterial(const QJsonObject &source) const { + QJsonObject result = source; + if (!result.value("albedo").isArray()) + result.insert("albedo", QJsonArray{0.8, 0.8, 0.8, 1.0}); + if (!result.value("metallic").isDouble()) + result.insert("metallic", 0.0); + if (!result.value("roughness").isDouble()) + result.insert("roughness", 0.5); + if (!result.value("ao").isDouble()) + result.insert("ao", 1.0); + if (!result.value("reflectivity").isDouble()) + result.insert("reflectivity", 0.5); + if (!result.value("emissiveColor").isArray()) + result.insert("emissiveColor", QJsonArray{0.0, 0.0, 0.0, 1.0}); + if (!result.value("emissiveIntensity").isDouble()) + result.insert("emissiveIntensity", 0.0); + if (!result.value("normalMapStrength").isDouble()) + result.insert("normalMapStrength", 1.0); + if (!result.value("useNormalMap").isBool()) + result.insert("useNormalMap", true); + if (!result.value("transmittance").isDouble()) + result.insert("transmittance", 0.0); + if (!result.value("ior").isDouble()) + result.insert("ior", 1.45); + return result; +} + +void MaterialEditorPanel::openMaterial(const QString &path) { + if (saveTimer->isActive()) { + saveMaterial(); + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Material Editor", + "The material could not be opened."); + return; + } + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + QMessageBox::warning(this, "Material Editor", + "The material file is not valid JSON."); + return; + } + materialPath = QFileInfo(path).absoluteFilePath(); + assignedObjectId = -1; + undoHistory.clear(); + redoHistory.clear(); + const QJsonObject root = document.object(); + material = normalizedMaterial(root.value("material").isObject() + ? root.value("material").toObject() + : root); + showMaterial(); +} + +void MaterialEditorPanel::rebuildBody() { + while (QLayoutItem *item = bodyLayout->takeAt(0)) { + if (item->widget() != nullptr) + item->widget()->deleteLater(); + delete item; + } + preview = nullptr; + textureFields.clear(); + texturePreviews.clear(); +} + +void MaterialEditorPanel::showEmptyState() { + rebuildBody(); + titleLabel->setText("Material Editor"); + statusLabel->clear(); + auto *empty = new QLabel( + "Double-click a material in the Content Browser to edit it.", body); + empty->setObjectName("materialEditorEmpty"); + empty->setAlignment(Qt::AlignCenter); + empty->setWordWrap(true); + bodyLayout->addWidget(empty, 1); +} + +void MaterialEditorPanel::showMaterial() { + rebuildBody(); + loading = true; + titleLabel->setText(QFileInfo(materialPath).completeBaseName()); + statusLabel->setText("Ready"); + + auto *splitter = new QSplitter(Qt::Horizontal, body); + splitter->setChildrenCollapsible(false); + auto *previewPane = new QWidget(splitter); + previewPane->setMinimumWidth(1); + previewPane->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + auto *previewLayout = new QVBoxLayout(previewPane); + previewLayout->setContentsMargins(0, 0, 5, 0); + previewLayout->setSpacing(8); + preview = new MaterialPreviewWidget(previewPane); + preview->setMaterial(material, QFileInfo(materialPath).absolutePath()); + auto *previewOptions = new QWidget(previewPane); + auto *previewOptionsLayout = new QHBoxLayout(previewOptions); + previewOptionsLayout->setContentsMargins(0, 0, 0, 0); + auto *previewLabel = new QLabel("Preview Environment", previewOptions); + auto *environment = new QComboBox(previewOptions); + environment->addItems({"Studio", "Sunset", "Open Sky"}); + previewOptionsLayout->addWidget(previewLabel); + previewOptionsLayout->addStretch(); + previewOptionsLayout->addWidget(environment); + previewLayout->addWidget(preview, 1); + previewLayout->addWidget(previewOptions); + connect(environment, &QComboBox::currentIndexChanged, preview, + &MaterialPreviewWidget::setEnvironmentMode); + + auto *propertiesScroll = new QScrollArea(splitter); + propertiesScroll->setObjectName("materialPropertiesScroll"); + propertiesScroll->setWidgetResizable(true); + propertiesScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto *properties = new QWidget(propertiesScroll); + properties->setMinimumWidth(1); + properties->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + auto *propertiesLayout = new QVBoxLayout(properties); + propertiesLayout->setContentsMargins(5, 0, 0, 0); + propertiesLayout->setSpacing(9); + propertiesScroll->setWidget(properties); + splitter->addWidget(previewPane); + splitter->addWidget(propertiesScroll); + splitter->setStretchFactor(0, 3); + splitter->setStretchFactor(1, 2); + bodyLayout->addWidget(splitter, 1); + + auto *surface = new QGroupBox("Surface", properties); + auto *surfaceForm = new QFormLayout(surface); + albedoButton = new QPushButton(surface); + displayColor(albedoButton, + jsonColor(material.value("albedo"), QColor(204, 204, 204))); + metallicField = scalarField(0.0, 1.0, 0.01, surface); + roughnessField = scalarField(0.02, 1.0, 0.01, surface); + aoField = scalarField(0.0, 1.0, 0.01, surface); + reflectivityField = scalarField(0.0, 1.0, 0.01, surface); + metallicField->setValue(material.value("metallic").toDouble()); + roughnessField->setValue(material.value("roughness").toDouble()); + aoField->setValue(material.value("ao").toDouble()); + reflectivityField->setValue(material.value("reflectivity").toDouble()); + surfaceForm->addRow("Base Color", albedoButton); + surfaceForm->addRow("Metallic", metallicField); + surfaceForm->addRow("Roughness", roughnessField); + surfaceForm->addRow("Ambient Occlusion", aoField); + surfaceForm->addRow("Reflectivity", reflectivityField); + propertiesLayout->addWidget(surface); + + auto *emission = new QGroupBox("Emission", properties); + auto *emissionForm = new QFormLayout(emission); + emissiveButton = new QPushButton(emission); + displayColor(emissiveButton, + jsonColor(material.value("emissiveColor"), Qt::black)); + emissiveIntensityField = scalarField(0.0, 100.0, 0.1, emission); + emissiveIntensityField->setValue( + material.value("emissiveIntensity").toDouble()); + emissionForm->addRow("Color", emissiveButton); + emissionForm->addRow("Strength", emissiveIntensityField); + propertiesLayout->addWidget(emission); + + auto *volume = new QGroupBox("Transmission", properties); + auto *volumeForm = new QFormLayout(volume); + transmittanceField = scalarField(0.0, 1.0, 0.01, volume); + iorField = scalarField(1.0, 3.0, 0.01, volume); + transmittanceField->setValue( + material.value("transmittance").toDouble()); + iorField->setValue(material.value("ior").toDouble()); + volumeForm->addRow("Weight", transmittanceField); + volumeForm->addRow("IOR", iorField); + propertiesLayout->addWidget(volume); + + auto *normal = new QGroupBox("Normal", properties); + auto *normalForm = new QFormLayout(normal); + normalMapField = new QCheckBox(normal); + normalMapField->setChecked(material.value("useNormalMap").toBool()); + normalStrengthField = scalarField(0.0, 4.0, 0.05, normal); + normalStrengthField->setValue( + material.value("normalMapStrength").toDouble()); + normalForm->addRow("Use Normal Map", normalMapField); + normalForm->addRow("Strength", normalStrengthField); + propertiesLayout->addWidget(normal); + + auto *textures = new QGroupBox("Texture Slots", properties); + auto *textureLayout = new QVBoxLayout(textures); + const QList> materialSlots{ + {"Base Color", "albedoTexture"}, {"Normal", "normalTexture"}, + {"Metallic", "metallicTexture"}, {"Roughness", "roughnessTexture"}, + {"Ambient Occlusion", "aoTexture"}, {"Opacity", "opacityTexture"}}; + for (const auto &[label, key] : materialSlots) { + auto *row = new QWidget(textures); + row->setObjectName("materialTextureSlot"); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(6, 5, 6, 5); + rowLayout->setSpacing(6); + auto *thumbnail = new QLabel(row); + thumbnail->setObjectName("materialTexturePreview"); + thumbnail->setFixedSize(38, 38); + auto *field = new QLineEdit(row); + field->setObjectName("materialTexturePath"); + field->setReadOnly(true); + field->setPlaceholderText("No image"); + auto *choose = new QToolButton(row); + choose->setText("Choose…"); + choose->setIcon( + styling::icon(styling::Icon::FolderOpen, "#7E929C")); + choose->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + auto *clear = new QToolButton(row); + clear->setIcon(styling::icon(styling::Icon::Close, "#A17F7F")); + clear->setToolTip("Remove texture"); + auto *identity = new QWidget(row); + auto *identityLayout = new QVBoxLayout(identity); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->setSpacing(2); + auto *name = new QLabel(label, identity); + name->setObjectName("materialTextureLabel"); + identityLayout->addWidget(name); + identityLayout->addWidget(field); + rowLayout->addWidget(thumbnail); + rowLayout->addWidget(identity, 1); + rowLayout->addWidget(choose); + rowLayout->addWidget(clear); + textureFields.insert(key, field); + texturePreviews.insert(key, thumbnail); + textureLayout->addWidget(row); + connect(choose, &QToolButton::clicked, this, + [this, key] { chooseTexture(key); }); + connect(clear, &QToolButton::clicked, this, + [this, key] { clearTexture(key); }); + updateTextureField(key); + } + propertiesLayout->addWidget(textures); + propertiesLayout->addStretch(); + + connect(albedoButton, &QPushButton::clicked, this, + [this] { setColor("albedo", albedoButton); }); + connect(emissiveButton, &QPushButton::clicked, this, + [this] { setColor("emissiveColor", emissiveButton); }); + const QList scalars{ + metallicField, roughnessField, aoField, + reflectivityField, emissiveIntensityField, + normalStrengthField, transmittanceField, + iorField}; + for (QDoubleSpinBox *field : scalars) { + connect(field, &QDoubleSpinBox::valueChanged, this, + [this](double) { materialChanged(); }); + } + connect(normalMapField, &QCheckBox::toggled, this, + [this](bool) { materialChanged(); }); + loading = false; +} + +void MaterialEditorPanel::setColor(const QString &key, QPushButton *button) { + const QColor initial = button->property("materialColor").value(); + const QColor color = QColorDialog::getColor( + initial, this, "Choose Material Color", QColorDialog::ShowAlphaChannel); + if (!color.isValid()) + return; + const QJsonObject previous = material; + displayColor(button, color); + material.insert(key, colorJson(color)); + recordHistory(previous); + refreshEditedMaterial(); +} + +void MaterialEditorPanel::chooseTexture(const QString &key) { + const QString selected = QFileDialog::getOpenFileName( + this, "Choose Texture", QFileInfo(materialPath).absolutePath(), + "Images (*.png *.jpg *.jpeg *.tga *.bmp *.hdr *.exr);;All Files (*)"); + if (selected.isEmpty()) + return; + const QJsonObject previous = material; + const QDir materialDir(QFileInfo(materialPath).absolutePath()); + material.insert(key, materialDir.relativeFilePath(selected)); + updateTextureField(key); + recordHistory(previous); + refreshEditedMaterial(); +} + +void MaterialEditorPanel::clearTexture(const QString &key) { + if (!material.contains(key)) + return; + const QJsonObject previous = material; + material.remove(key); + updateTextureField(key); + recordHistory(previous); + refreshEditedMaterial(); +} + +void MaterialEditorPanel::updateTextureField(const QString &key) { + QLineEdit *field = textureFields.value(key); + QLabel *thumbnail = texturePreviews.value(key); + if (field == nullptr || thumbnail == nullptr) + return; + const QString path = texturePath(material.value(key)); + field->setText(path); + const QImage image = loadTextureImage( + QFileInfo(materialPath).absolutePath(), material.value(key)); + if (image.isNull()) { + thumbnail->setPixmap(QPixmap()); + thumbnail->setText(path.isEmpty() ? "" : "!"); + } else { + thumbnail->clear(); + thumbnail->setPixmap(QPixmap::fromImage(image).scaled( + thumbnail->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } +} + +void MaterialEditorPanel::materialChanged() { + if (loading || materialPath.isEmpty()) + return; + const QJsonObject previous = material; + material.insert("metallic", metallicField->value()); + material.insert("roughness", roughnessField->value()); + material.insert("ao", aoField->value()); + material.insert("reflectivity", reflectivityField->value()); + material.insert("emissiveIntensity", emissiveIntensityField->value()); + material.insert("normalMapStrength", normalStrengthField->value()); + material.insert("useNormalMap", normalMapField->isChecked()); + material.insert("transmittance", transmittanceField->value()); + material.insert("ior", iorField->value()); + if (material == previous) + return; + recordHistory(previous); + refreshEditedMaterial(); +} + +void MaterialEditorPanel::refreshEditedMaterial() { + preview->setMaterial(material, QFileInfo(materialPath).absolutePath()); + statusLabel->setText("Saving…"); + saveTimer->start(); +} + +void MaterialEditorPanel::recordHistory(const QJsonObject &previous) { + if (previous == material) + return; + undoHistory.append(previous); + while (undoHistory.size() > 100) + undoHistory.removeFirst(); + redoHistory.clear(); +} + +void MaterialEditorPanel::saveMaterial() { + if (materialPath.isEmpty()) + return; + saveTimer->stop(); + QSaveFile file(materialPath); + if (!file.open(QIODevice::WriteOnly)) { + statusLabel->setText("Save failed"); + return; + } + QJsonObject root; + root.insert("material", material); + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!file.commit()) { + statusLabel->setText("Save failed"); + return; + } + statusLabel->setText("Saved"); + if (assignedObjectId >= 0 && viewport != nullptr) { + viewport->applyRuntimeMaterial(assignedObjectId, materialPath); + } + emit materialSaved(materialPath); +} + +void MaterialEditorPanel::assignToSelectedObject() { + if (materialPath.isEmpty() || viewport == nullptr) { + return; + } + const int objectId = viewport->selectedRuntimeObjectId(); + if (objectId < 0) { + QMessageBox::information( + this, "Assign Material", + "Select a renderable object in the Hierarchy or Viewport first."); + return; + } + saveMaterial(); + if (!viewport->applyRuntimeMaterial(objectId, materialPath)) { + QMessageBox::warning( + this, "Assign Material", + "This material can only be assigned to a solid or model object."); + return; + } + assignedObjectId = objectId; + statusLabel->setText("Assigned · live updates enabled"); +} + +void MaterialEditorPanel::undo() { + if (undoHistory.isEmpty() || materialPath.isEmpty()) + return; + redoHistory.append(material); + material = undoHistory.takeLast(); + showMaterial(); + saveMaterial(); +} + +void MaterialEditorPanel::redo() { + if (redoHistory.isEmpty() || materialPath.isEmpty()) + return; + undoHistory.append(material); + material = redoHistory.takeLast(); + showMaterial(); + saveMaterial(); +} diff --git a/editor/views/editor/postProcessing.cpp b/editor/views/editor/postProcessing.cpp new file mode 100644 index 00000000..1e86d178 --- /dev/null +++ b/editor/views/editor/postProcessing.cpp @@ -0,0 +1,467 @@ +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QString effectTitle(const QString &type) { + QString result; + for (int index = 0; index < type.size(); ++index) { + const QChar character = type.at(index); + if (character == '_' || character == '-') { + result += ' '; + } else if (index > 0 && character.isUpper() && + type.at(index - 1).isLower()) { + result += ' '; + result += character; + } else { + result += index == 0 ? character.toUpper() : character; + } + } + return result; +} + +QJsonObject effectDefaults(const QString &type) { + const QString normalized = type.toLower().remove('_').remove('-'); + if (normalized == "blur") + return {{"type", "blur"}, {"magnitude", 16.0}}; + if (normalized == "colorcorrection") + return {{"type", "color_correction"}, + {"exposure", 1.0}, + {"contrast", 1.0}, + {"saturation", 1.0}, + {"gamma", 1.0}, + {"temperature", 0.0}, + {"tint", 0.0}}; + if (normalized == "motionblur") + return {{"type", "motion_blur"}, {"size", 8}, {"separation", 1.0}}; + if (normalized == "chromaticaberration") + return {{"type", "chromatic_aberration"}, + {"red", 0.01}, + {"green", 0.0}, + {"blue", -0.01}, + {"direction", QJsonArray{1.0, 0.0}}}; + if (normalized == "posterization") + return {{"type", "posterization"}, {"levels", 8.0}}; + if (normalized == "pixelation") + return {{"type", "pixelation"}, {"pixelSize", 4}}; + if (normalized == "dilation") + return {{"type", "dilation"}, {"size", 3}, {"separation", 1.0}}; + if (normalized == "filmgrain") + return {{"type", "film_grain"}, {"amount", 0.15}}; + return {{"type", type}}; +} + +QDoubleSpinBox *effectNumber(double value, QWidget *parent) { + auto *field = new ScrubbableDoubleSpinBox(parent); + field->setRange(-10000.0, 10000.0); + field->setDecimals(3); + field->setSingleStep(0.05); + field->setValue(value); + field->setKeyboardTracking(true); + return field; +} +} + +PostProcessingPanel::PostProcessingPanel(ViewportPanel *viewport, + QWidget *parent) + : QWidget(parent), viewport(viewport) { + setObjectName("postProcessingPanel"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *toolbar = new QWidget(this); + toolbar->setObjectName("postProcessingToolbar"); + auto *toolbarLayout = new QHBoxLayout(toolbar); + toolbarLayout->setContentsMargins(8, 4, 8, 4); + toolbarLayout->setSpacing(4); + auto *title = new QLabel("Post Processing", toolbar); + title->setObjectName("postProcessingTitle"); + targetSelector = new QComboBox(toolbar); + targetSelector->setMinimumWidth(180); + auto *addTargetButton = new QToolButton(toolbar); + addTargetButton->setIcon( + styling::icon(styling::Icon::Plus, "#8498A8")); + addTargetButton->setText("Target"); + addTargetButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + removeTargetButton = new QToolButton(toolbar); + removeTargetButton->setText("Remove"); + removeTargetButton->setIcon( + styling::icon(styling::Icon::Trash, "#A17F7F")); + auto *applyButton = new QPushButton("Apply to Preview", toolbar); + applyButton->setIcon( + styling::icon(styling::Icon::Sparkle, "#849589")); + statusLabel = new QLabel(toolbar); + statusLabel->setObjectName("postProcessingStatus"); + toolbarLayout->addWidget(title); + toolbarLayout->addWidget(targetSelector); + toolbarLayout->addWidget(addTargetButton); + toolbarLayout->addWidget(removeTargetButton); + toolbarLayout->addStretch(); + toolbarLayout->addWidget(statusLabel); + toolbarLayout->addWidget(applyButton); + layout->addWidget(toolbar); + + auto *scroll = new QScrollArea(this); + scroll->setObjectName("postProcessingScroll"); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + body = new QWidget(scroll); + body->setObjectName("postProcessingBody"); + bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(12, 12, 12, 14); + bodyLayout->setSpacing(9); + scroll->setWidget(body); + layout->addWidget(scroll, 1); + + connect(targetSelector, &QComboBox::currentIndexChanged, this, + [this](int index) { + targetIndex = index; + rebuildEditor(); + }); + connect(addTargetButton, &QToolButton::clicked, this, + &PostProcessingPanel::addTarget); + connect(removeTargetButton, &QToolButton::clicked, this, + &PostProcessingPanel::removeTarget); + connect(applyButton, &QPushButton::clicked, this, [this] { + if (this->viewport != nullptr) { + statusLabel->setText("Reloading…"); + this->viewport->reloadRuntime(); + } + }); + if (viewport != nullptr) { + connect(viewport, &ViewportPanel::sceneSnapshotChanged, this, + &PostProcessingPanel::applySceneSnapshot); + connect(viewport, &ViewportPanel::runtimeAvailabilityChanged, this, + [this](bool available) { + if (available) + statusLabel->setText("Preview up to date"); + }); + } + rebuildTargetList(); +} + +void PostProcessingPanel::applySceneSnapshot(const QString &snapshot) { + const QJsonDocument document = QJsonDocument::fromJson(snapshot.toUtf8()); + if (!document.isObject()) + return; + const QJsonArray nextTargets = document.object().value("targets").toArray(); + if (nextTargets == targets) + return; + targets = nextTargets; + if (!applying) { + rebuildTargetList(); + } +} + +void PostProcessingPanel::rebuildTargetList() { + const int previous = targetIndex; + targetSelector->blockSignals(true); + targetSelector->clear(); + for (int index = 0; index < targets.size(); ++index) { + const QJsonObject target = targets.at(index).toObject(); + targetSelector->addItem( + target.value("name").toString( + QStringLiteral("Render Target %1").arg(index + 1))); + } + targetIndex = targets.isEmpty() + ? -1 + : std::clamp(previous, 0, + static_cast(targets.size()) - 1); + targetSelector->setCurrentIndex(targetIndex); + targetSelector->blockSignals(false); + removeTargetButton->setEnabled(targetIndex >= 0); + rebuildEditor(); +} + +void PostProcessingPanel::rebuildEditor() { + while (QLayoutItem *item = bodyLayout->takeAt(0)) { + if (item->widget() != nullptr) + item->widget()->deleteLater(); + delete item; + } + if (targetIndex < 0 || targetIndex >= targets.size()) { + auto *empty = new QLabel( + "Create a render target to build a post-processing stack.", body); + empty->setObjectName("postProcessingEmpty"); + empty->setAlignment(Qt::AlignCenter); + bodyLayout->addWidget(empty, 1); + return; + } + + const QJsonObject target = targets.at(targetIndex).toObject(); + auto *settings = new QGroupBox("Render Target", body); + auto *form = new QFormLayout(settings); + auto *name = new QLineEdit(target.value("name").toString(), settings); + auto *type = new QComboBox(settings); + type->addItems({"scene", "multisampled"}); + type->setCurrentText(target.value("type").toString("scene")); + auto *render = new QCheckBox(settings); + render->setChecked(target.value("render").toBool(true)); + auto *display = new QCheckBox(settings); + display->setChecked(target.value("display").toBool(true)); + form->addRow("Name", name); + form->addRow("Type", type); + form->addRow("Render Scene", render); + form->addRow("Display Output", display); + bodyLayout->addWidget(settings); + connect(name, &QLineEdit::editingFinished, this, + [this, name] { setTargetValue("/name", name->text()); }); + connect(type, &QComboBox::currentTextChanged, this, + [this](const QString &value) { setTargetValue("/type", value); }); + connect(render, &QCheckBox::toggled, this, + [this](bool value) { setTargetValue("/render", value); }); + connect(display, &QCheckBox::toggled, this, + [this](bool value) { setTargetValue("/display", value); }); + + auto *effectsHeading = new QWidget(body); + auto *effectsHeadingLayout = new QHBoxLayout(effectsHeading); + effectsHeadingLayout->setContentsMargins(0, 4, 0, 0); + auto *effectsTitle = new QLabel("Effect Stack", effectsHeading); + effectsTitle->setObjectName("postProcessingSectionTitle"); + auto *addEffectButton = new QToolButton(effectsHeading); + addEffectButton->setIcon( + styling::icon(styling::Icon::Plus, "#8498A8")); + addEffectButton->setText("Add Effect"); + addEffectButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + addEffectButton->setPopupMode(QToolButton::InstantPopup); + auto *effectMenu = new QMenu(addEffectButton); + const QStringList effects{ + "inversion", "grayscale", "sharpen", + "blur", "edge_detection", "color_correction", + "motion_blur", "chromatic_aberration", + "posterization", "pixelation", "dilation", + "film_grain"}; + for (const QString &effect : effects) { + effectMenu->addAction( + styling::icon(styling::Icon::Sparkle, "#849589"), + effectTitle(effect), this, + [this, effect] { addEffect(effect); }); + } + addEffectButton->setMenu(effectMenu); + effectsHeadingLayout->addWidget(effectsTitle); + effectsHeadingLayout->addStretch(); + effectsHeadingLayout->addWidget(addEffectButton); + bodyLayout->addWidget(effectsHeading); + + const QJsonArray effectStack = target.value("effects").toArray(); + for (int effectIndex = 0; effectIndex < effectStack.size(); ++effectIndex) { + const QJsonObject effect = effectStack.at(effectIndex).toObject(); + const QString effectType = effect.value("type").toString("effect"); + auto *group = new QGroupBox(effectTitle(effectType), body); + auto *groupLayout = new QVBoxLayout(group); + auto *effectForm = new QFormLayout(); + groupLayout->addLayout(effectForm); + for (auto iterator = effect.begin(); iterator != effect.end(); + ++iterator) { + if (iterator.key() == "type") + continue; + if (iterator.value().isDouble()) { + const bool integral = iterator.key() == "size" || + iterator.key() == "pixelSize"; + if (integral) { + auto *field = new ScrubbableSpinBox(group); + field->setRange(1, 1024); + field->setValue(iterator.value().toInt()); + effectForm->addRow(effectTitle(iterator.key()), field); + connect(field, &QSpinBox::valueChanged, this, + [this, effectIndex, key = iterator.key()](int value) { + setEffectValue(effectIndex, key, value); + }); + } else { + auto *field = + effectNumber(iterator.value().toDouble(), group); + effectForm->addRow(effectTitle(iterator.key()), field); + connect(field, &QDoubleSpinBox::valueChanged, this, + [this, effectIndex, + key = iterator.key()](double value) { + setEffectValue(effectIndex, key, value); + }); + } + } else if (iterator.value().isArray() && + iterator.value().toArray().size() == 2) { + const QJsonArray vector = iterator.value().toArray(); + auto *vectorEditor = new QWidget(group); + auto *vectorLayout = new QHBoxLayout(vectorEditor); + vectorLayout->setContentsMargins(0, 0, 0, 0); + auto *x = effectNumber(vector.at(0).toDouble(), vectorEditor); + auto *y = effectNumber(vector.at(1).toDouble(), vectorEditor); + vectorLayout->addWidget(x); + vectorLayout->addWidget(y); + effectForm->addRow(effectTitle(iterator.key()), vectorEditor); + auto commit = [this, effectIndex, key = iterator.key(), x, y] { + setEffectValue(effectIndex, key, + QJsonArray{x->value(), y->value()}); + }; + connect(x, &QDoubleSpinBox::valueChanged, this, + [commit](double) { commit(); }); + connect(y, &QDoubleSpinBox::valueChanged, this, + [commit](double) { commit(); }); + } + } + auto *actions = new QWidget(group); + auto *actionsLayout = new QHBoxLayout(actions); + actionsLayout->setContentsMargins(0, 0, 0, 0); + actionsLayout->setSpacing(4); + auto *moveUp = new QToolButton(actions); + moveUp->setText("Move Up"); + moveUp->setIcon( + styling::icon(styling::Icon::CaretUp, "#7E929C")); + moveUp->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + moveUp->setEnabled(effectIndex > 0); + auto *moveDown = new QToolButton(actions); + moveDown->setText("Move Down"); + moveDown->setIcon( + styling::icon(styling::Icon::CaretDown, "#7E929C")); + moveDown->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + moveDown->setEnabled(effectIndex + 1 < effectStack.size()); + auto *remove = new QToolButton(actions); + remove->setText("Remove Effect"); + remove->setIcon( + styling::icon(styling::Icon::Trash, "#A17F7F")); + remove->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + actionsLayout->addStretch(); + actionsLayout->addWidget(moveUp); + actionsLayout->addWidget(moveDown); + actionsLayout->addWidget(remove); + groupLayout->addWidget(actions); + connect(moveUp, &QToolButton::clicked, this, + [this, effectIndex] { moveEffect(effectIndex, -1); }); + connect(moveDown, &QToolButton::clicked, this, + [this, effectIndex] { moveEffect(effectIndex, 1); }); + connect(remove, &QToolButton::clicked, this, + [this, effectIndex] { removeEffect(effectIndex); }); + bodyLayout->addWidget(group); + } + bodyLayout->addStretch(); +} + +void PostProcessingPanel::addTarget() { + const int number = targets.size() + 1; + targets.append(QJsonObject{{"name", QStringLiteral("Render Target %1").arg(number)}, + {"type", "scene"}, + {"render", true}, + {"display", targets.isEmpty()}, + {"effects", QJsonArray{}}}); + targetIndex = targets.size() - 1; + replaceTargets(); + rebuildTargetList(); +} + +void PostProcessingPanel::removeTarget() { + if (targetIndex < 0 || targetIndex >= targets.size()) + return; + targets.removeAt(targetIndex); + targetIndex = + std::min(targetIndex, static_cast(targets.size()) - 1); + replaceTargets(); + rebuildTargetList(); +} + +void PostProcessingPanel::addEffect(const QString &type) { + if (targetIndex < 0 || targetIndex >= targets.size()) + return; + QJsonObject target = targets.at(targetIndex).toObject(); + QJsonArray effects = target.value("effects").toArray(); + effects.append(effectDefaults(type)); + target.insert("effects", effects); + targets.replace(targetIndex, target); + setTargetValue("/effects", effects); + rebuildEditor(); +} + +void PostProcessingPanel::removeEffect(int effectIndex) { + if (targetIndex < 0 || targetIndex >= targets.size()) + return; + QJsonObject target = targets.at(targetIndex).toObject(); + QJsonArray effects = target.value("effects").toArray(); + if (effectIndex < 0 || effectIndex >= effects.size()) + return; + effects.removeAt(effectIndex); + target.insert("effects", effects); + targets.replace(targetIndex, target); + setTargetValue("/effects", effects); + rebuildEditor(); +} + +void PostProcessingPanel::moveEffect(int effectIndex, int offset) { + if (targetIndex < 0 || targetIndex >= targets.size()) + return; + QJsonObject target = targets.at(targetIndex).toObject(); + QJsonArray effects = target.value("effects").toArray(); + const int destination = effectIndex + offset; + if (effectIndex < 0 || effectIndex >= effects.size() || destination < 0 || + destination >= effects.size()) + return; + const QJsonValue effect = effects.at(effectIndex); + effects.removeAt(effectIndex); + effects.insert(destination, effect); + target.insert("effects", effects); + targets.replace(targetIndex, target); + setTargetValue("/effects", effects); + rebuildEditor(); +} + +void PostProcessingPanel::setTargetValue(const QString &path, + const QJsonValue &value) { + if (targetIndex < 0 || targetIndex >= targets.size() || viewport == nullptr) + return; + QJsonObject target = targets.at(targetIndex).toObject(); + target.insert(path.mid(1), value); + targets.replace(targetIndex, target); + applying = true; + viewport->setRuntimeSceneProperty("targets", targetIndex, path, value); + applying = false; + statusLabel->setText("Saved · apply to update preview"); + if (path == "/name") + rebuildTargetList(); +} + +void PostProcessingPanel::setEffectValue(int effectIndex, const QString &key, + const QJsonValue &value) { + if (targetIndex < 0 || targetIndex >= targets.size()) + return; + QJsonObject target = targets.at(targetIndex).toObject(); + QJsonArray effects = target.value("effects").toArray(); + if (effectIndex < 0 || effectIndex >= effects.size()) + return; + QJsonObject effect = effects.at(effectIndex).toObject(); + effect.insert(key, value); + effects.replace(effectIndex, effect); + target.insert("effects", effects); + targets.replace(targetIndex, target); + setTargetValue("/effects", effects); +} + +void PostProcessingPanel::replaceTargets() { + if (viewport == nullptr) + return; + applying = true; + viewport->setRuntimeSceneProperty("targets", -1, QString(), targets); + applying = false; + statusLabel->setText("Saved · apply to update preview"); +} diff --git a/editor/views/editor/viewport.cpp b/editor/views/editor/viewport.cpp new file mode 100644 index 00000000..9163a3ed --- /dev/null +++ b/editor/views/editor/viewport.cpp @@ -0,0 +1,1430 @@ +/* + * viewport.cpp + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Viewport definitions + * 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 +#include + +namespace { +constexpr int RuntimeEditorCameraKeyForward = 0; +constexpr int RuntimeEditorCameraKeyBackward = 1; +constexpr int RuntimeEditorCameraKeyLeft = 2; +constexpr int RuntimeEditorCameraKeyRight = 3; +constexpr int RuntimeEditorCameraKeyUp = 4; +constexpr int RuntimeEditorCameraKeyDown = 5; + +int runtimeMouseButton(Qt::MouseButton button) { + switch (button) { + case Qt::LeftButton: + return static_cast(MouseButton::Left); + case Qt::MiddleButton: + return static_cast(MouseButton::Middle); + case Qt::RightButton: + return static_cast(MouseButton::Right); + case Qt::BackButton: + return static_cast(MouseButton::Button4); + case Qt::ForwardButton: + return static_cast(MouseButton::Button5); + default: + return 0; + } +} + +int activeRuntimeMouseButton(Qt::MouseButtons buttons, + int rightDragRuntimeButton) { + if (buttons.testFlag(Qt::RightButton)) { + return rightDragRuntimeButton; + } + if (buttons.testFlag(Qt::MiddleButton)) { + return runtimeMouseButton(Qt::MiddleButton); + } + if (buttons.testFlag(Qt::LeftButton)) { + return runtimeMouseButton(Qt::LeftButton); + } + return runtimeMouseButton(Qt::LeftButton); +} + +int editorCameraKey(int key) { + switch (key) { + case Qt::Key_Up: + return RuntimeEditorCameraKeyForward; + case Qt::Key_Down: + return RuntimeEditorCameraKeyBackward; + case Qt::Key_Left: + return RuntimeEditorCameraKeyLeft; + case Qt::Key_Right: + return RuntimeEditorCameraKeyRight; + case Qt::Key_PageUp: + return RuntimeEditorCameraKeyUp; + case Qt::Key_PageDown: + return RuntimeEditorCameraKeyDown; + default: + return -1; + } +} + +float widgetScale(QWidget *widget) { + const qreal scale = widget != nullptr ? widget->devicePixelRatioF() : 1.0; + return scale > 0.0 ? static_cast(scale) : 1.0f; +} + +QJsonObject findSnapshotObject(const QJsonArray &objects, int id) { + for (const QJsonValue &entry : objects) { + const QJsonObject object = entry.toObject(); + if (object.value("id").toInt(-1) == id) { + return object; + } + const QJsonObject child = + findSnapshotObject(object.value("children").toArray(), id); + if (!child.isEmpty()) { + return child; + } + } + return {}; +} + +QJsonObject findSnapshotObjectByName(const QJsonArray &objects, + const QString &name) { + for (const QJsonValue &entry : objects) { + const QJsonObject object = entry.toObject(); + if (object.value("name").toString() == name) + return object; + const QJsonObject child = findSnapshotObjectByName( + object.value("children").toArray(), name); + if (!child.isEmpty()) + return child; + } + return {}; +} + +QString decodePointerSegment(QString segment) { + return segment.replace("~1", "/").replace("~0", "~"); +} + +QJsonValue snapshotValueAt(QJsonValue value, const QString &path) { + for (const QString &raw : path.split('/', Qt::SkipEmptyParts)) { + const QString segment = decodePointerSegment(raw); + if (value.isObject()) { + value = value.toObject().value(segment); + } else if (value.isArray()) { + bool valid = false; + const int index = segment.toInt(&valid); + const QJsonArray array = value.toArray(); + if (!valid || index < 0 || index >= array.size()) { + return QJsonValue(QJsonValue::Undefined); + } + value = array.at(index); + } else { + return QJsonValue(QJsonValue::Undefined); + } + } + return value; +} + +class RuntimePropertyCommand : public QUndoCommand { + public: + RuntimePropertyCommand(ViewportPanel *viewport, int objectId, + QString component, int componentIndex, QString path, + QJsonValue before, QJsonValue after, + QUndoCommand *parent = nullptr) + : QUndoCommand(parent), viewport(viewport), objectId(objectId), + component(std::move(component)), componentIndex(componentIndex), + path(std::move(path)), before(std::move(before)), + after(std::move(after)) { + setText(QStringLiteral("Edit %1").arg(this->component)); + } + + void undo() override { + if (viewport != nullptr) { + viewport->applyRuntimeObjectProperty( + objectId, component, componentIndex, path, before); + } + } + + void redo() override { + if (viewport != nullptr) { + viewport->applyRuntimeObjectProperty( + objectId, component, componentIndex, path, after); + } + } + + int id() const override { return 0x415450; } + + bool mergeWith(const QUndoCommand *other) override { + const auto *command = dynamic_cast(other); + if (command == nullptr || command->viewport != viewport || + command->objectId != objectId || command->component != component || + command->componentIndex != componentIndex || command->path != path) { + return false; + } + after = command->after; + return true; + } + + private: + QPointer viewport; + int objectId; + QString component; + int componentIndex; + QString path; + QJsonValue before; + QJsonValue after; +}; + +class RuntimeRenameCommand : public QUndoCommand { + public: + RuntimeRenameCommand(ViewportPanel *viewport, int objectId, QString before, + QString after) + : viewport(viewport), objectId(objectId), before(std::move(before)), + after(std::move(after)) { + setText("Rename Object"); + } + + void undo() override { + if (viewport != nullptr) + viewport->renameRuntimeObjectDirect(objectId, before); + } + + void redo() override { + if (viewport != nullptr) + viewport->renameRuntimeObjectDirect(objectId, after); + } + + private: + QPointer viewport; + int objectId; + QString before; + QString after; +}; +} + +ViewportPanel::ViewportPanel(const QString &projectFile, QWidget *parent) + : QWidget(parent), projectFile(projectFile) { + setAcceptDrops(true); + setAttribute(Qt::WA_DontCreateNativeAncestors); + setAttribute(Qt::WA_NativeWindow); + setAttribute(Qt::WA_NoSystemBackground); + setAttribute(Qt::WA_OpaquePaintEvent); + setAttribute(Qt::WA_PaintOnScreen); + setAutoFillBackground(false); + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); + setMinimumSize(1, 1); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + frameTimer = new QTimer(this); + resizeTimer = new QTimer(this); + environmentReloadTimer = new QTimer(this); + undoStack = new QUndoStack(this); + frameTimer->setTimerType(Qt::PreciseTimer); + resizeTimer->setSingleShot(true); + resizeTimer->setInterval(0); + environmentReloadTimer->setSingleShot(true); + environmentReloadTimer->setInterval(140); + connect(frameTimer, &QTimer::timeout, this, [this] { stepRuntime(); }); + connect(resizeTimer, &QTimer::timeout, this, + [this] { resizeRuntime(); }); + connect(environmentReloadTimer, &QTimer::timeout, this, + &ViewportPanel::reloadRuntime); + if (auto *app = QCoreApplication::instance()) { + connect(app, &QCoreApplication::aboutToQuit, this, + [this] { shutdownRuntime(); }); + } + +} + +ViewportPanel::~ViewportPanel() { shutdownRuntime(); } + +QSize ViewportPanel::sizeHint() const { return QSize(640, 360); } + +QSize ViewportPanel::minimumSizeHint() const { return QSize(1, 1); } + +QPaintEngine *ViewportPanel::paintEngine() const { return nullptr; } + +void ViewportPanel::setRuntimeStartupEnabled(bool enabled) { + runtimeStartupEnabled = enabled; + if (runtimeStartupEnabled) + scheduleRuntimeStart(); +} + +void ViewportPanel::showEvent(QShowEvent *event) { + QWidget::showEvent(event); + if (runtimeContext != nullptr) { + frameTimer->start(16); + return; + } + if (runtimeStartupEnabled) + scheduleRuntimeStart(); +} + +void ViewportPanel::hideEvent(QHideEvent *event) { + QWidget::hideEvent(event); +} + +void ViewportPanel::closeEvent(QCloseEvent *event) { + shutdownRuntime(); + QWidget::closeEvent(event); +} + +void ViewportPanel::dragEnterEvent(QDragEnterEvent *event) { + if (event->mimeData()->hasUrls()) { + const QString suffix = + QFileInfo(event->mimeData()->urls().constFirst().toLocalFile()) + .suffix() + .toLower(); + const bool model = suffix == "obj" || suffix == "fbx" || + suffix == "gltf" || suffix == "glb" || + suffix == "dae"; + if (model || + (selectedRuntimeObjectId() >= 0 && + (suffix == "amat" || suffix == "material" || suffix == "ts" || + suffix == "js" || suffix == "wav" || suffix == "mp3" || + suffix == "ogg" || suffix == "flac" || suffix == "m4a" || + suffix == "aac"))) { + event->acceptProposedAction(); + return; + } + } + event->ignore(); +} + +void ViewportPanel::dropEvent(QDropEvent *event) { + const QString path = + event->mimeData()->hasUrls() + ? event->mimeData()->urls().constFirst().toLocalFile() + : QString(); + const QString suffix = QFileInfo(path).suffix().toLower(); + if ((suffix == "obj" || suffix == "fbx" || suffix == "gltf" || + suffix == "glb" || suffix == "dae") && + importRuntimeModel(path)) { + event->acceptProposedAction(); + return; + } + const int objectId = selectedRuntimeObjectId(); + if (objectId >= 0 && event->mimeData()->hasUrls() && + attachRuntimeAsset( + objectId, + event->mimeData()->urls().constFirst().toLocalFile())) { + event->acceptProposedAction(); + emit runtimeObjectActivated(objectId); + return; + } + event->ignore(); +} + +void ViewportPanel::resizeEvent(QResizeEvent *event) { + QWidget::resizeEvent(event); + if (runtimeContext == nullptr && runtimeStartupEnabled) { + scheduleRuntimeStart(); + return; + } + if (!resizeTimer->isActive()) + resizeTimer->start(); +} + +void ViewportPanel::scheduleRuntimeStart() { + if (shuttingDown || runtimeContext != nullptr || runtimeStartQueued || + width() <= 1 || height() <= 1) { + return; + } + runtimeStartQueued = true; + QTimer::singleShot(0, this, [this] { + runtimeStartQueued = false; + if (shuttingDown) { + return; + } + if (runtimeContext == nullptr && width() > 1 && height() > 1) { + startRuntime(); + } + }); +} + +void ViewportPanel::shutdownRuntime() { + shuttingDown = true; + runtimeStartQueued = false; + if (resizeTimer != nullptr) + resizeTimer->stop(); + if (environmentReloadTimer != nullptr) + environmentReloadTimer->stop(); + stopRuntime(); +} + +void ViewportPanel::mousePressEvent(QMouseEvent *event) { + setFocus(Qt::MouseFocusReason); + if (keyboardTransformActive && + (event->button() == Qt::LeftButton || + event->button() == Qt::RightButton)) { + finishKeyboardTransform(event->button() == Qt::LeftButton); + event->accept(); + return; + } + if (event->button() == Qt::LeftButton) { + leftPointerMoved = false; + const int selected = selectedRuntimeObjectId(); + transformUndoBefore = + findSnapshotObject( + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()) + .object() + .value("objects") + .toArray(), + selected); + } + int pointerButton = runtimeMouseButton(event->button()); + if (event->button() == Qt::RightButton) { + rightDragRuntimeButton = + event->modifiers().testFlag(Qt::ShiftModifier) + ? runtimeMouseButton(Qt::RightButton) + : runtimeMouseButton(Qt::MiddleButton); + pointerButton = rightDragRuntimeButton; + } + sendPointerEvent(0, static_cast(event->position().x()), + static_cast(event->position().y()), pointerButton); + if (event->button() == Qt::LeftButton && runtimeContext != nullptr) { + emit runtimeObjectActivated(runtimeContext->selectedObjectId()); + } + event->accept(); +} + +void ViewportPanel::mouseMoveEvent(QMouseEvent *event) { + if (event->buttons().testFlag(Qt::LeftButton)) + leftPointerMoved = true; + sendPointerEvent(1, static_cast(event->position().x()), + static_cast(event->position().y()), + activeRuntimeMouseButton(event->buttons(), + rightDragRuntimeButton)); + if (keyboardTransformActive) { + const QRect bounds(mapToGlobal(QPoint(0, 0)), size()); + QPoint cursor = event->globalPosition().toPoint(); + bool wrapped = false; + if (cursor.x() <= bounds.left() + 2) { + cursor.setX(bounds.right() - 3); + wrapped = true; + } else if (cursor.x() >= bounds.right() - 2) { + cursor.setX(bounds.left() + 3); + wrapped = true; + } + if (cursor.y() <= bounds.top() + 2) { + cursor.setY(bounds.bottom() - 3); + wrapped = true; + } else if (cursor.y() >= bounds.bottom() - 2) { + cursor.setY(bounds.top() + 3); + wrapped = true; + } + if (wrapped) + QCursor::setPos(cursor); + } + event->accept(); +} + +void ViewportPanel::mouseReleaseEvent(QMouseEvent *event) { + const int pointerButton = event->button() == Qt::RightButton + ? rightDragRuntimeButton + : runtimeMouseButton(event->button()); + sendPointerEvent(2, static_cast(event->position().x()), + static_cast(event->position().y()), pointerButton); + if (event->button() == Qt::LeftButton && leftPointerMoved && + runtimeContext != nullptr && selectedRuntimeObjectId() >= 0) { + const int selected = selectedRuntimeObjectId(); + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + pushTransformUndo(selected, transformUndoBefore); + transformUndoBefore = {}; + setSceneDirty(true); + emit runtimeObjectActivated(selected); + } + leftPointerMoved = false; + if (event->button() == Qt::RightButton) + rightDragRuntimeButton = 0; + event->accept(); +} + +void ViewportPanel::wheelEvent(QWheelEvent *event) { + if (runtimeContext == nullptr) { + QWidget::wheelEvent(event); + return; + } + const float delta = static_cast(event->angleDelta().y()) / 120.0f; + if (std::abs(delta) > 0.0f) { + runtimeContext->editorScrollEvent(delta, widgetScale(this)); + } + event->accept(); +} + +void ViewportPanel::keyPressEvent(QKeyEvent *event) { + if (!event->isAutoRepeat() && runtimeContext != nullptr && + playbackState == 0) { + if (keyboardTransformActive) { + if (event->key() == Qt::Key_Escape) { + finishKeyboardTransform(false); + event->accept(); + return; + } + if (event->key() == Qt::Key_Return || + event->key() == Qt::Key_Enter) { + finishKeyboardTransform(true); + event->accept(); + return; + } + if (event->key() == Qt::Key_X || event->key() == Qt::Key_Y || + event->key() == Qt::Key_Z) { + updateKeyboardTransformAxes( + event->key(), + event->modifiers().testFlag(Qt::ShiftModifier)); + event->accept(); + return; + } + } else if (event->key() == Qt::Key_G || + event->key() == Qt::Key_R || + event->key() == Qt::Key_S) { + beginKeyboardTransform(event->key() == Qt::Key_G ? 1 + : event->key() == Qt::Key_R ? 2 + : 3); + event->accept(); + return; + } + } + const int key = editorCameraKey(event->key()); + if (event->isAutoRepeat()) { + if (key >= 0) { + event->accept(); + return; + } + QWidget::keyPressEvent(event); + return; + } + if (runtimeContext != nullptr && key >= 0) { + runtimeContext->editorKeyEvent(key, true); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} + +void ViewportPanel::keyReleaseEvent(QKeyEvent *event) { + const int key = editorCameraKey(event->key()); + if (event->isAutoRepeat()) { + if (key >= 0) { + event->accept(); + return; + } + QWidget::keyReleaseEvent(event); + return; + } + if (runtimeContext != nullptr && key >= 0) { + runtimeContext->editorKeyEvent(key, false); + event->accept(); + return; + } + QWidget::keyReleaseEvent(event); +} + +void ViewportPanel::startRuntime() { + if (shuttingDown || runtimeContext != nullptr || width() <= 1 || + height() <= 1) { + return; + } +#ifdef METAL + const std::string runtimeProjectFile = projectFile.toUtf8().toStdString(); + if (runtimeProjectFile.empty()) { + qWarning() << "Atlas viewport runtime project file is not configured"; + emit runtimeStartupFinished(false, + "Runtime project file is not configured"); + return; + } + + void *metalView = reinterpret_cast(static_cast(winId())); + if (metalView == nullptr) { + qWarning() << "Atlas viewport could not resolve a native Metal view"; + emit runtimeStartupFinished(false, + "Viewport native surface is unavailable"); + return; + } + + try { + runtimeContext = runtime::makeContextForMetalViewNonBlocking( + runtimeProjectFile, metalView); + runtimeContext->setEditorControlsEnabled(true); + runtimeContext->setEditorSimulationEnabled(false); + runtimeContext->setEditorControlMode(0); + runtimeContext->setEditorShadingMode(shadingMode); + resizeRuntime(); + refreshSceneSnapshot(); + if (!selectionToRestore.isEmpty()) { + const QJsonDocument document = + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()); + const QJsonObject restored = findSnapshotObjectByName( + document.object().value("objects").toArray(), + selectionToRestore); + const int restoredId = restored.value("id").toInt(-1); + selectionToRestore.clear(); + if (restoredId >= 0 && + runtimeContext->selectObject(restoredId, false)) { + refreshSceneSnapshot(); + emit runtimeObjectActivated(restoredId); + } + } + emit runtimeAvailabilityChanged(true); + playbackState = 0; + emit playbackStateChanged(playbackState); + frameTimer->start(16); + emit sceneOpened(currentRuntimeScene()); + emit runtimeStartupFinished(true, {}); + } catch (const std::exception &error) { + qWarning().noquote() + << QStringLiteral("Failed to start Atlas viewport runtime: %1") + .arg(QString::fromUtf8(error.what())); + runtimeContext.reset(); + emit runtimeStartupFinished(false, + QString::fromUtf8(error.what())); + } catch (...) { + qWarning() << "Failed to start Atlas viewport runtime"; + runtimeContext.reset(); + emit runtimeStartupFinished(false, "Runtime initialization failed"); + } +#else + qWarning() << "Atlas viewport runtime embedding requires the Metal backend"; + emit runtimeStartupFinished(false, + "Runtime embedding requires the Metal backend"); +#endif +} + +void ViewportPanel::stopRuntime() { + if (frameTimer != nullptr) { + frameTimer->stop(); + } + if (runtimeContext == nullptr) { + return; + } + auto context = std::move(runtimeContext); + lastSceneSnapshot.clear(); + if (undoStack != nullptr) + undoStack->clear(); + emit runtimeAvailabilityChanged(false); + playbackState = 0; + emit playbackStateChanged(playbackState); + try { + context->end(); + } catch (const std::exception &error) { + qWarning().noquote() + << QStringLiteral("Failed to stop Atlas viewport runtime: %1") + .arg(QString::fromUtf8(error.what())); + } catch (...) { + qWarning() << "Failed to stop Atlas viewport runtime"; + } + runtimeWidth = 0; + runtimeHeight = 0; + runtimeScale = 0.0f; +} + +void ViewportPanel::stepRuntime() { + if (runtimeContext == nullptr) { + return; + } + try { + if (!runtimeContext->stepFrame()) { + stopRuntime(); + return; + } + refreshSceneSnapshot(); + emit frameRateChanged(runtimeContext->frameRate()); + } catch (const std::exception &error) { + qWarning().noquote() + << QStringLiteral("Atlas viewport runtime frame failed: %1") + .arg(QString::fromUtf8(error.what())); + stopRuntime(); + } catch (...) { + qWarning() << "Atlas viewport runtime frame failed"; + stopRuntime(); + } +} + +void ViewportPanel::resizeRuntime() { + if (runtimeContext == nullptr) { + return; + } + const int nextWidth = std::max(1, width()); + const int nextHeight = std::max(1, height()); + const float nextScale = widgetScale(this); + if (nextWidth == runtimeWidth && nextHeight == runtimeHeight && + std::abs(nextScale - runtimeScale) <= 0.0001f) { + return; + } + try { + runtimeContext->resize(nextWidth, nextHeight, nextScale); + runtimeWidth = nextWidth; + runtimeHeight = nextHeight; + runtimeScale = nextScale; + } catch (const std::exception &error) { + qWarning().noquote() + << QStringLiteral("Atlas viewport resize failed: %1") + .arg(QString::fromUtf8(error.what())); + } catch (...) { + qWarning() << "Atlas viewport resize failed"; + } +} + +void ViewportPanel::sendPointerEvent(int action, float x, float y, int button) { + if (keyboardTransformActive && action == 1 && button == 0) + button = 1; + if (runtimeContext == nullptr || button == 0) { + return; + } + const float flippedY = static_cast(height()) - y; + runtimeContext->editorPointerEvent(action, x, flippedY, button, + widgetScale(this)); +} + +bool ViewportPanel::selectRuntimeObject(int id, bool focusCamera) { + if (runtimeContext == nullptr || + !runtimeContext->selectObject(id, focusCamera)) { + return false; + } + refreshSceneSnapshot(); + if (id >= 0) { + setFocus(Qt::OtherFocusReason); + } + return true; +} + +bool ViewportPanel::focusRuntimeObjects(const QList &ids) { + if (runtimeContext == nullptr || ids.isEmpty()) + return false; + std::vector runtimeIds; + runtimeIds.reserve(static_cast(ids.size())); + for (int id : ids) + runtimeIds.push_back(id); + return runtimeContext->focusObjects(runtimeIds); +} + +bool ViewportPanel::renameRuntimeObject(int id, const QString &name) { + if (playbackState != 0) + return false; + const QJsonObject object = findSnapshotObject( + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()) + .object() + .value("objects") + .toArray(), + id); + const QString previous = object.value("name").toString(); + if (previous.isEmpty() || previous == name) { + return previous == name; + } + undoStack->push(new RuntimeRenameCommand(this, id, previous, name)); + return true; +} + +bool ViewportPanel::renameRuntimeObjectDirect(int id, const QString &name) { + if (runtimeContext == nullptr || playbackState != 0 || + !runtimeContext->renameObject(id, name.toUtf8().toStdString())) { + return false; + } + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + return true; +} + +bool ViewportPanel::setRuntimeObjectProperty( + int id, const QString &component, int componentIndex, + const QString &propertyPath, const QJsonValue &value) { + if (playbackState != 0) + return false; + const QJsonValue previous = runtimeObjectProperty( + id, component, componentIndex, propertyPath); + if (previous.isUndefined()) { + return applyRuntimeObjectProperty(id, component, componentIndex, + propertyPath, value); + } + if (previous == value) { + return true; + } + undoStack->push(new RuntimePropertyCommand( + this, id, component, componentIndex, propertyPath, previous, value)); + return true; +} + +bool ViewportPanel::setRuntimeSceneProperty( + const QString §ion, int index, const QString &propertyPath, + const QJsonValue &value) { + if (runtimeContext == nullptr || playbackState != 0 || section.isEmpty()) { + 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->setSceneProperty( + section.toStdString(), index, propertyPath.toStdString(), + parsed.front())) { + return false; + } + } catch (const json::exception &) { + return false; + } + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + if (section.compare("environment", Qt::CaseInsensitive) == 0) + environmentReloadTimer->start(); + return true; +} + +bool ViewportPanel::setRuntimePropertySync(const QJsonObject &target, + const QJsonObject &source) { + if (runtimeContext == nullptr || playbackState != 0 || target.isEmpty() || + source.isEmpty()) { + return false; + } + try { + const json parsedTarget = json::parse( + QJsonDocument(target).toJson(QJsonDocument::Compact).constData()); + const json parsedSource = json::parse( + QJsonDocument(source).toJson(QJsonDocument::Compact).constData()); + if (!runtimeContext->setPropertySync(parsedTarget, parsedSource) || + !runtimeContext->saveCurrentScene()) { + return false; + } + } catch (const json::exception &) { + return false; + } + refreshSceneSnapshot(); + setSceneDirty(true); + return true; +} + +bool ViewportPanel::clearRuntimePropertySync(const QJsonObject &target) { + if (runtimeContext == nullptr || playbackState != 0 || target.isEmpty()) + return false; + try { + const json parsedTarget = json::parse( + QJsonDocument(target).toJson(QJsonDocument::Compact).constData()); + if (!runtimeContext->clearPropertySync(parsedTarget) || + !runtimeContext->saveCurrentScene()) { + return false; + } + } catch (const json::exception &) { + return false; + } + refreshSceneSnapshot(); + setSceneDirty(true); + return true; +} + +bool ViewportPanel::applyRuntimeObjectProperty( + int id, const QString &component, int componentIndex, + const QString &propertyPath, const QJsonValue &value) { + if (runtimeContext == nullptr || playbackState != 0) { + 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; + } + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + return true; +} + +int ViewportPanel::addRuntimeObjectComponent( + int id, const QString &type, const QJsonObject &properties) { + if (runtimeContext == nullptr || playbackState != 0 || type.isEmpty()) { + return -1; + } + QJsonObject definition = properties; + definition.insert("type", type); + if (type.toLower().remove('_').remove('-') == "rigidbody") { + QJsonObject collider = definition.value("collider").toObject(); + collider.insert("inheritObjectSize", true); + definition.insert("collider", collider); + } + 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) { + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + } + return index; + } catch (const json::exception &) { + return -1; + } +} + +bool ViewportPanel::removeRuntimeObjectComponent(int id, int componentIndex) { + if (runtimeContext == nullptr || playbackState != 0 || + !runtimeContext->removeObjectComponent(id, componentIndex) || + !runtimeContext->saveCurrentScene()) { + return false; + } + refreshSceneSnapshot(); + setSceneDirty(true); + const QJsonDocument document = + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()); + selectionToRestore = + findSnapshotObject(document.object().value("objects").toArray(), id) + .value("name") + .toString(); + QTimer::singleShot(0, this, &ViewportPanel::reloadRuntime); + return true; +} + +bool ViewportPanel::controlRuntimeAudio(int id, int componentIndex, + const QString &action) { + return runtimeContext != nullptr && + runtimeContext->controlObjectAudio(id, componentIndex, + action.toStdString()); +} + +bool ViewportPanel::setRuntimeObjectParent(int childId, int parentId) { + if (runtimeContext == nullptr || playbackState != 0 || + !runtimeContext->setObjectParent(childId, parentId)) { + return false; + } + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + return true; +} + +bool ViewportPanel::deleteRuntimeObject(int id) { + if (runtimeContext == nullptr || playbackState != 0 || + !runtimeContext->deleteObject(id)) { + return false; + } + if (undoStack != nullptr) { + undoStack->clear(); + } + if (!runtimeContext->saveCurrentScene()) { + qWarning() << "Atlas editor could not persist the deleted object"; + } + refreshSceneSnapshot(); + setSceneDirty(true); + emit runtimeObjectActivated(-1); + return true; +} + +int ViewportPanel::createRuntimeObject(const QString &type, + const QString &name) { + if (runtimeContext == nullptr || playbackState != 0) { + return -1; + } + const int id = runtimeContext->createObject(type.toUtf8().toStdString(), + name.toUtf8().toStdString()); + if (id >= 0) { + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + } + return id; +} + +bool ViewportPanel::copySelectedRuntimeObject() { + if (runtimeContext == nullptr || selectedRuntimeObjectId() < 0) + return false; + const std::string definition = + runtimeContext->objectDefinitionJson(selectedRuntimeObjectId()); + objectClipboard = QByteArray::fromStdString(definition); + return !objectClipboard.isEmpty(); +} + +bool ViewportPanel::cutSelectedRuntimeObject() { + const int id = selectedRuntimeObjectId(); + return id >= 0 && copySelectedRuntimeObject() && deleteRuntimeObject(id); +} + +bool ViewportPanel::pasteRuntimeObject() { + if (runtimeContext == nullptr || playbackState != 0 || + objectClipboard.isEmpty()) { + return false; + } + const int id = runtimeContext->pasteObjectDefinition( + objectClipboard.toStdString()); + if (id < 0) + return false; + if (undoStack != nullptr) + undoStack->clear(); + refreshSceneSnapshot(); + setSceneDirty(true); + emit runtimeObjectActivated(id); + return true; +} + +bool ViewportPanel::duplicateSelectedRuntimeObject() { + return copySelectedRuntimeObject() && pasteRuntimeObject(); +} + +bool ViewportPanel::resetSelectedTransform(int mode) { + const int id = selectedRuntimeObjectId(); + if (id < 0) + return false; + if (mode == 1) + return setRuntimeObjectProperty(id, "transform", -1, "/position", + QJsonArray{0.0, 0.0, 0.0}); + if (mode == 2) + return setRuntimeObjectProperty(id, "transform", -1, "/rotation", + QJsonArray{0.0, 0.0, 0.0}); + if (mode == 3) + return setRuntimeObjectProperty(id, "transform", -1, "/scale", + QJsonArray{1.0, 1.0, 1.0}); + return false; +} + +bool ViewportPanel::saveRuntimeScene() { + if (playbackState != 0) + return false; + const bool saved = + runtimeContext != nullptr && runtimeContext->saveCurrentScene(); + if (saved) + setSceneDirty(false); + return saved; +} + +bool ViewportPanel::openRuntimeScene(const QString &path) { + if (runtimeContext == nullptr || playbackState != 0 || path.isEmpty() || + !runtimeContext->openSceneFile(path.toStdString())) { + return false; + } + if (undoStack != nullptr) + undoStack->clear(); + selectionToRestore.clear(); + refreshSceneSnapshot(); + setSceneDirty(false); + emit sceneOpened(path); + return true; +} + +bool ViewportPanel::saveRuntimeSceneAs(const QString &path) { + if (runtimeContext == nullptr || playbackState != 0 || path.isEmpty() || + !saveRuntimeScene()) { + return false; + } + const QString source = currentRuntimeScene(); + if (source.isEmpty()) + return false; + if (QFileInfo(source).absoluteFilePath() != QFileInfo(path).absoluteFilePath()) { + if (QFile::exists(path) && !QFile::remove(path)) + return false; + if (!QFile::copy(source, path)) + return false; + } + return openRuntimeScene(path); +} + +QString ViewportPanel::currentRuntimeScene() const { + return runtimeContext != nullptr + ? QString::fromStdString(runtimeContext->currentScenePath()) + : QString(); +} + +int ViewportPanel::selectedRuntimeObjectId() const { + return runtimeContext != nullptr ? runtimeContext->selectedObjectId() : -1; +} + +bool ViewportPanel::applyRuntimeMaterial(int id, const QString &path) { + return applyRuntimeMaterialDirect(id, path); +} + +bool ViewportPanel::applyRuntimeMaterialDirect(int id, const QString &path) { + if (runtimeContext == nullptr || playbackState != 0 || id < 0 || + path.isEmpty() || + !runtimeContext->setObjectMaterial(id, path.toStdString())) { + return false; + } + runtimeContext->saveCurrentScene(); + refreshSceneSnapshot(); + setSceneDirty(true); + return true; +} + +bool ViewportPanel::attachRuntimeAsset(int id, const QString &path) { + const QFileInfo info(path); + const QString suffix = info.suffix().toLower(); + if (suffix == "amat" || suffix == "material") { + return applyRuntimeMaterial(id, info.absoluteFilePath()); + } + if (suffix == "ts" || suffix == "js") { + return addRuntimeObjectComponent( + id, "script", + QJsonObject{{"name", info.completeBaseName()}, + {"source", info.absoluteFilePath()}, + {"variables", QJsonObject{}}}) >= 0; + } + if (suffix == "wav" || suffix == "mp3" || suffix == "ogg" || + suffix == "flac" || suffix == "m4a" || suffix == "aac") { + return addRuntimeObjectComponent( + id, "audio_player", + QJsonObject{{"source", info.absoluteFilePath()}, + {"useSpatialization", true}, + {"volume", 1.0}, + {"loop", false}, + {"autoplay", false}}) >= 0; + } + return false; +} + +bool ViewportPanel::importRuntimeModel(const QString &path) { + if (runtimeContext == nullptr || playbackState != 0 || path.isEmpty()) + return false; + const QJsonObject definition{ + {"type", "model"}, + {"name", QFileInfo(path).completeBaseName()}, + {"source", QFileInfo(path).absoluteFilePath()}, + {"position", QJsonArray{0.0, 0.0, 0.0}}, + {"rotation", QJsonArray{0.0, 0.0, 0.0}}, + {"scale", QJsonArray{1.0, 1.0, 1.0}}, + {"components", QJsonArray{}}}; + const int id = runtimeContext->pasteObjectDefinition( + QJsonDocument(definition).toJson(QJsonDocument::Compact).toStdString()); + if (id < 0) + return false; + refreshSceneSnapshot(); + setSceneDirty(true); + emit runtimeObjectActivated(id); + return true; +} + +void ViewportPanel::undo() { + if (undoStack != nullptr && playbackState == 0) { + undoStack->undo(); + const int selected = selectedRuntimeObjectId(); + if (selected >= 0) + emit runtimeObjectActivated(selected); + } +} + +void ViewportPanel::redo() { + if (undoStack != nullptr && playbackState == 0) { + undoStack->redo(); + const int selected = selectedRuntimeObjectId(); + if (selected >= 0) + emit runtimeObjectActivated(selected); + } +} + +void ViewportPanel::setSceneDirty(bool dirty) { + if (sceneDirty == dirty) + return; + sceneDirty = dirty; + emit sceneDirtyChanged(sceneDirty); +} + +QJsonValue ViewportPanel::runtimeObjectProperty( + int id, const QString &component, int componentIndex, + const QString &propertyPath) const { + const QJsonDocument document = + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()); + const QJsonObject object = findSnapshotObject( + document.object().value("objects").toArray(), id); + if (object.isEmpty()) + return QJsonValue(QJsonValue::Undefined); + const QString normalized = component.toLower(); + if (normalized == "transform") + return snapshotValueAt(object, propertyPath); + if (normalized == "object") + return snapshotValueAt(object.value("properties"), propertyPath); + const QJsonArray components = object.value("components").toArray(); + if (componentIndex < 0 || componentIndex >= components.size()) + return QJsonValue(QJsonValue::Undefined); + return snapshotValueAt(components.at(componentIndex), propertyPath); +} + +void ViewportPanel::playRuntime() { + if (runtimeContext == nullptr) { + return; + } + finishKeyboardTransform(false); + if (playbackState == 0 && !saveRuntimeScene()) { + qWarning() << "Atlas editor could not checkpoint the scene for play"; + return; + } + runtimeContext->setEditorSimulationEnabled(true); + playbackState = 1; + emit playbackStateChanged(playbackState); +} + +void ViewportPanel::toggleRuntimePlayback() { + if (playbackState == 1) + pauseRuntime(); + else + playRuntime(); +} + +void ViewportPanel::pauseRuntime() { + if (runtimeContext == nullptr || playbackState == 0) { + return; + } + runtimeContext->setEditorSimulationEnabled(false); + refreshSceneSnapshot(); + playbackState = 2; + emit playbackStateChanged(playbackState); +} + +void ViewportPanel::stepRuntimeOnce() { + if (runtimeContext == nullptr || playbackState == 0) { + return; + } + runtimeContext->setEditorSimulationEnabled(true); + stepRuntime(); + if (runtimeContext != nullptr) { + runtimeContext->setEditorSimulationEnabled(false); + playbackState = 2; + emit playbackStateChanged(playbackState); + } +} + +void ViewportPanel::stopRuntimePlayback() { + if (runtimeContext == nullptr || playbackState == 0) { + return; + } + reloadRuntime(); +} + +void ViewportPanel::reloadRuntime() { + if (shuttingDown) { + return; + } + if (selectionToRestore.isEmpty() && runtimeContext != nullptr) { + const int selected = selectedRuntimeObjectId(); + if (selected >= 0) { + const QJsonDocument document = + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()); + selectionToRestore = findSnapshotObject( + document.object() + .value("objects") + .toArray(), + selected) + .value("name") + .toString(); + } + } + finishKeyboardTransform(false); + stopRuntime(); + QTimer::singleShot(0, this, [this] { scheduleRuntimeStart(); }); +} + +void ViewportPanel::setRuntimeShadingMode(int mode) { + if (mode < 0 || mode > 2) { + return; + } + shadingMode = mode; + if (runtimeContext != nullptr) { + runtimeContext->setEditorShadingMode(mode); + } +} + +void ViewportPanel::setRuntimeControlMode(int mode) { + if (mode < 0 || mode > 3 || runtimeContext == nullptr) { + return; + } + finishKeyboardTransform(false); + runtimeContext->setEditorControlMode(mode); +} + +void ViewportPanel::toggleTransformSpace() { + if (runtimeContext != nullptr) + emit transformSpaceChanged(runtimeContext->toggleEditorTransformSpace()); +} + +void ViewportPanel::toggleTransformSnapping() { + if (runtimeContext == nullptr) + return; + const bool enabled = runtimeContext->toggleEditorTransformSnapping(); + const float increment = + runtimeContext->changeEditorTransformSnapIncrement(1.0f); + emit transformSnappingChanged(enabled, increment); +} + +void ViewportPanel::changeTransformSnapIncrement(float factor) { + if (runtimeContext == nullptr) + return; + const float increment = + runtimeContext->changeEditorTransformSnapIncrement(factor); + emit transformSnappingChanged(true, increment); +} + +void ViewportPanel::beginKeyboardTransform(int mode) { + if (runtimeContext == nullptr || selectedRuntimeObjectId() < 0) + return; + transformUndoBefore = + findSnapshotObject( + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()) + .object() + .value("objects") + .toArray(), + selectedRuntimeObjectId()); + const QPoint pointer = mapFromGlobal(QCursor::pos()); + if (!runtimeContext->beginEditorKeyboardTransform( + mode, static_cast(pointer.x()), + static_cast(height() - pointer.y()), widgetScale(this))) { + return; + } + keyboardTransformActive = true; + keyboardTransformMode = mode; + keyboardTransformAxes = 7; + grabMouse(); + const QString operation = + mode == 1 ? "Move" : mode == 2 ? "Rotate" : "Scale"; + emit transformHintChanged( + QStringLiteral("%1 · All axes · X/Y/Z constrain · Shift+Axis exclude " + "· Enter/LMB confirm · Esc/RMB cancel") + .arg(operation)); +} + +void ViewportPanel::updateKeyboardTransformAxes(int key, bool exclude) { + if (!keyboardTransformActive || runtimeContext == nullptr) + return; + const int bit = key == Qt::Key_X ? 1 : key == Qt::Key_Y ? 2 : 4; + if (exclude) { + keyboardTransformAxes = 7 & ~bit; + } else if (keyboardTransformAxes == 7) { + keyboardTransformAxes = bit; + } else { + keyboardTransformAxes |= bit; + } + runtimeContext->setEditorKeyboardTransformAxes(keyboardTransformAxes); + QString axes; + if ((keyboardTransformAxes & 1) != 0) + axes += 'X'; + if ((keyboardTransformAxes & 2) != 0) + axes += 'Y'; + if ((keyboardTransformAxes & 4) != 0) + axes += 'Z'; + const QString operation = keyboardTransformMode == 1 ? "Move" + : keyboardTransformMode == 2 ? "Rotate" + : "Scale"; + emit transformHintChanged( + QStringLiteral("%1 · %2 locked · X/Y/Z add axes · Shift+Axis exclude " + "· Enter/LMB confirm · Esc/RMB cancel") + .arg(operation, axes)); +} + +void ViewportPanel::finishKeyboardTransform(bool commit) { + if (!keyboardTransformActive) + return; + if (runtimeContext != nullptr) { + const int selected = selectedRuntimeObjectId(); + runtimeContext->finishEditorKeyboardTransform(commit); + if (commit) { + runtimeContext->saveCurrentScene(); + setSceneDirty(true); + } + refreshSceneSnapshot(); + if (commit) + pushTransformUndo(selected, transformUndoBefore); + if (selected >= 0) + emit runtimeObjectActivated(selected); + } + keyboardTransformActive = false; + releaseMouse(); + keyboardTransformMode = 0; + keyboardTransformAxes = 7; + transformUndoBefore = {}; + emit transformHintChanged( + "Tab Frame · Right-Drag Pan · Middle-Drag Orbit · G Move · R Rotate · S Scale"); +} + +void ViewportPanel::pushTransformUndo(int objectId, + const QJsonObject &before) { + if (undoStack == nullptr || objectId < 0 || before.isEmpty()) + return; + const QJsonObject after = findSnapshotObject( + QJsonDocument::fromJson(lastSceneSnapshot.toUtf8()) + .object() + .value("objects") + .toArray(), + objectId); + if (after.isEmpty()) + return; + auto *command = new QUndoCommand("Transform Object"); + const QList> properties{ + {"position", "/position"}, + {"rotation", "/rotation"}, + {"scale", "/scale"}}; + for (const auto &[key, path] : properties) { + if (before.value(key) != after.value(key)) { + new RuntimePropertyCommand(this, objectId, "transform", -1, path, + before.value(key), after.value(key), + command); + } + } + if (command->childCount() == 0) { + delete command; + return; + } + undoStack->push(command); +} + +void ViewportPanel::refreshSceneSnapshot() { + if (runtimeContext == nullptr) { + return; + } + const QString snapshot = + QString::fromStdString(runtimeContext->sceneObjectsJson()); + if (snapshot == lastSceneSnapshot) { + return; + } + lastSceneSnapshot = snapshot; + emit sceneSnapshotChanged(snapshot); +} diff --git a/editor/views/editor/viewportTools.cpp b/editor/views/editor/viewportTools.cpp new file mode 100644 index 00000000..8616c1ce --- /dev/null +++ b/editor/views/editor/viewportTools.cpp @@ -0,0 +1,268 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ViewportTools::ViewportTools(ViewportPanel *viewport, + const QString &projectFile, QWidget *parent) + : QWidget(parent), viewport(viewport), + projectRoot(QFileInfo(projectFile).absolutePath()) { + setObjectName("viewportTools"); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + sceneTabs = new QTabBar(this); + sceneTabs->setObjectName("sceneTabs"); + sceneTabs->setDocumentMode(true); + sceneTabs->setExpanding(false); + sceneTabs->setMovable(true); + sceneTabs->setTabsClosable(true); + sceneTabs->setVisible(false); + layout->addWidget(sceneTabs); + + auto *toolbar = new QWidget(this); + toolbar->setObjectName("viewportToolbar"); + auto *tools = new QHBoxLayout(toolbar); + tools->setContentsMargins(5, 3, 5, 3); + tools->setSpacing(2); + + playButton = new QToolButton(toolbar); + playButton->setObjectName("viewportPlaybackButton"); + playButton->setIcon(styling::icon(styling::Icon::Play, "#849589")); + playButton->setToolTip("Play"); + pauseButton = new QToolButton(toolbar); + pauseButton->setObjectName("viewportPlaybackButton"); + pauseButton->setIcon(styling::icon(styling::Icon::Pause, "#A1957D")); + pauseButton->setToolTip("Pause"); + stepButton = new QToolButton(toolbar); + stepButton->setObjectName("viewportPlaybackButton"); + stepButton->setIcon( + styling::icon(styling::Icon::SkipForward, "#7E929C")); + stepButton->setToolTip("Step one frame"); + stopButton = new QToolButton(toolbar); + stopButton->setObjectName("viewportPlaybackButton"); + stopButton->setIcon(styling::icon(styling::Icon::Stop, "#A17F7F")); + stopButton->setToolTip("Stop and restore the scene"); + reloadButton = new QToolButton(toolbar); + reloadButton->setObjectName("viewportPlaybackButton"); + reloadButton->setIcon( + styling::icon(styling::Icon::ArrowCounterClockwise, "#71889A")); + reloadButton->setToolTip("Reload runtime"); + playButton->setShortcut(QKeySequence("Ctrl+P")); + pauseButton->setShortcut(QKeySequence("Ctrl+Shift+P")); + stepButton->setShortcut(QKeySequence("Ctrl+Alt+P")); + + auto *transformGroup = new QActionGroup(toolbar); + transformGroup->setExclusive(true); + const QStringList transformNames{"Select", "Move", "Rotate", "Scale"}; + const QList transformIcons{ + styling::Icon::CursorClick, styling::Icon::ArrowsOutCardinal, + styling::Icon::ArrowClockwise, styling::Icon::BoundingBox}; + const QList transformColors{ + QColor("#7E929C"), QColor("#849589"), QColor("#A1957D"), + QColor("#71889A")}; + for (int index = 0; index < transformNames.size(); ++index) { + auto *button = new QToolButton(toolbar); + button->setObjectName("viewportModeButton"); + button->setToolButtonStyle(Qt::ToolButtonIconOnly); + button->setCheckable(true); + auto *action = new QAction(transformNames.at(index), button); + action->setIcon( + styling::icon(transformIcons.at(index), transformColors.at(index))); + action->setToolTip(transformNames.at(index) + " tool"); + action->setCheckable(true); + action->setData(index); + button->setDefaultAction(action); + transformGroup->addAction(action); + tools->addWidget(button); + if (index == 0) { + action->setChecked(true); + } + } + + tools->addSpacing(10); + spaceButton = new QToolButton(toolbar); + spaceButton->setObjectName("viewportOptionButton"); + spaceButton->setIcon(styling::icon(styling::Icon::Globe, "#7E929C")); + spaceButton->setToolTip("World transform space · Shift+T"); + tools->addWidget(spaceButton); + + tools->addStretch(); + tools->addWidget(playButton); + tools->addWidget(pauseButton); + tools->addWidget(stepButton); + tools->addWidget(stopButton); + tools->addWidget(reloadButton); + tools->addStretch(); + + auto *shadingGroup = new QActionGroup(toolbar); + shadingGroup->setExclusive(true); + const QStringList shadingNames{"Lit", "Wireframe", "Points"}; + const QList shadingIcons{ + styling::Icon::Sphere, styling::Icon::CubeTransparent, + styling::Icon::DotsNine}; + for (int index = 0; index < shadingNames.size(); ++index) { + auto *button = new QToolButton(toolbar); + button->setObjectName("viewportShadingButton"); + button->setToolButtonStyle(Qt::ToolButtonIconOnly); + button->setCheckable(true); + auto *action = new QAction(shadingNames.at(index), button); + action->setIcon( + styling::icon(shadingIcons.at(index), "#9AA6B8")); + action->setToolTip(shadingNames.at(index) + " shading"); + action->setCheckable(true); + action->setData(index); + button->setDefaultAction(action); + shadingGroup->addAction(action); + tools->addWidget(button); + if (index == 0) + action->setChecked(true); + } + + auto *fpsButton = new QToolButton(toolbar); + fpsButton->setObjectName("viewportOptionButton"); + fpsButton->setIcon( + styling::icon(styling::Icon::Monitor, "#849589")); + fpsButton->setCheckable(true); + fpsButton->setChecked(true); + fpsButton->setToolTip("Toggle frame rate"); + fpsLabel = new QLabel("-- FPS", toolbar); + fpsLabel->setObjectName("viewportFpsLabel"); + fpsLabel->setMinimumWidth(62); + tools->addWidget(fpsButton); + tools->addWidget(fpsLabel); + + layout->addWidget(toolbar); + layout->addWidget(viewport, 1); + shortcutHint = new QLabel( + "Tab Frame · Right-Drag Orbit · Shift + Right-Drag Pan · G Move · R Rotate · S Scale", + this); + shortcutHint->setObjectName("viewportShortcutHint"); + shortcutHint->setTextInteractionFlags(Qt::NoTextInteraction); + layout->addWidget(shortcutHint); + + connect(playButton, &QToolButton::clicked, viewport, + &ViewportPanel::playRuntime); + connect(pauseButton, &QToolButton::clicked, viewport, + &ViewportPanel::pauseRuntime); + connect(stepButton, &QToolButton::clicked, viewport, + &ViewportPanel::stepRuntimeOnce); + connect(stopButton, &QToolButton::clicked, viewport, + &ViewportPanel::stopRuntimePlayback); + connect(reloadButton, &QToolButton::clicked, viewport, + &ViewportPanel::reloadRuntime); + connect(transformGroup, &QActionGroup::triggered, this, + [viewport](QAction *action) { + viewport->setRuntimeControlMode(action->data().toInt()); + }); + connect(shadingGroup, &QActionGroup::triggered, this, + [viewport](QAction *action) { + viewport->setRuntimeShadingMode(action->data().toInt()); + }); + connect(spaceButton, &QToolButton::clicked, viewport, + &ViewportPanel::toggleTransformSpace); + connect(viewport, &ViewportPanel::transformSpaceChanged, this, + [this](bool local) { + spaceButton->setIcon(styling::icon( + local ? styling::Icon::Cube : styling::Icon::Globe, + local ? QColor("#71889A") : QColor("#7E929C"))); + spaceButton->setToolTip( + local ? "Local transform space · Shift+T" + : "World transform space · Shift+T"); + }); + connect(fpsButton, &QToolButton::toggled, fpsLabel, &QWidget::setVisible); + connect(viewport, &ViewportPanel::frameRateChanged, this, + [this](float fps) { + fpsLabel->setText(QStringLiteral("%1 FPS").arg(fps, 0, 'f', 0)); + }); + connect(viewport, &ViewportPanel::playbackStateChanged, this, + &ViewportTools::updatePlaybackState); + connect(viewport, &ViewportPanel::transformHintChanged, shortcutHint, + &QLabel::setText); + connect(viewport, &ViewportPanel::runtimeAvailabilityChanged, this, + [this](bool available) { + runtimeAvailable = available; + updatePlaybackState(playbackState); + }); + connect(sceneTabs, &QTabBar::currentChanged, this, [this](int index) { + if (index >= 0 && index < scenePaths.size() && + this->viewport != nullptr) + this->viewport->openRuntimeScene(scenePaths.at(index)); + }); + connect(sceneTabs, &QTabBar::tabCloseRequested, this, [this](int index) { + closeSceneTab(index); + }); + connect(viewport, &ViewportPanel::sceneOpened, this, + &ViewportTools::openSceneTab); + refreshSceneTabs(); + updatePlaybackState(0); +} + +void ViewportTools::refreshSceneTabs() { + const QString current = viewport != nullptr ? viewport->currentRuntimeScene() + : QString(); + if (!current.trimmed().isEmpty()) + openSceneTab(current); + updateSceneTabs(); +} + +void ViewportTools::openSceneTab(const QString &path) { + const QString absolute = QFileInfo(path).absoluteFilePath(); + if (path.trimmed().isEmpty() || !QFileInfo(absolute).isFile()) + return; + int index = scenePaths.indexOf(absolute); + if (index < 0) { + scenePaths.append(absolute); + index = sceneTabs->addTab(QFileInfo(absolute).completeBaseName()); + sceneTabs->setTabToolTip(index, absolute); + } + const QSignalBlocker blocker(sceneTabs); + sceneTabs->setCurrentIndex(index); + updateSceneTabs(); +} + +void ViewportTools::closeCurrentSceneTab() { + closeSceneTab(sceneTabs->currentIndex()); +} + +void ViewportTools::closeSceneTab(int index) { + if (index < 0 || index >= scenePaths.size()) + return; + const QSignalBlocker blocker(sceneTabs); + scenePaths.removeAt(index); + sceneTabs->removeTab(index); + if (!scenePaths.isEmpty()) { + const int next = qMin(index, scenePaths.size() - 1); + sceneTabs->setCurrentIndex(next); + if (viewport != nullptr) + viewport->openRuntimeScene(scenePaths.at(next)); + } + updateSceneTabs(); +} + +void ViewportTools::updateSceneTabs() { + sceneTabs->setVisible(!scenePaths.isEmpty()); + sceneTabs->setTabsClosable(!scenePaths.isEmpty()); +} + +void ViewportTools::updatePlaybackState(int state) { + playbackState = state; + playButton->setEnabled(runtimeAvailable && state != 1); + pauseButton->setEnabled(runtimeAvailable && state == 1); + stepButton->setEnabled(runtimeAvailable && state != 0); + stopButton->setEnabled(runtimeAvailable && state != 0); + reloadButton->setEnabled(runtimeAvailable); +} diff --git a/editor/views/general/contentBrowser.cpp b/editor/views/general/contentBrowser.cpp new file mode 100644 index 00000000..85a1bef9 --- /dev/null +++ b/editor/views/general/contentBrowser.cpp @@ -0,0 +1,671 @@ +/* + * contentBrowser.cpp + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Content Browser / File Explorer Declaration + * Copyright (c) 2026 Max Van den Eynde + */ + +#include "editor/views/fileExplorer.h" +#include "editor/styling/icons.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +const QByteArray EmptyScene = R"({ + "name": "New Scene", + "id": "new_scene", + "objects": [], + "lights": [ + { + "type": "ambient", + "intensity": 0.25 + } + ], + "camera": { + "position": [0.0, 1.5, -5.0], + "target": [0.0, 0.0, 0.0], + "fov": 60.0 + }, + "targets": [ + { + "name": "Main Target", + "type": "scene", + "render": true, + "display": true + } + ] +} +)"; + +bool writeNewFile(const QString &path, const QByteArray &contents) { + QSaveFile file(path); + return file.open(QIODevice::WriteOnly) && + file.write(contents) == contents.size() && file.commit(); +} + +bool isValidEntryName(const QString &name) { + return !name.isEmpty() && name != "." && name != ".." && + !name.contains('/') && !name.contains('\\'); +} + +bool copyEntry(const QString &source, const QString &destination) { + const QFileInfo info(source); + if (info.isDir()) { + if (!QDir().mkpath(destination)) + return false; + QDir directory(source); + for (const QFileInfo &entry : directory.entryInfoList( + QDir::NoDotAndDotDot | QDir::AllEntries)) { + if (!copyEntry(entry.absoluteFilePath(), + QDir(destination).filePath(entry.fileName()))) { + return false; + } + } + return true; + } + return QFile::copy(source, destination); +} + +class AtlasFileIconProvider : public QFileIconProvider { +public: + QIcon icon(const QFileInfo &info) const override { + if (info.isDir()) + return styling::icon(styling::Icon::Folder, "#7E929C"); + const QString suffix = info.suffix().toLower(); + if (suffix == "ascene") + return styling::icon(styling::Icon::CubeFocus, "#8498A8"); + if (suffix == "amat" || suffix == "material") + return styling::icon(styling::Icon::Material, "#9E897D"); + if (suffix == "ts" || suffix == "js" || suffix == "cpp" || + suffix == "h" || suffix == "json") + return styling::icon(styling::Icon::FileCode, "#7E929C"); + if (suffix == "png" || suffix == "jpg" || suffix == "jpeg" || + suffix == "hdr") + return styling::icon(styling::Icon::Image, "#A1957D"); + if (suffix == "wav" || suffix == "mp3" || suffix == "ogg" || + suffix == "flac") + return styling::icon(styling::Icon::MusicNote, "#849589"); + return styling::icon(styling::Icon::File, "#8490A4"); + } + + QIcon icon(IconType type) const override { + if (type == Folder || type == Drive) + return styling::icon(styling::Icon::Folder, "#7E929C"); + return styling::icon(styling::Icon::File, "#8490A4"); + } +}; + +AtlasFileIconProvider atlasFileIconProvider; +} // namespace + +ContentBrowserPanel::ContentBrowserPanel(const QString &projectFile, + QWidget *parent) + : QWidget(parent) { + setObjectName("contentBrowserPanel"); + const QFileInfo projectInfo(projectFile); + projectRoot = projectInfo.absoluteDir().canonicalPath(); + if (projectRoot.isEmpty()) { + projectRoot = projectInfo.absoluteDir().absolutePath(); + } + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(4, 4, 4, 4); + layout->setSpacing(4); + + auto *toolbar = new QWidget(this); + toolbar->setObjectName("panelToolbar"); + auto *toolbarLayout = new QHBoxLayout(toolbar); + toolbarLayout->setContentsMargins(0, 0, 0, 0); + toolbarLayout->setSpacing(4); + + backButton = new QToolButton(toolbar); + backButton->setObjectName("browserNavigationButton"); + backButton->setIcon( + styling::icon(styling::Icon::ArrowLeft, "#AAB4C4")); + backButton->setToolTip("Back"); + forwardButton = new QToolButton(toolbar); + forwardButton->setObjectName("browserNavigationButton"); + forwardButton->setIcon( + styling::icon(styling::Icon::ArrowRight, "#AAB4C4")); + forwardButton->setToolTip("Forward"); + upButton = new QToolButton(toolbar); + upButton->setObjectName("browserNavigationButton"); + upButton->setIcon(styling::icon(styling::Icon::ArrowUp, "#AAB4C4")); + upButton->setToolTip("Parent Folder"); + + pathField = new QLineEdit(toolbar); + pathField->setObjectName("contentPathField"); + pathField->setReadOnly(true); + + searchField = new QLineEdit(toolbar); + searchField->setObjectName("contentSearchField"); + searchField->setPlaceholderText("Search"); + searchField->setClearButtonEnabled(true); + searchField->setMaximumWidth(180); + + createButton = new QToolButton(toolbar); + createButton->setObjectName("panelAddButton"); + createButton->setIcon(styling::icon(styling::Icon::Plus, "#8498A8")); + createButton->setText("Create"); + createButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + createButton->setPopupMode(QToolButton::InstantPopup); + + revealButton = new QToolButton(toolbar); + revealButton->setObjectName("browserRevealButton"); + revealButton->setIcon( + styling::icon(styling::Icon::FolderOpen, "#7E929C")); + revealButton->setToolTip("Reveal in Finder"); + + moreButton = new QToolButton(toolbar); + moreButton->setObjectName("panelMoreButton"); + moreButton->setIcon( + styling::icon(styling::Icon::DotsVertical, "#8490A4")); + moreButton->setPopupMode(QToolButton::InstantPopup); + moreButton->setToolTip("Content actions"); + + toolbarLayout->addWidget(backButton); + toolbarLayout->addWidget(forwardButton); + toolbarLayout->addWidget(upButton); + toolbarLayout->addWidget(pathField, 1); + toolbarLayout->addWidget(searchField); + toolbarLayout->addWidget(createButton); + toolbarLayout->addWidget(revealButton); + toolbarLayout->addWidget(moreButton); + layout->addWidget(toolbar); + + model = new QFileSystemModel(this); + model->setIconProvider(&atlasFileIconProvider); + model->setFilter(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot); + model->setReadOnly(false); + model->setRootPath(projectRoot); + model->sort(0, Qt::AscendingOrder); + + gridView = new QListView(this); + gridView->setObjectName("contentGrid"); + gridView->setModel(model); + gridView->setViewMode(QListView::IconMode); + gridView->setFlow(QListView::LeftToRight); + gridView->setWrapping(true); + gridView->setResizeMode(QListView::Adjust); + gridView->setMovement(QListView::Static); + gridView->setGridSize(QSize(154, 118)); + gridView->setIconSize(QSize(50, 50)); + gridView->setWordWrap(true); + gridView->setTextElideMode(Qt::ElideNone); + gridView->setSelectionMode(QAbstractItemView::ExtendedSelection); + gridView->setDragEnabled(true); + gridView->setDragDropMode(QAbstractItemView::DragOnly); + gridView->setDefaultDropAction(Qt::CopyAction); + gridView->setContextMenuPolicy(Qt::CustomContextMenu); + gridView->setUniformItemSizes(true); + gridView->setSpacing(4); + layout->addWidget(gridView, 1); + + auto *createMenu = new QMenu(createButton); + createMenu->addAction(styling::icon(styling::Icon::Folder, "#7E929C"), "Folder", + this, &ContentBrowserPanel::createFolder); + createMenu->addSeparator(); + createMenu->addAction(styling::icon(styling::Icon::CubeFocus, "#8498A8"), "Scene", + this, &ContentBrowserPanel::createScene); + createMenu->addAction(styling::icon(styling::Icon::Material, "#9E897D"), + "Material", this, + &ContentBrowserPanel::createMaterial); + createMenu->addAction(styling::icon(styling::Icon::FileCode, "#7E929C"), + "TypeScript Script", this, + &ContentBrowserPanel::createScript); + createButton->setMenu(createMenu); + + auto *moreMenu = new QMenu(moreButton); + moreMenu->addAction( + styling::icon(styling::Icon::FolderOpen, "#7E929C"), "Open", this, [this] { + if (gridView->currentIndex().isValid()) { + openIndex(gridView->currentIndex()); + } + }); + moreMenu->addAction(styling::icon(styling::Icon::File, "#8498A8"), + "Rename", this, + &ContentBrowserPanel::renameSelection); + moreMenu->addAction(styling::icon(styling::Icon::Trash, "#A17F7F"), + "Delete", this, + &ContentBrowserPanel::deleteSelection); + moreMenu->addSeparator(); + moreMenu->addAction(styling::icon(styling::Icon::FolderOpen, "#7E929C"), + "Reveal in Finder", this, + &ContentBrowserPanel::revealSelection); + moreMenu->addAction(styling::icon(styling::Icon::FileCode, "#8490A4"), + "Copy Path", this, + &ContentBrowserPanel::copySelectionPath); + moreButton->setMenu(moreMenu); + + connect(gridView, &QListView::doubleClicked, this, + &ContentBrowserPanel::openIndex); + connect(gridView, &QListView::customContextMenuRequested, this, + &ContentBrowserPanel::showContextMenu); + connect(revealButton, &QToolButton::clicked, this, + &ContentBrowserPanel::revealSelection); + connect(backButton, &QToolButton::clicked, this, [this] { + if (historyIndex > 0) { + --historyIndex; + navigateTo(history.at(historyIndex), false); + } + }); + connect(forwardButton, &QToolButton::clicked, this, [this] { + if (historyIndex + 1 < history.size()) { + ++historyIndex; + navigateTo(history.at(historyIndex), false); + } + }); + connect(upButton, &QToolButton::clicked, this, [this] { + navigateTo(QFileInfo(currentPath).absoluteDir().absolutePath()); + }); + connect(searchField, &QLineEdit::textChanged, this, + [this](const QString &search) { + model->setNameFilters(search.isEmpty() + ? QStringList() + : QStringList{"*" + search + "*"}); + model->setNameFilterDisables(false); + }); + connect(gridView->selectionModel(), &QItemSelectionModel::selectionChanged, + this, [this] { + updateNavigationState(); + emit selectionChanged(selectedPath()); + }); + + auto *deleteAction = new QAction(this); + deleteAction->setShortcuts( + {QKeySequence::Delete, + QKeySequence(Qt::META | Qt::Key_Backspace)}); + deleteAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(deleteAction, &QAction::triggered, this, + &ContentBrowserPanel::deleteSelection); + addAction(deleteAction); + + auto *renameAction = new QAction(this); + renameAction->setShortcut(Qt::Key_F2); + renameAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(renameAction, &QAction::triggered, this, + &ContentBrowserPanel::renameSelection); + addAction(renameAction); + + renameAction->setShortcuts({QKeySequence(Qt::Key_Return), + QKeySequence(Qt::Key_Enter), + QKeySequence(Qt::Key_F2)}); + + auto *createFolderAction = new QAction(this); + createFolderAction->setShortcut( + QKeySequence(Qt::META | Qt::SHIFT | Qt::Key_N)); + createFolderAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(createFolderAction, &QAction::triggered, this, + &ContentBrowserPanel::createFolder); + addAction(createFolderAction); + + auto *revealAction = new QAction(this); + revealAction->setShortcut( + QKeySequence(Qt::META | Qt::SHIFT | Qt::Key_R)); + revealAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(revealAction, &QAction::triggered, this, + &ContentBrowserPanel::revealSelection); + addAction(revealAction); + + navigateTo(projectRoot); +} + +void ContentBrowserPanel::setRootPath(const QString &path) { + const QFileInfo info(path); + projectRoot = info.isDir() ? info.canonicalFilePath() + : info.absoluteDir().canonicalPath(); + if (projectRoot.isEmpty()) { + projectRoot = info.isDir() ? info.absoluteFilePath() + : info.absoluteDir().absolutePath(); + } + model->setRootPath(projectRoot); + history.clear(); + historyIndex = -1; + 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() + ? info.absoluteFilePath() + : info.canonicalFilePath(); + if (!info.isDir() || !isInsideProject(target)) { + return; + } + + currentPath = target; + gridView->setRootIndex(model->index(currentPath)); + gridView->clearSelection(); + searchField->clear(); + + if (recordHistory) { + while (history.size() > historyIndex + 1) { + history.removeLast(); + } + if (history.isEmpty() || history.constLast() != currentPath) { + history.append(currentPath); + } + historyIndex = history.size() - 1; + } + updateNavigationState(); + emit selectionChanged(QString()); +} + +void ContentBrowserPanel::openIndex(const QModelIndex &index) { + const QFileInfo info = model->fileInfo(index); + if (info.isDir()) { + navigateTo(info.absoluteFilePath()); + return; + } + const QString suffix = info.suffix().toLower(); + if (suffix == "amat" || suffix == "material") { + emit assetActivated(info.absoluteFilePath()); + return; + } + if (suffix == "ascene") { + emit sceneActivated(info.absoluteFilePath()); + return; + } + QDesktopServices::openUrl(QUrl::fromLocalFile(info.absoluteFilePath())); +} + +void ContentBrowserPanel::showContextMenu(const QPoint &position) { + const QModelIndex index = gridView->indexAt(position); + if (index.isValid()) { + gridView->setCurrentIndex(index); + if (!gridView->selectionModel()->isSelected(index)) { + gridView->selectionModel()->select( + index, QItemSelectionModel::ClearAndSelect); + } + } + + QMenu menu(this); + auto *createMenu = menu.addMenu("Create"); + for (QAction *action : createButton->menu()->actions()) { + createMenu->addAction(action); + } + if (index.isValid()) { + menu.addSeparator(); + menu.addAction("Open", this, [this, index] { openIndex(index); }); + menu.addAction("Rename", this, &ContentBrowserPanel::renameSelection); + menu.addAction("Delete", this, &ContentBrowserPanel::deleteSelection); + menu.addSeparator(); + menu.addAction("Reveal in Finder", this, + &ContentBrowserPanel::revealSelection); + menu.addAction("Copy Path", this, + &ContentBrowserPanel::copySelectionPath); + } else { + menu.addSeparator(); + menu.addAction("Reveal This Folder in Finder", this, [this] { +#ifdef Q_OS_MACOS + QProcess::startDetached("/usr/bin/open", {currentPath}); +#else + QDesktopServices::openUrl(QUrl::fromLocalFile(currentPath)); +#endif + }); + } + menu.exec(gridView->viewport()->mapToGlobal(position)); +} + +void ContentBrowserPanel::showCreateMenu(const QPoint &position) { + if (createButton->menu() != nullptr) { + createButton->menu()->popup(position); + } +} + +void ContentBrowserPanel::createFolder() { + bool accepted = false; + const QString name = + QInputDialog::getText(this, "New Folder", "Folder name", + QLineEdit::Normal, "New Folder", &accepted); + const QString entryName = name.trimmed(); + if (!accepted) { + return; + } + if (!isValidEntryName(entryName)) { + QMessageBox::warning(this, "New Folder", "Enter a valid folder name."); + return; + } + if (!QDir(currentPath).mkdir(entryName)) { + QMessageBox::warning(this, "New Folder", + "The folder could not be created."); + } +} + +void ContentBrowserPanel::createScene() { + const QString path = uniquePath("New Scene.ascene"); + if (writeNewFile(path, EmptyScene)) { + gridView->setCurrentIndex(model->index(path)); + } +} + +void ContentBrowserPanel::createScript() { + const QString path = uniquePath("NewScript.ts"); + const QByteArray script = "import { Component } from \"atlas\";\n\n" + "export class NewScript extends Component {\n" + " init() {}\n\n" + " update(deltaTime: number) {}\n" + "}\n"; + if (writeNewFile(path, script)) { + gridView->setCurrentIndex(model->index(path)); + } +} + +void ContentBrowserPanel::createMaterial() { + const QString path = uniquePath("New Material.amat"); + const QByteArray material = + "{\n" + " \"material\": {\n" + " \"albedo\": [0.8, 0.8, 0.8, 1.0],\n" + " \"metallic\": 0.0,\n" + " \"roughness\": 0.5,\n" + " \"ao\": 1.0,\n" + " \"reflectivity\": 0.5,\n" + " \"emissiveColor\": [0.0, 0.0, 0.0, 1.0],\n" + " \"emissiveIntensity\": 0.0,\n" + " \"normalMapStrength\": 1.0,\n" + " \"useNormalMap\": true,\n" + " \"transmittance\": 0.0,\n" + " \"ior\": 1.45\n" + " }\n" + "}\n"; + if (writeNewFile(path, material)) { + const QModelIndex index = model->index(path); + gridView->setCurrentIndex(index); + emit assetActivated(path); + } +} + +void ContentBrowserPanel::renameSelection() { + const QString path = selectedPath(); + if (path.isEmpty()) { + return; + } + const QFileInfo info(path); + bool accepted = false; + const QString name = QInputDialog::getText( + this, "Rename", "Name", QLineEdit::Normal, info.fileName(), &accepted); + const QString entryName = name.trimmed(); + if (!accepted || entryName == info.fileName()) { + return; + } + if (!isValidEntryName(entryName)) { + QMessageBox::warning(this, "Rename", "Enter a valid file name."); + return; + } + if (!QDir(info.absolutePath()).rename(info.fileName(), entryName)) { + QMessageBox::warning(this, "Rename", "The item could not be renamed."); + return; + } + gridView->setCurrentIndex( + model->index(QDir(info.absolutePath()).filePath(entryName))); +} + +void ContentBrowserPanel::deleteSelection() { + const QModelIndexList selected = + gridView->selectionModel()->selectedIndexes(); + if (selected.isEmpty()) { + return; + } + for (const QModelIndex &index : selected) { + const QFileInfo info = model->fileInfo(index); + if (info.isSymLink()) { + QFile::remove(info.absoluteFilePath()); + } else if (info.isDir()) { + QDir(info.absoluteFilePath()).removeRecursively(); + } else { + QFile::remove(info.absoluteFilePath()); + } + } +} + +void ContentBrowserPanel::focusSearch() { + searchField->setFocus(); + searchField->selectAll(); +} + +void ContentBrowserPanel::copySelection() { + clipboardPaths.clear(); + for (const QModelIndex &index : + gridView->selectionModel()->selectedIndexes()) { + clipboardPaths.append(model->filePath(index)); + } + cutClipboard = false; +} + +void ContentBrowserPanel::cutSelection() { + copySelection(); + cutClipboard = true; +} + +void ContentBrowserPanel::pasteSelection() { + for (const QString &source : std::as_const(clipboardPaths)) { + const QFileInfo info(source); + QString destination = QDir(currentPath).filePath(info.fileName()); + if (QFileInfo(destination).exists()) + destination = uniquePath(info.completeBaseName() + " Copy" + + (info.suffix().isEmpty() + ? QString() + : "." + info.suffix())); + if (cutClipboard) { + QDir().rename(source, destination); + } else { + copyEntry(source, destination); + } + } + if (cutClipboard) + clipboardPaths.clear(); + refreshAssets(); +} + +void ContentBrowserPanel::duplicateSelection() { + copySelection(); + pasteSelection(); +} + +void ContentBrowserPanel::refreshAssets() { + model->setRootPath(QString()); + model->setRootPath(projectRoot); + gridView->setRootIndex(model->index(currentPath)); +} + +void ContentBrowserPanel::selectAllAssets() { gridView->selectAll(); } + +void ContentBrowserPanel::revealSelection() const { + const QString path = + selectedPath().isEmpty() ? currentPath : selectedPath(); +#ifdef Q_OS_MACOS + QProcess::startDetached("/usr/bin/open", {"-R", path}); +#elif defined(Q_OS_WIN) + QProcess::startDetached("explorer.exe", + {"/select,", QDir::toNativeSeparators(path)}); +#else + const QFileInfo info(path); + QDesktopServices::openUrl( + QUrl::fromLocalFile(info.isDir() ? path : info.absolutePath())); +#endif +} + +void ContentBrowserPanel::copySelectionPath() const { + const QString path = + selectedPath().isEmpty() ? currentPath : selectedPath(); + QApplication::clipboard()->setText(path); +} + +QString ContentBrowserPanel::selectedPath() const { + const QModelIndex index = gridView->currentIndex(); + return index.isValid() && gridView->selectionModel()->isSelected(index) + ? model->filePath(index) + : QString(); +} + +QString ContentBrowserPanel::uniquePath(const QString &baseName) const { + const QFileInfo base(baseName); + QString path = QDir(currentPath).filePath(baseName); + int suffix = 2; + while (QFileInfo::exists(path)) { + const QString name = + base.completeBaseName() + ' ' + QString::number(suffix++) + + (base.suffix().isEmpty() ? QString() : '.' + base.suffix()); + path = QDir(currentPath).filePath(name); + } + return path; +} + +bool ContentBrowserPanel::isInsideProject(const QString &path) const { + const QString root = QDir::cleanPath(projectRoot); + const QString candidate = QDir::cleanPath(path); + return candidate == root || candidate.startsWith(root + QDir::separator()); +} + +void ContentBrowserPanel::updateNavigationState() { + backButton->setEnabled(historyIndex > 0); + forwardButton->setEnabled(historyIndex + 1 < history.size()); + upButton->setEnabled(currentPath != projectRoot); + pathField->setText( + QDir(projectRoot).relativeFilePath(currentPath) == "." + ? QFileInfo(projectRoot).fileName() + : QFileInfo(projectRoot).fileName() + '/' + + QDir(projectRoot).relativeFilePath(currentPath)); + const bool hasSelection = !selectedPath().isEmpty(); + moreButton->setEnabled(hasSelection); +} diff --git a/editor/views/general/projectBrowser.cpp b/editor/views/general/projectBrowser.cpp new file mode 100644 index 00000000..0c5924f0 --- /dev/null +++ b/editor/views/general/projectBrowser.cpp @@ -0,0 +1,564 @@ +#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 + +#ifndef ATLAS_VERSION +#define ATLAS_VERSION "Alpha 9" +#endif + +namespace { +constexpr int ProjectPathRole = Qt::UserRole; +constexpr int ProjectAvailableRole = Qt::UserRole + 1; + +class TemplateCard : public QFrame { +public: + TemplateCard(const QString& title, const QString& description, + styling::Icon icon, const QColor& color, + QWidget* parent = nullptr) + : QFrame(parent) { + setProperty("templateCard", true); + setProperty("selected", false); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setMinimumHeight(112); + setCursor(Qt::PointingHandCursor); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(16, 14, 16, 14); + layout->setSpacing(8); + auto* heading = new QHBoxLayout(); + heading->setSpacing(8); + auto* iconLabel = new QLabel(this); + iconLabel->setObjectName("templateIcon"); + iconLabel->setPixmap(styling::icon(icon, color).pixmap(24, 24)); + iconLabel->setFixedSize(28, 28); + iconLabel->setAlignment(Qt::AlignCenter); + option = new QRadioButton(title, this); + option->setObjectName("templateOption"); + option->setCursor(Qt::PointingHandCursor); + heading->addWidget(iconLabel); + heading->addWidget(option, 1); + layout->addLayout(heading); + auto* descriptionLabel = new QLabel(description, this); + descriptionLabel->setObjectName("templateDescription"); + descriptionLabel->setWordWrap(true); + descriptionLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + layout->addWidget(descriptionLabel); + layout->addStretch(); + connect(option, &QRadioButton::toggled, this, [this](bool selected) { + setProperty("selected", selected); + style()->unpolish(this); + style()->polish(this); + update(); + }); + } + + QRadioButton* button() const { + return option; + } + +protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + option->setChecked(true); + } + QFrame::mousePressEvent(event); + } + +private: + QRadioButton* option = nullptr; +}; + +class CreateProjectDialog : public QDialog { +public: + explicit CreateProjectDialog(QWidget* parent = nullptr) + : QDialog(parent) { + setWindowTitle("Create an Atlas project"); + setModal(true); + setMinimumWidth(760); + setObjectName("createProjectDialog"); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(28, 26, 28, 24); + root->setSpacing(18); + + auto* title = new QLabel("Create a new project", this); + title->setObjectName("dialogTitle"); + root->addWidget(title); + auto* subtitle = new QLabel( + "Choose a renderer template. You can change these settings later.", + this); + subtitle->setObjectName("dialogSubtitle"); + root->addWidget(subtitle); + + auto* templateLayout = new QHBoxLayout(); + templateLayout->setSpacing(12); + templateGroup = new QButtonGroup(this); + templateGroup->setExclusive(true); + auto* pbr = new TemplateCard( + "PBR", "Deferred physically based rendering for most 3D projects.", + styling::Icon::Cube, "#8498A8", this); + auto* ddgi = new TemplateCard( + "PBR + DDGI", + "PBR with dynamic diffuse global illumination enabled.", + styling::Icon::Sun, "#A1957D", this); + auto* pathTracing = new TemplateCard( + "Path Tracing", + "Progressive ray-traced lighting for high-fidelity scenes.", + styling::Icon::Aperture, "#9E897D", this); + templateGroup->addButton(pbr->button(), + static_cast(AtlasProjectTemplate::Pbr)); + templateGroup->addButton( + ddgi->button(), static_cast(AtlasProjectTemplate::PbrDdgi)); + templateGroup->addButton( + pathTracing->button(), + static_cast(AtlasProjectTemplate::PathTracing)); + pbr->button()->setChecked(true); + templateLayout->addWidget(pbr); + templateLayout->addWidget(ddgi); + templateLayout->addWidget(pathTracing); + root->addLayout(templateLayout); + + auto* fields = new QVBoxLayout(); + fields->setSpacing(8); + auto* nameLabel = new QLabel("Project name", this); + nameLabel->setObjectName("fieldLabel"); + fields->addWidget(nameLabel); + nameField = new QLineEdit(this); + nameField->setPlaceholderText("My Atlas Project"); + nameField->setClearButtonEnabled(true); + fields->addWidget(nameField); + + auto* locationLabel = new QLabel("Location", this); + locationLabel->setObjectName("fieldLabel"); + fields->addWidget(locationLabel); + auto* locationLayout = new QHBoxLayout(); + locationField = new QLineEdit(this); + QString defaultLocation = QStandardPaths::writableLocation( + QStandardPaths::DocumentsLocation); + if (defaultLocation.isEmpty()) { + defaultLocation = QDir::homePath(); + } + locationField->setText(defaultLocation); + locationLayout->addWidget(locationField, 1); + auto* browse = new QPushButton("Browse…", this); + browse->setProperty("secondary", true); + browse->setIcon( + styling::icon(styling::Icon::FolderOpen, "#7E929C")); + locationLayout->addWidget(browse); + fields->addLayout(locationLayout); + root->addLayout(fields); + + errorLabel = new QLabel(this); + errorLabel->setObjectName("dialogError"); + errorLabel->setWordWrap(true); + errorLabel->hide(); + root->addWidget(errorLabel); + + auto* actions = new QHBoxLayout(); + actions->addStretch(); + auto* cancel = new QPushButton("Cancel", this); + cancel->setProperty("secondary", true); + actions->addWidget(cancel); + createButton = new QPushButton("Create project", this); + createButton->setObjectName("primaryAction"); + createButton->setIcon( + styling::icon(styling::Icon::RocketLaunch, "#FFFFFF")); + createButton->setDefault(true); + createButton->setEnabled(false); + actions->addWidget(createButton); + root->addLayout(actions); + + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + connect(browse, &QPushButton::clicked, this, [this] { + const QString directory = QFileDialog::getExistingDirectory( + this, "Choose a project location", locationField->text()); + if (!directory.isEmpty()) { + locationField->setText(directory); + } + }); + const auto updateAvailability = [this] { + createButton->setEnabled(!nameField->text().trimmed().isEmpty() && + QDir(locationField->text()).exists()); + errorLabel->hide(); + }; + connect(nameField, &QLineEdit::textChanged, this, + updateAvailability); + connect(locationField, &QLineEdit::textChanged, this, + updateAvailability); + connect(createButton, &QPushButton::clicked, this, [this] { + QString error; + const auto projectTemplate = static_cast( + templateGroup->checkedId()); + createdProjectFile = ProjectStore::createProject( + nameField->text(), locationField->text(), projectTemplate, + &error); + if (createdProjectFile.isEmpty()) { + errorLabel->setText(error); + errorLabel->show(); + return; + } + accept(); + }); + nameField->setFocus(); + } + + QString projectFile() const { + return createdProjectFile; + } + +private: + QButtonGroup* templateGroup = nullptr; + QLineEdit* nameField = nullptr; + QLineEdit* locationField = nullptr; + QLabel* errorLabel = nullptr; + QPushButton* createButton = nullptr; + QString createdProjectFile; +}; + +class ProjectRow : public QFrame { +public: + explicit ProjectRow(const AtlasProjectInfo& project, + QWidget* parent = nullptr) + : QFrame(parent) { + setObjectName("projectRow"); + setProperty("available", project.available); + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(16, 12, 12, 12); + layout->setSpacing(14); + + auto* projectIcon = new QLabel(this); + projectIcon->setObjectName("projectIcon"); + projectIcon->setPixmap( + styling::icon(styling::Icon::GameController, "#8498A8") + .pixmap(30, 30)); + projectIcon->setFixedSize(36, 36); + projectIcon->setAlignment(Qt::AlignCenter); + layout->addWidget(projectIcon); + + auto* copy = new QVBoxLayout(); + copy->setSpacing(3); + auto* title = new QLabel(project.name, this); + title->setObjectName("projectName"); + copy->addWidget(title); + auto* path = new QLabel(project.directory, this); + path->setObjectName("projectPath"); + path->setTextInteractionFlags(Qt::TextSelectableByMouse); + copy->addWidget(path); + layout->addLayout(copy, 1); + + auto* renderer = new QLabel(project.renderer, this); + renderer->setObjectName("rendererBadge"); + layout->addWidget(renderer); + + auto* date = new QLabel( + project.lastModified.isValid() + ? project.lastModified.toString("d MMM yyyy") + : QStringLiteral("Unavailable"), + this); + date->setObjectName("projectDate"); + date->setMinimumWidth(90); + layout->addWidget(date); + + moreButton = new QToolButton(this); + moreButton->setObjectName("projectMoreButton"); + moreButton->setIcon( + styling::icon(styling::Icon::DotsVertical, "#8490A4")); + moreButton->setToolTip("Project options"); + layout->addWidget(moreButton); + } + + QToolButton* optionsButton() const { + return moreButton; + } + +private: + QToolButton* moreButton = nullptr; +}; +} + +ProjectBrowser::ProjectBrowser(QWidget* parent) + : QMainWindow(parent) { + setWindowTitle("Atlas Engine — Projects"); + setMinimumSize(900, 580); + resize(1120, 720); + setupUi(); + reloadProjects(); +} + +void ProjectBrowser::setupUi() { + auto* root = new QWidget(this); + root->setObjectName("projectBrowserRoot"); + setCentralWidget(root); + auto* rootLayout = new QHBoxLayout(root); + rootLayout->setContentsMargins(0, 0, 0, 0); + rootLayout->setSpacing(0); + + auto* sidebar = new QFrame(root); + sidebar->setObjectName("projectSidebar"); + sidebar->setFixedWidth(224); + auto* sidebarLayout = new QVBoxLayout(sidebar); + sidebarLayout->setContentsMargins(22, 26, 22, 22); + sidebarLayout->setSpacing(18); + + auto* brandLayout = new QHBoxLayout(); + brandLayout->setSpacing(11); + auto* brandIcon = new QLabel(sidebar); + brandIcon->setFixedSize(38, 38); + brandIcon->setPixmap( + QPixmap(":/editor/assets/Icon-iOS-Default-1024x1024@1x.png") + .scaled(brandIcon->size(), Qt::KeepAspectRatio, + Qt::SmoothTransformation)); + brandLayout->addWidget(brandIcon); + auto* brandCopy = new QVBoxLayout(); + brandCopy->setSpacing(0); + auto* brand = new QLabel("Atlas Engine", sidebar); + brand->setObjectName("projectBrand"); + brandCopy->addWidget(brand); + auto* brandVersion = new QLabel(QStringLiteral(ATLAS_VERSION), sidebar); + brandVersion->setObjectName("projectSidebarVersion"); + brandCopy->addWidget(brandVersion); + brandLayout->addLayout(brandCopy); + brandLayout->addStretch(); + sidebarLayout->addLayout(brandLayout); + + auto* projectsNav = new QPushButton("Projects", sidebar); + projectsNav->setObjectName("projectNavSelected"); + projectsNav->setIcon( + styling::icon(styling::Icon::SquaresFour, "#8498A8")); + projectsNav->setEnabled(false); + sidebarLayout->addWidget(projectsNav); + sidebarLayout->addStretch(); + + rootLayout->addWidget(sidebar); + + auto* content = new QWidget(root); + content->setObjectName("projectBrowserContent"); + auto* contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(34, 30, 34, 30); + contentLayout->setSpacing(20); + + auto* headingLayout = new QHBoxLayout(); + auto* headingCopy = new QVBoxLayout(); + headingCopy->setSpacing(4); + auto* title = new QLabel("Projects", content); + title->setObjectName("projectBrowserTitle"); + headingCopy->addWidget(title); + auto* subtitle = new QLabel( + "Create a project or continue where you left off.", content); + subtitle->setObjectName("projectBrowserSubtitle"); + headingCopy->addWidget(subtitle); + headingLayout->addLayout(headingCopy, 1); + + auto* openButton = new QPushButton("Open existing", content); + openButton->setProperty("secondary", true); + openButton->setIcon( + styling::icon(styling::Icon::FolderOpen, "#7E929C")); + headingLayout->addWidget(openButton); + auto* createButton = new QPushButton("New project", content); + createButton->setObjectName("primaryAction"); + createButton->setIcon( + styling::icon(styling::Icon::Plus, "#FFFFFF")); + headingLayout->addWidget(createButton); + contentLayout->addLayout(headingLayout); + + searchField = new QLineEdit(content); + searchField->setObjectName("projectSearch"); + searchField->setPlaceholderText("Search projects"); + searchField->setClearButtonEnabled(true); + contentLayout->addWidget(searchField); + + projectStack = new QStackedWidget(content); + projectList = new QListWidget(projectStack); + projectList->setObjectName("projectList"); + projectList->setSpacing(8); + projectList->setSelectionMode(QAbstractItemView::SingleSelection); + projectList->setContextMenuPolicy(Qt::CustomContextMenu); + projectList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + projectStack->addWidget(projectList); + + auto* empty = new QWidget(projectStack); + empty->setObjectName("projectEmptyState"); + auto* emptyLayout = new QVBoxLayout(empty); + emptyLayout->setContentsMargins(40, 40, 40, 40); + emptyLayout->addStretch(); + emptyTitle = new QLabel("No projects yet", empty); + emptyTitle->setObjectName("emptyStateTitle"); + emptyTitle->setAlignment(Qt::AlignCenter); + emptyLayout->addWidget(emptyTitle); + auto* emptySubtitle = new QLabel( + "Create your first Atlas project or open one from disk.", empty); + emptySubtitle->setObjectName("emptyStateSubtitle"); + emptySubtitle->setAlignment(Qt::AlignCenter); + emptyLayout->addWidget(emptySubtitle); + emptyLayout->addStretch(); + projectStack->addWidget(empty); + contentLayout->addWidget(projectStack, 1); + rootLayout->addWidget(content, 1); + + connect(createButton, &QPushButton::clicked, this, + &ProjectBrowser::createProject); + connect(openButton, &QPushButton::clicked, this, + &ProjectBrowser::openExistingProject); + connect(searchField, &QLineEdit::textChanged, this, + &ProjectBrowser::filterProjects); + connect(projectList, &QListWidget::itemDoubleClicked, this, + [this] { openSelectedProject(); }); + connect(projectList, &QListWidget::customContextMenuRequested, this, + &ProjectBrowser::showProjectMenu); +} + +void ProjectBrowser::reloadProjects() { + projectList->clear(); + const QList projects = ProjectStore::recentProjects(); + for (const AtlasProjectInfo& project : projects) { + auto* item = new QListWidgetItem(projectList); + item->setData(ProjectPathRole, project.projectFile); + item->setData(ProjectAvailableRole, project.available); + item->setSizeHint(QSize(0, 76)); + auto* row = new ProjectRow(project, projectList); + projectList->setItemWidget(item, row); + connect(row->optionsButton(), &QToolButton::clicked, this, + [this, item, row] { + projectList->setCurrentItem(item); + const QPoint menuPosition = projectList->viewport()->mapFromGlobal( + row->optionsButton()->mapToGlobal( + QPoint(0, row->optionsButton()->height()))); + showProjectMenu(menuPosition); + }); + } + filterProjects(searchField->text()); +} + +void ProjectBrowser::filterProjects(const QString& query) { + const QString normalized = query.trimmed(); + int visibleCount = 0; + for (int index = 0; index < projectList->count(); ++index) { + QListWidgetItem* item = projectList->item(index); + QWidget* row = projectList->itemWidget(item); + const QString path = item->data(ProjectPathRole).toString(); + const bool matches = + normalized.isEmpty() || path.contains(normalized, Qt::CaseInsensitive) || + (row != nullptr && + row->findChild("projectName") != nullptr && + row->findChild("projectName") + ->text() + .contains(normalized, Qt::CaseInsensitive)); + item->setHidden(!matches); + if (matches) { + ++visibleCount; + } + } + emptyTitle->setText(normalized.isEmpty() ? "No projects yet" + : "No matching projects"); + projectStack->setCurrentIndex(visibleCount > 0 ? 0 : 1); +} + +void ProjectBrowser::createProject() { + CreateProjectDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + reloadProjects(); + emit openProjectRequested(dialog.projectFile()); +} + +void ProjectBrowser::openExistingProject() { + const QString projectFile = QFileDialog::getOpenFileName( + this, "Open an Atlas project", QDir::homePath(), + "Atlas projects (*.atlas)"); + if (projectFile.isEmpty()) { + return; + } + if (!ProjectStore::isProjectFile(projectFile)) { + QMessageBox::warning(this, "Invalid project", + "Choose a readable .atlas project file."); + return; + } + ProjectStore::addRecentProject(projectFile); + reloadProjects(); + emit openProjectRequested(projectFile); +} + +void ProjectBrowser::openSelectedProject() { + QListWidgetItem* item = projectList->currentItem(); + if (item == nullptr) { + return; + } + const QString projectFile = item->data(ProjectPathRole).toString(); + if (!ProjectStore::isProjectFile(projectFile)) { + QMessageBox::warning( + this, "Project unavailable", + "Atlas could not find this project. Remove it from the list or " + "open it again from its current location."); + return; + } + ProjectStore::addRecentProject(projectFile); + emit openProjectRequested(projectFile); +} + +void ProjectBrowser::showProjectMenu(const QPoint& position) { + QListWidgetItem* item = projectList->itemAt(position); + if (item == nullptr) { + item = projectList->currentItem(); + } + if (item == nullptr) { + return; + } + projectList->setCurrentItem(item); + const QString projectFile = item->data(ProjectPathRole).toString(); + const bool available = ProjectStore::isProjectFile(projectFile); + + QMenu menu(this); + QAction* open = menu.addAction( + styling::icon(styling::Icon::GameController, "#8498A8"), + "Open project"); + open->setEnabled(available); + QAction* reveal = menu.addAction( + styling::icon(styling::Icon::FolderOpen, "#7E929C"), + "Show in Finder"); + reveal->setEnabled(QFileInfo::exists(QFileInfo(projectFile).absolutePath())); + menu.addSeparator(); + QAction* remove = menu.addAction( + styling::icon(styling::Icon::Trash, "#A17F7F"), + "Remove from list"); + QAction* selected = menu.exec(projectList->viewport()->mapToGlobal(position)); + if (selected == open) { + openSelectedProject(); + } else if (selected == reveal) { + QDesktopServices::openUrl( + QUrl::fromLocalFile(QFileInfo(projectFile).absolutePath())); + } else if (selected == remove) { + ProjectStore::removeRecentProject(projectFile); + reloadProjects(); + } +} diff --git a/editor/views/general/splashScreen.cpp b/editor/views/general/splashScreen.cpp new file mode 100644 index 00000000..54e7b4e7 --- /dev/null +++ b/editor/views/general/splashScreen.cpp @@ -0,0 +1,182 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef ATLAS_VERSION +#define ATLAS_VERSION "Alpha 9" +#endif + +#ifndef ATLAS_BUILD_STRING +#define ATLAS_BUILD_STRING "" +#endif + +SplashScreen::SplashScreen(QWidget *parent) + : QDialog(parent, Qt::SplashScreen | Qt::FramelessWindowHint) { + setAttribute(Qt::WA_TranslucentBackground); + setModal(true); + setObjectName("atlasSplash"); + setFixedSize(708, 252); + + auto *card = new QFrame(this); + card->setObjectName("splashCard"); + card->setGeometry(0, 0, 708, 252); + auto *shadow = new QGraphicsDropShadowEffect(card); + shadow->setBlurRadius(20.0); + shadow->setOffset(0.0, 5.0); + shadow->setColor(QColor(0, 0, 0, 64)); + card->setGraphicsEffect(shadow); + + auto *icon = new QLabel(card); + icon->setObjectName("splashIcon"); + icon->setGeometry(30, 38, 108, 108); +#ifdef ATLAS_DEBUG_BUILD + icon->setPixmap(QPixmap(":/editor/assets/Icon-iOS-Default-1024x1024@1x.png") + .scaled(icon->size(), Qt::KeepAspectRatio, + Qt::SmoothTransformation)); +#else + icon->setPixmap( + QPixmap(":/editor/assets/iconFile-iOS-Dark-1024x1024@1x.png") + .scaled(icon->size(), Qt::KeepAspectRatio, + Qt::SmoothTransformation)); +#endif + + auto *title = new QLabel(card); + title->setObjectName("splashTitle"); + title->setGeometry(164, 42, 514, 44); + + QFont titleFont = title->font(); + titleFont.setLetterSpacing(QFont::AbsoluteSpacing, -0.3); + titleFont.setKerning(true); +#ifdef ATLAS_DEBUG_BUILD + title->setText("Atlas Engine (Development)"); + title->setFont(titleFont); +#else + title->setText("Atlas Engine"); +#endif + title->setWordWrap(false); + title->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + + auto *version = new QLabel(card); + version->setObjectName("splashVersion"); + version->setGeometry(164, 86, 500, 28); +#ifdef ATLAS_DEBUG_BUILD + version->setText(QStringLiteral("%1 (build %2)") + .arg(QStringLiteral(ATLAS_VERSION), + QStringLiteral(ATLAS_BUILD_STRING))); +#else + version->setText(QStringLiteral(ATLAS_VERSION)); +#endif + version->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + + auto *company = new QLabel("by neutral software", card); + company->setObjectName("splashCompany"); + company->setGeometry(164, 114, 400, 22); + company->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + + statusLabel = new QLabel("Loading the engine…", card); + statusLabel->setObjectName("splashStatus"); + statusLabel->setGeometry(164, 151, 500, 22); + statusLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + + auto *progress = new QProgressBar(card); + progress->setObjectName("splashProgress"); + progress->setGeometry(164, 181, 500, 5); + progress->setRange(0, 0); + progress->setTextVisible(false); + +#ifdef ATLAS_DEBUG_BUILD + auto *warning = new QLabel( + "As this software is in its development version issues may be found " + "with the experience. If you meant to use the traditional version " + "please access: https://atlasengine.org to get the official builds. " + "In development versions, the engine may require you to have a " + "runtime already installed therefore, make sure that you have an " + "appropriate runtime in your system that works with this version.", + card); + warning->setObjectName("splashWarning"); + warning->setGeometry(40, 199, 628, 44); + warning->setWordWrap(true); + warning->setAlignment(Qt::AlignLeft | Qt::AlignTop); +#endif + + setStyleSheet(R"( +#atlasSplash { + background: transparent; +} +#splashCard { + background: #202224; + border: 1px solid #494D52; + border-radius: 18px; +} +#splashTitle { + background: transparent; + color: #F5F7FA; + font-size: 34px; + font-weight: 750; +} +#splashVersion { + background: transparent; + color: #8498A8; + font-size: 14px; + font-weight: 700; +} +#splashCompany { + background: transparent; + color: #7F8B9D; + font-family: "Manrope"; + font-size: 12px; + font-weight: 650; +} +#splashStatus { + background: transparent; + color: #B7C1CF; + font-size: 10px; + font-weight: 550; +} +#splashProgress { + background: #34373A; + border: none; + border-radius: 2px; +} +#splashProgress::chunk { + background: #71889A; + border-radius: 2px; +} +#splashWarning { + background: transparent; + color: #68758A; + font-size: 9px; + font-weight: 450; +} +)"); +} + +void SplashScreen::start(const QString &statusText) { + setStatus(statusText); + const QRect available = + QGuiApplication::primaryScreen()->availableGeometry(); + move(available.left() + (available.width() - width()) / 2, + available.top() + (available.height() - height()) / 2); + show(); + raise(); +} + +void SplashScreen::setStatus(const QString &statusText) { + QString displayStatus = statusText; + displayStatus.replace(QChar(0x2026), "..."); + statusLabel->setText(displayStatus); + statusLabel->repaint(); +} + +void SplashScreen::finish() { + hide(); + emit ready(); +} diff --git a/editor/vite.config.ts b/editor/vite.config.ts deleted file mode 100644 index d9513590..00000000 --- a/editor/vite.config.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import path from "node:path"; - -export default defineConfig({ - root: "src/renderer", - base: "./", - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@shared": path.resolve(__dirname, "src/shared"), - }, - }, - build: { - outDir: "../../dist/renderer", - emptyOutDir: true, - }, - server: { - port: 5173, - strictPort: true, - }, -}); diff --git a/finewave/engine.cpp b/finewave/engine.cpp index a2906578..efe32e50 100644 --- a/finewave/engine.cpp +++ b/finewave/engine.cpp @@ -44,8 +44,11 @@ const char *getALErrorStringSourceEngine(ALenum error) { } bool AudioEngine::initialize() { + if (context != nullptr) { + return true; + } atlas_log("Initializing audio engine"); - ALCdevice *device = alcOpenDevice(nullptr); + device = alcOpenDevice(nullptr); if (device == nullptr) { atlas_error("Failed to open OpenAL device"); return false; @@ -55,13 +58,15 @@ bool AudioEngine::initialize() { if (alcError != ALC_NO_ERROR) { std::cout << "OpenAL error: " << alcError << std::endl; alcCloseDevice(device); + device = nullptr; return false; } - ALCcontext *context = alcCreateContext(device, nullptr); + context = alcCreateContext(device, nullptr); if (context == nullptr) { atlas_error("Failed to create OpenAL context"); alcCloseDevice(device); + device = nullptr; return false; } alcError = alcGetError(device); @@ -70,12 +75,16 @@ bool AudioEngine::initialize() { << std::endl; alcDestroyContext(context); alcCloseDevice(device); + context = nullptr; + device = nullptr; return false; } if (!alcMakeContextCurrent(context)) { alcDestroyContext(context); alcCloseDevice(device); + context = nullptr; + device = nullptr; return false; } @@ -83,8 +92,11 @@ bool AudioEngine::initialize() { if (alcError != ALC_NO_ERROR) { std::cerr << "ALC error after making context current: " << alcError << std::endl; + alcMakeContextCurrent(nullptr); alcDestroyContext(context); alcCloseDevice(device); + context = nullptr; + device = nullptr; return false; } @@ -111,19 +123,24 @@ bool AudioEngine::initialize() { } void AudioEngine::shutdown() { - ALCcontext *context = alcGetCurrentContext(); - ALCdevice *device = alcGetContextsDevice(context); - if (context != nullptr) { - alcMakeContextCurrent(nullptr); + if (alcGetCurrentContext() == context) { + alcMakeContextCurrent(nullptr); + } alcDestroyContext(context); + context = nullptr; } if (device != nullptr) { alcCloseDevice(device); + device = nullptr; } } +AudioEngine::~AudioEngine() { + shutdown(); +} + void AudioEngine::setListenerPosition(Position3d position) { alListener3f(AL_POSITION, static_cast(position.x), static_cast(position.y), @@ -146,4 +163,4 @@ void AudioEngine::setListenerVelocity(Magnitude3d velocity) { alListener3f(AL_VELOCITY, static_cast(velocity.x), static_cast(velocity.y), static_cast(velocity.z)); -} \ No newline at end of file +} diff --git a/graphite/text.cpp b/graphite/text.cpp index c5b9f524..0c367268 100644 --- a/graphite/text.cpp +++ b/graphite/text.cpp @@ -365,7 +365,10 @@ void Text::render(float dt, std::shared_ptr commandBuffer, if (TracerServices::getInstance().isOk()) { DebugObjectPacket debugPacket{}; debugPacket.drawCallsForObject = 1; - debugPacket.frameCount = Window::mainWindow->device->frameCount; + debugPacket.frameCount = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; debugPacket.triangleCount = static_cast(glyphCount) * 2; debugPacket.vertexBufferSizeMb = static_cast(requiredBytes) / (1024.0f * 1024.0f); diff --git a/hydra/fluid.cpp b/hydra/fluid.cpp index 59608a61..c5a77f0a 100644 --- a/hydra/fluid.cpp +++ b/hydra/fluid.cpp @@ -201,7 +201,10 @@ void Fluid::render(float dt, std::shared_ptr commandBuffer, if (TracerServices::getInstance().isOk()) { DebugObjectPacket debugPacket{}; debugPacket.drawCallsForObject = 1; - debugPacket.frameCount = Window::mainWindow->device->frameCount; + debugPacket.frameCount = + Window::mainWindow != nullptr && Window::mainWindow->device != nullptr + ? Window::mainWindow->device->frameCount + : 0; debugPacket.triangleCount = indices.size() / 3; debugPacket.vertexBufferSizeMb = static_cast(sizeof(FluidVertex) * vertices.size()) / diff --git a/include/atlas/component.h b/include/atlas/component.h index 74adc727..6d22b32b 100644 --- a/include/atlas/component.h +++ b/include/atlas/component.h @@ -22,6 +22,7 @@ #include #include #include +#include #include class CoreObject; @@ -223,6 +224,7 @@ class GameObject : public Renderable { std::uniform_int_distribution dist(0, INT_MAX); id = dist(gen); + name = other.name; copyComponents(other); dependencies = other.dependencies; @@ -235,6 +237,7 @@ class GameObject : public Renderable { std::uniform_int_distribution dist(0, INT_MAX); id = dist(gen); + name = std::move(other.name); moveComponents(std::move(other)); dependencies = std::move(other.dependencies); @@ -243,6 +246,7 @@ class GameObject : public Renderable { GameObject &operator=(const GameObject &other) { if (this != &other) { + name = other.name; copyComponents(other); dependencies = other.dependencies; atlas::gameObjects[id] = this; @@ -252,6 +256,7 @@ class GameObject : public Renderable { GameObject &operator=(GameObject &&other) noexcept { if (this != &other) { + name = std::move(other.name); moveComponents(std::move(other)); dependencies = std::move(other.dependencies); atlas::gameObjects[id] = this; @@ -440,6 +445,8 @@ class GameObject : public Renderable { */ unsigned int getId() override { return id; } + std::string name; + /** @brief Returns object rotation in Euler angles. */ virtual Rotation3d getRotation() const { return Rotation3d(0.f, 0.f, 0.f); } diff --git a/include/atlas/core/renderable.h b/include/atlas/core/renderable.h index 33ed413c..5afc1664 100644 --- a/include/atlas/core/renderable.h +++ b/include/atlas/core/renderable.h @@ -182,6 +182,12 @@ class Renderable { * pass and rendered only after all standard forward elements. */ bool renderLateForward = false; + + /** + * @brief Whether the object is an editor-only object (like light debug markers). + * Objects flagged here will not be rendered if editor controls are disabled. + */ + bool editorOnly = false; }; #endif // ATLAS_RENDERABLE_H diff --git a/include/atlas/particle.h b/include/atlas/particle.h index 42fe324d..86c22c53 100644 --- a/include/atlas/particle.h +++ b/include/atlas/particle.h @@ -220,10 +220,13 @@ class ParticleEmitter : public GameObject { * @brief Moves the emitter by a relative offset. */ void move(const Position3d &deltaPosition) override; + void setRotation(const Rotation3d &newRotation) override; + void rotate(const Rotation3d &deltaRotation) override; /** * @brief Returns the emitter world-space origin. */ Position3d getPosition() const override { return position; }; + Rotation3d getRotation() const override { return rotation; }; /** * @brief Particle emitters do not cast scene shadows. */ @@ -340,6 +343,7 @@ class ParticleEmitter : public GameObject { glm::mat4 model = glm::mat4(1.0f); Position3d position = {0.0, 0.0, 0.0}; + Rotation3d rotation = {0.0, 0.0, 0.0}; std::optional firstCameraPosition = std::nullopt; void spawnParticle(); diff --git a/include/atlas/runtime/c_api.h b/include/atlas/runtime/c_api.h index 6b69eef1..753ec4da 100644 --- a/include/atlas/runtime/c_api.h +++ b/include/atlas/runtime/c_api.h @@ -54,10 +54,30 @@ bool atlas_runtime_set_editor_control_mode(void *runtimeContext, int mode); bool atlas_runtime_editor_pointer_event(void *runtimeContext, int action, float x, float y, int button, float scale); +bool atlas_runtime_editor_scroll_event(void *runtimeContext, float delta, + float scale); bool atlas_runtime_editor_key_event(void *runtimeContext, int key, bool pressed); int atlas_runtime_get_selected_object_id(void *runtimeContext); const char *atlas_runtime_get_selected_object_name(void *runtimeContext); +const char *atlas_runtime_get_scene_objects(void *runtimeContext); +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); +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); +int atlas_runtime_create_object(void *runtimeContext, const char *type, + const char *name); +bool atlas_runtime_save_current_scene(void *runtimeContext); /** * @brief Requests shutdown and releases frame-loop resources. diff --git a/include/atlas/runtime/context.h b/include/atlas/runtime/context.h index 25d7dcb4..785ecd6f 100644 --- a/include/atlas/runtime/context.h +++ b/include/atlas/runtime/context.h @@ -30,7 +30,7 @@ class Context; class RuntimeScene : public Scene { public: - std::shared_ptr context; + std::weak_ptr context; void update(Window &window) override; void initialize(Window &window) override; @@ -53,9 +53,11 @@ class ProjectConfig { class Context { public: Context() = default; + ~Context(); std::string projectFile; std::string projectDir; std::string sceneDir; + std::string currentSceneFile; std::string currentSceneName; std::shared_ptr scene; @@ -74,11 +76,34 @@ class Context { std::vector> areaLights; std::vector cameraActions; bool cameraAutomaticMoving = false; + bool editorRuntime = false; std::unique_ptr window; std::vector> objects; + std::vector> retiredObjects; std::unordered_map objectReferences; std::unordered_map objectNames; + std::unordered_map objectSceneReferences; + std::unordered_map objectSceneTypes; + std::unordered_map objectSceneSolidTypes; + std::unordered_map objectParentReferences; + 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; + std::unordered_map editorDirectionalLights; + std::unordered_map editorLightSourceData; + json editorCameraData = json::object(); + json editorTargetData = json::array(); + json editorEnvironmentData = json::object(); + json editorPropertySyncs = json::array(); + bool applyingPropertySyncs = false; + std::vector> deletedObjectReferences; ProjectConfig config; @@ -88,11 +113,44 @@ class Context { bool setEditorControlsEnabled(bool enabled); bool setEditorSimulationEnabled(bool enabled); bool setEditorControlMode(int mode); + bool setEditorShadingMode(int mode); + float frameRate() const; bool editorPointerEvent(int action, float x, float y, int button, float scale); + bool editorScrollEvent(float delta, float scale); bool editorKeyEvent(int key, bool pressed); + bool beginEditorKeyboardTransform(int mode, float x, float y, float scale); + bool setEditorKeyboardTransformAxes(int axes); + bool finishEditorKeyboardTransform(bool commit); + bool toggleEditorTransformSpace(); + bool toggleEditorTransformSnapping(); + float changeEditorTransformSnapIncrement(float factor); int selectedObjectId() const; std::string selectedObjectName() const; + std::string sceneObjectsJson() const; + bool selectObject(int id, bool focusCamera); + bool focusObjects(const std::vector &ids); + 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 setSceneProperty(const std::string §ion, int index, + const std::string &propertyPath, const json &value); + bool setPropertySync(const json &target, const json &source); + bool clearPropertySync(const json &target); + bool setObjectMaterial(int id, const std::string &path); + int addObjectComponent(int id, const json &component); + bool removeObjectComponent(int id, int componentIndex); + bool controlObjectAudio(int id, int componentIndex, + const std::string &action); + bool setObjectParent(int childId, int parentId); + bool deleteObject(int id); + int createObject(const std::string &type, const std::string &name); + std::string objectDefinitionJson(int id) const; + int pasteObjectDefinition(const std::string &definition); + bool saveCurrentScene(); + bool openSceneFile(const std::string &path); + std::string currentScenePath() const; void end(); void loadProject(); void loadMainScene(Window &window); @@ -104,6 +162,7 @@ class Context { namespace runtime { std::shared_ptr makeContext(std::string projectFile); +std::shared_ptr makeHiddenContext(std::string projectFile); std::shared_ptr makeContextForMetalView(std::string projectFile, void *metalView, CoreWindowReference sdlInputWindow = nullptr); diff --git a/include/atlas/scene.h b/include/atlas/scene.h index b7d117b5..f273fd95 100644 --- a/include/atlas/scene.h +++ b/include/atlas/scene.h @@ -240,7 +240,6 @@ class Scene { * @param light The directional light to add. */ void addDirectionalLight(DirectionalLight *light) { - directionalLights.clear(); directionalLights.push_back(light); } @@ -405,6 +404,7 @@ class Scene { friend class Terrain; friend class RenderTarget; friend struct Fluid; + friend class Context; }; #endif // ATLAS_SCENE_H diff --git a/include/atlas/units.h b/include/atlas/units.h index fecefbd6..72578208 100644 --- a/include/atlas/units.h +++ b/include/atlas/units.h @@ -14,7 +14,6 @@ #include #include #include -#include /** * @brief Structure representing a position in 3D space with double precision. @@ -42,21 +41,27 @@ struct Position3d { static Position3d back() { return Position3d(0.0f, 0.0f, -1.0f); } static Position3d right() { return Position3d(1.0f, 0.0f, 0.0f); } static Position3d left() { return Position3d(-1.0f, 0.0f, 0.0f); } + static Position3d invalid() { return Position3d(std::nanf(""), std::nanf(""), std::nanf("")); } - Position3d() : x(0.0f), y(0.0f), z(0.0f) {} - Position3d(float x, float y, float z) : x(x), y(y), z(z) {} + Position3d() : x(0.0f), y(0.0f), z(0.0f) { + } + + Position3d(float x, float y, float z) : x(x), y(y), z(z) { + } + Position3d(double x, double y, double z) : x(static_cast(x)), y(static_cast(y)), - z(static_cast(z)) {} + z(static_cast(z)) { + } - Position3d operator+(const Position3d &other) const { + Position3d operator+(const Position3d& other) const { return {x + other.x, y + other.y, z + other.z}; } - Position3d operator-(const Position3d &other) const { + Position3d operator-(const Position3d& other) const { return {x - other.x, y - other.y, z - other.z}; } @@ -68,27 +73,27 @@ struct Position3d { return {x / scalar, y / scalar, z / scalar}; } - bool operator==(const Position3d &other) const { + bool operator==(const Position3d& other) const { return x == other.x && y == other.y && z == other.z; } glm::vec3 toGlm() const { return glm::vec3(x, y, z); } - Position3d operator+(const glm::vec3 &vec) const { + Position3d operator+(const glm::vec3& vec) const { return {x + vec.x, y + vec.y, z + vec.z}; } - Position3d operator-(const glm::vec3 &vec) const { + Position3d operator-(const glm::vec3& vec) const { return {x - vec.x, y - vec.y, z - vec.z}; } - void operator+=(const Position3d &vec) { + void operator+=(const Position3d& vec) { x += vec.x; y += vec.y; z += vec.z; } - void operator-=(const Position3d &vec) { + void operator-=(const Position3d& vec) { x -= vec.x; y -= vec.y; z -= vec.z; @@ -101,14 +106,9 @@ struct Position3d { return {x / length, y / length, z / length}; } - static Position3d fromGlm(const glm::vec3 &vec) { + static Position3d fromGlm(const glm::vec3& vec) { return {vec.x, vec.y, vec.z}; }; - - friend std::ostream &operator<<(std::ostream &os, const Position3d &p) { - os << "Position3d(" << p.x << ", " << p.y << ", " << p.z << ")"; - return os; - } }; /** @@ -154,22 +154,25 @@ struct BoundingBox { /** @brief Maximum corner of the box (inclusive). */ Position3d max; - BoundingBox() : min(Position3d::zero()), max(Position3d::zero()) {} - BoundingBox(const Position3d &min, const Position3d &max) - : min(min), max(max) {} + BoundingBox() : min(Position3d::zero()), max(Position3d::zero()) { + } + + BoundingBox(const Position3d& min, const Position3d& max) + : min(min), max(max) { + } /** @brief Returns true when the point lies inside the bounds. */ - bool contains(const Position3d &point) const { + bool contains(const Position3d& point) const { return (point.x >= min.x && point.x <= max.x) && - (point.y >= min.y && point.y <= max.y) && - (point.z >= min.z && point.z <= max.z); + (point.y >= min.y && point.y <= max.y) && + (point.z >= min.z && point.z <= max.z); } /** @brief Returns true when two axis-aligned boxes overlap. */ - bool intersects(const BoundingBox &other) const { + bool intersects(const BoundingBox& other) const { return (min.x <= other.max.x && max.x >= other.min.x) && - (min.y <= other.max.y && max.y >= other.min.y) && - (min.z <= other.max.z && max.z >= other.min.z); + (min.y <= other.max.y && max.y >= other.min.y) && + (min.z <= other.max.z && max.z >= other.min.z); } }; @@ -189,23 +192,26 @@ struct BoundingBox { */ struct Rotation3d { float pitch; // Rotation around the X-axis - float yaw; // Rotation around the Y-axis - float roll; // Rotation around the Z-axis + float yaw; // Rotation around the Y-axis + float roll; // Rotation around the Z-axis - Rotation3d() : pitch(0.0f), yaw(0.0f), roll(0.0f) {} + Rotation3d() : pitch(0.0f), yaw(0.0f), roll(0.0f) { + } Rotation3d(float pitch, float yaw, float roll) - : pitch(pitch), yaw(yaw), roll(roll) {} + : pitch(pitch), yaw(yaw), roll(roll) { + } Rotation3d(double pitch, double yaw, double roll) : pitch(static_cast(pitch)), yaw(static_cast(yaw)), - roll(static_cast(roll)) {} + roll(static_cast(roll)) { + } - Rotation3d operator+(const Rotation3d &other) const { + Rotation3d operator+(const Rotation3d& other) const { return {pitch + other.pitch, yaw + other.yaw, roll + other.roll}; } - Rotation3d operator-(const Rotation3d &other) const { + Rotation3d operator-(const Rotation3d& other) const { return {pitch - other.pitch, yaw - other.yaw, roll - other.roll}; } @@ -213,7 +219,7 @@ struct Rotation3d { return {pitch * scalar, yaw * scalar, roll * scalar}; } - bool operator==(const Rotation3d &other) const { + bool operator==(const Rotation3d& other) const { return pitch == other.pitch && yaw == other.yaw && roll == other.roll; } @@ -235,7 +241,7 @@ struct Rotation3d { return qRoll * qPitch * qYaw; } - static Rotation3d fromGlmQuat(const glm::quat &quat) { + static Rotation3d fromGlmQuat(const glm::quat& quat) { glm::mat3 m = glm::mat3_cast(quat); float sPitch = glm::clamp(m[1][2], -1.0f, 1.0f); @@ -254,11 +260,13 @@ struct Rotation3d { rollRad = 0.0f; } - return {glm::degrees(pitchRad), glm::degrees(yawRad), - glm::degrees(rollRad)}; + return { + glm::degrees(pitchRad), glm::degrees(yawRad), + glm::degrees(rollRad) + }; } - static Rotation3d fromGlm(const glm::vec3 &vec) { + static Rotation3d fromGlm(const glm::vec3& vec) { return {vec.x, vec.y, vec.z}; } }; @@ -277,23 +285,17 @@ struct Quaternion { glm::quat toGlm() const { return glm::quat(w, x, y, z); } /** @brief Builds a Quaternion from a GLM quaternion. */ - static Quaternion fromGlm(const glm::quat &quat) { + static Quaternion fromGlm(const glm::quat& quat) { return {.x = quat.x, .y = quat.y, .z = quat.z, .w = quat.w}; } - friend std::ostream &operator<<(std::ostream &os, const Quaternion &q) { - os << "Quaternion(" << q.x << ", " << q.y << ", " << q.z << ", " << q.w - << ")"; - return os; - } - /** @brief Converts this quaternion to Euler rotation angles. */ - static Rotation3d toEuler(const Quaternion &quat) { + static Rotation3d toEuler(const Quaternion& quat) { return Rotation3d::fromGlmQuat(quat.toGlm()); } /** @brief Builds a quaternion from Euler rotation angles. */ - static Quaternion fromEuler(const Rotation3d &euler) { + static Quaternion fromEuler(const Rotation3d& euler) { return fromGlm(euler.toGlmQuat()); } }; @@ -321,38 +323,46 @@ struct Color { float b = 1.0; float a = 1.0; - Color operator+(const Color &other) const { - return {.r = r + other.r, - .g = g + other.g, - .b = b + other.b, - .a = a + other.a}; + Color operator+(const Color& other) const { + return { + .r = r + other.r, + .g = g + other.g, + .b = b + other.b, + .a = a + other.a + }; } - Color operator-(const Color &other) const { - return {.r = r - other.r, - .g = g - other.g, - .b = b - other.b, - .a = a - other.a}; + Color operator-(const Color& other) const { + return { + .r = r - other.r, + .g = g - other.g, + .b = b - other.b, + .a = a - other.a + }; } Color operator*(float scalar) const { return { - .r = r * scalar, .g = g * scalar, .b = b * scalar, .a = a * scalar}; + .r = r * scalar, .g = g * scalar, .b = b * scalar, .a = a * scalar + }; } - Color operator*(const Color &other) const { - return {.r = r * other.r, - .g = g * other.g, - .b = b * other.b, - .a = a * other.a}; + Color operator*(const Color& other) const { + return { + .r = r * other.r, + .g = g * other.g, + .b = b * other.b, + .a = a * other.a + }; } Color operator/(float scalar) const { return { - .r = r / scalar, .g = g / scalar, .b = b / scalar, .a = a / scalar}; + .r = r / scalar, .g = g / scalar, .b = b / scalar, .a = a / scalar + }; } - bool operator==(const Color &other) const { + bool operator==(const Color& other) const { return r == other.r && g == other.g && b == other.b && a == other.a; } @@ -361,9 +371,11 @@ struct Color { static Color red() { return {.r = 1.0, .g = 0.0, .b = 0.0, .a = 1.0}; } static Color green() { return {.r = 0.0, .g = 1.0, .b = 0.0, .a = 1.0}; } static Color blue() { return {.r = 0.0, .g = 0.0, .b = 1.0, .a = 1.0}; } + static Color transparent() { return {.r = 0.0, .g = 0.0, .b = 0.0, .a = 0.0}; } + static Color yellow() { return {.r = 1.0, .g = 1.0, .b = 0.0, .a = 1.0}; } static Color cyan() { return {.r = 0.0, .g = 1.0, .b = 1.0, .a = 1.0}; } static Color magenta() { return {.r = 1.0, .g = 0.0, .b = 1.0, .a = 1.0}; } @@ -447,11 +459,11 @@ struct Position2d { /** @brief Y coordinate in 2D space. */ float y; - Position2d operator+(const Position2d &other) const { + Position2d operator+(const Position2d& other) const { return {.x = x + other.x, .y = y + other.y}; } - Position2d operator-(const Position2d &other) const { + Position2d operator-(const Position2d& other) const { return {.x = x - other.x, .y = y - other.y}; } @@ -503,11 +515,11 @@ using Magnitude2d = Position2d; struct Radians { float value; - Radians operator+(const Radians &other) const { + Radians operator+(const Radians& other) const { return {value + other.value}; } - Radians operator-(const Radians &other) const { + Radians operator-(const Radians& other) const { return {value - other.value}; } @@ -542,11 +554,11 @@ struct Size2d { float width; float height; - Size2d operator+(const Size2d &other) const { + Size2d operator+(const Size2d& other) const { return {.width = width + other.width, .height = height + other.height}; } - Size2d operator-(const Size2d &other) const { + Size2d operator-(const Size2d& other) const { return {.width = width - other.width, .height = height - other.height}; } diff --git a/include/atlas/window.h b/include/atlas/window.h index eb4f6a9e..a08026dd 100644 --- a/include/atlas/window.h +++ b/include/atlas/window.h @@ -47,6 +47,12 @@ enum class EditorControlMode { Scale = 3, }; +enum class EditorShadingMode { + Lit = 0, + Wireframe = 1, + Points = 2, +}; + /** * @brief Structure representing the configuration options for creating a * window. @@ -155,6 +161,7 @@ struct WindowConfiguration { CoreWindowReference sdlInputWindow = nullptr; bool editorControls = false; + bool showHostWindow = true; }; /** @@ -437,11 +444,24 @@ class Window { bool isEditorSimulationEnabled() const { return editorSimulationEnabled; } void setEditorControlMode(EditorControlMode mode); EditorControlMode getEditorControlMode() const { return editorControlMode; } + void setEditorShadingMode(EditorShadingMode mode); + EditorShadingMode getEditorShadingMode() const { return editorShadingMode; } void editorPointerEvent(int action, float x, float y, int button, float scale = 1.0f); + void editorScrollEvent(float delta, float scale = 1.0f); void editorKeyEvent(int key, bool pressed); + bool beginEditorKeyboardTransform(EditorControlMode mode, float x, float y, + float scale = 1.0f); + void setEditorKeyboardTransformAxes(int axes); + void finishEditorKeyboardTransform(bool commit); + bool toggleEditorTransformSpace(); + bool toggleEditorTransformSnapping(); + float changeEditorTransformSnapIncrement(float factor); GameObject *getSelectedEditorObject() const { return selectedEditorObject; } unsigned int getSelectedEditorObjectId() const; + void selectEditorObject(GameObject *object, bool focusCamera = false); + void focusEditorObjects(const std::vector &objects); + void setEditorObjectParent(GameObject *child, GameObject *parent); /** * @brief Tears down state created by stepFrame()/run(). */ @@ -618,6 +638,9 @@ class Window { * @param target The render target to add. */ void addRenderTarget(RenderTarget *target); + void removeRenderTarget(RenderTarget *target); + void setHostWindowVisible(bool visible); + void setDefaultFramebufferRenderingEnabled(bool enabled); /** * @brief Gets the framebuffer size of the window. @@ -821,6 +844,7 @@ class Window { std::vector lateFluids; std::vector renderTargets; std::shared_ptr screenRenderTarget; + std::unique_ptr modeScreenTarget; std::shared_ptr gBuffer; std::shared_ptr ssaoBuffer; @@ -874,11 +898,20 @@ class Window { void updateBackbufferTarget(int backbufferWidth, int backbufferHeight); void renderEditorControls( const std::shared_ptr &commandBuffer); + void renderEditorGrid( + const std::shared_ptr &commandBuffer); + void renderEditorOverlays( + const std::shared_ptr &commandBuffer); void updateEditorControlGeometry(); void selectEditorObjectAt(float x, float y, float scale); + int hitTestEditorGizmoAxis(float x, float y, float scale); void updateEditorDrag(float x, float y, float scale); void updateEditorCameraDrag(float x, float y, float scale); + void updateEditorCameraPan(float x, float y, float scale); void updateEditorCameraMovement(float deltaTime); + void applyEditorOrbitDelta(float yawDelta, float pitchDelta); + void applyEditorZoomDelta(float scrollAmount); + void updateEditorCameraInertia(float deltaTime); void queryDrawableSizeInPixels(int *width, int *height) const; void initializeRunLoop(); void pollEvents(); @@ -937,6 +970,7 @@ class Window { float metalUpscalingRatio = 1.0f; bool renderToExternalMetalView = false; bool showHostWindow = true; + bool renderDefaultFramebuffer = true; void *externalMetalView = nullptr; unsigned int bloomBlurPasses = 4; int ssaoKernelSize = 32; @@ -963,17 +997,35 @@ class Window { bool editorControlsEnabled = false; bool editorSimulationEnabled = true; EditorControlMode editorControlMode = EditorControlMode::None; + EditorShadingMode editorShadingMode = EditorShadingMode::Lit; GameObject *selectedEditorObject = nullptr; bool editorDragging = false; + bool editorKeyboardTransform = false; + bool editorLocalTransformSpace = false; + bool editorTransformSnapping = false; + float editorTransformSnapIncrement = 0.5f; + int editorKeyboardTransformAxes = 7; + float editorKeyboardLastX = 0.0f; + float editorKeyboardLastY = 0.0f; + float editorKeyboardAccumulatedX = 0.0f; + float editorKeyboardAccumulatedY = 0.0f; bool editorCameraDragging = false; + bool editorCameraPanning = false; + int editorActiveGizmoAxis = 0; float editorDragStartX = 0.0f; float editorDragStartY = 0.0f; float editorCameraLastX = 0.0f; float editorCameraLastY = 0.0f; + float editorOrbitVelocityX = 0.0f; + float editorOrbitVelocityY = 0.0f; + float editorZoomVelocity = 0.0f; float editorDragStartScale = 1.0f; Position3d editorDragStartPosition; Rotation3d editorDragStartRotation; Scale3d editorDragStartObjectScale; + Position3d editorOrbitPivot; + float editorOrbitDistance = 3.0f; + bool editorOrbitPivotInitialized = false; std::unique_ptr editorGridObject; std::unique_ptr editorOutlineObject; std::unique_ptr editorGizmoObject; @@ -981,9 +1033,17 @@ class Window { bool editorOutlineInitialized = false; bool editorGizmoInitialized = false; std::array editorCameraKeys{}; + std::unordered_map editorObjectParents; + std::unordered_map> + editorObjectChildren; void prepareDefaultPipeline(Renderable *renderable, int fbWidth, int fbHeight); + bool editorSelectionBounds(GameObject *object, glm::vec3 &boundsMin, + glm::vec3 &boundsMax); + void moveEditorObjectChildren(GameObject *object, + const Position3d &deltaPosition); + void updateEditorKeyboardTransform(float x, float y, float scale); uint64_t pipelineStateVersion = 1; std::unordered_map renderablePipelineVersions; diff --git a/include/editor/application/dockManager.h b/include/editor/application/dockManager.h new file mode 100644 index 00000000..51e81141 --- /dev/null +++ b/include/editor/application/dockManager.h @@ -0,0 +1,48 @@ +/* +* dockManager.h +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Manager for docking in the editor +* Copyright (c) 2026 Max Van den Eynde +*/ + +#ifndef ATLAS_DOCKMANAGER_H +#define ATLAS_DOCKMANAGER_H + +#include +#include "DockWidget.h" + +class QWidget; + +enum class EditorDockArea { + Left, + Right, + Bottom, + Center +}; + +struct EditorDockPanelDesc { + QString id; + QString title; + QWidget* widget = nullptr; + EditorDockArea area = EditorDockArea::Left; + QIcon icon; +}; + +class EditorDockManager { +public: + explicit EditorDockManager(ads::CDockManager* dockManager); + + ads::CDockWidget* addPanel(const EditorDockPanelDesc& desc); + ads::CDockWidget* panel(const QString& id) const; + +private: + ads::DockWidgetArea toAdsArea(EditorDockArea area) const; + + ads::CDockManager* dockManager = nullptr; + QMap panels; + ads::CDockAreaWidget* centerArea = nullptr; +}; + +#endif //ATLAS_DOCKMANAGER_H diff --git a/include/editor/application/styling.h b/include/editor/application/styling.h new file mode 100644 index 00000000..dabe1f43 --- /dev/null +++ b/include/editor/application/styling.h @@ -0,0 +1,20 @@ +/* +* styling.h +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Atlas Styling Definition +* Copyright (c) 2026 Max Van den Eynde +*/ + +#ifndef ATLAS_STYLING_H +#define ATLAS_STYLING_H + +#define THEME_ICON(iconName) style()->standardIcon(iconName) + +namespace styling { + void applyColorPalette(QApplication& app); + void applyTheme(QApplication& app); +} + +#endif //ATLAS_STYLING_H diff --git a/include/editor/application/toolchainInstaller.h b/include/editor/application/toolchainInstaller.h new file mode 100644 index 00000000..6e81b899 --- /dev/null +++ b/include/editor/application/toolchainInstaller.h @@ -0,0 +1,11 @@ +#ifndef ATLAS_TOOLCHAININSTALLER_H +#define ATLAS_TOOLCHAININSTALLER_H + +class QWidget; + +namespace ToolchainInstaller { +bool ensureInstalled(QWidget* parent = nullptr); +bool install(QWidget* parent = nullptr); +} + +#endif diff --git a/include/editor/core/themes.h b/include/editor/core/themes.h new file mode 100644 index 00000000..b3f15739 --- /dev/null +++ b/include/editor/core/themes.h @@ -0,0 +1,1377 @@ +#pragma once + +// This file is generated. Do not edit manually. +// Generated from .qss theme files. + +// Source: /Users/maxvdec/Coding/Projects/Atlas/editor/styling/dark.qss +inline constexpr const char* DARK_THEME = +"* {\n" +" font-family: \"Manrope\";\n" +" font-size: 12px;\n" +" color: #E7ECF3;\n" +" selection-background-color: #647B8D;\n" +" selection-color: #FFFFFF;\n" +"}\n" +"\n" +"QWidget {\n" +" background-color: #18191B;\n" +" color: #E7ECF3;\n" +"}\n" +"\n" +"QMainWindow,\n" +"QDialog,\n" +"QFrame {\n" +" background-color: #18191B;\n" +"}\n" +"\n" +"QLabel {\n" +" background: transparent;\n" +"}\n" +"\n" +"QLabel:disabled {\n" +" color: #566174;\n" +"}\n" +"\n" +"QToolTip {\n" +" background-color: #34373A;\n" +" color: #F7F9FC;\n" +" border: 1px solid #505459;\n" +" border-radius: 8px;\n" +" padding: 4px 6px;\n" +"}\n" +"\n" +"QGroupBox {\n" +" background-color: #242628;\n" +" border: 1px solid #3A3D40;\n" +" border-radius: 10px;\n" +" margin-top: 14px;\n" +" padding: 8px;\n" +" color: #F1F4F8;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"QGroupBox::title {\n" +" subcontrol-origin: margin;\n" +" subcontrol-position: top left;\n" +" left: 9px;\n" +" padding: 0 6px;\n" +" color: #AEB8C8;\n" +" background-color: #242628;\n" +"}\n" +"\n" +"QScrollArea,\n" +"QAbstractScrollArea {\n" +" background-color: #1E2022;\n" +" border: none;\n" +"}\n" +"\n" +"QAbstractScrollArea::corner {\n" +" background-color: #1E2022;\n" +"}\n" +"\n" +"QScrollBar:vertical {\n" +" background-color: transparent;\n" +" width: 9px;\n" +" margin: 2px;\n" +" border: none;\n" +"}\n" +"\n" +"QScrollBar:horizontal {\n" +" background-color: transparent;\n" +" height: 9px;\n" +" margin: 2px;\n" +" border: none;\n" +"}\n" +"\n" +"QScrollBar::handle:vertical,\n" +"QScrollBar::handle:horizontal {\n" +" background-color: #505357;\n" +" border-radius: 4px;\n" +" min-height: 30px;\n" +" min-width: 30px;\n" +"}\n" +"\n" +"QScrollBar::handle:vertical:hover,\n" +"QScrollBar::handle:horizontal:hover {\n" +" background-color: #65696E;\n" +"}\n" +"\n" +"QScrollBar::add-line,\n" +"QScrollBar::sub-line,\n" +"QScrollBar::add-page,\n" +"QScrollBar::sub-page {\n" +" background: none;\n" +" border: none;\n" +" width: 0;\n" +" height: 0;\n" +"}\n" +"\n" +"QPushButton,\n" +"QToolButton {\n" +" background-color: #2B2E31;\n" +" border: 1px solid #45494D;\n" +" border-radius: 8px;\n" +" padding: 4px 8px;\n" +" color: #E9EDF4;\n" +" min-height: 18px;\n" +" font-weight: 550;\n" +"}\n" +"\n" +"QPushButton:hover,\n" +"QToolButton:hover {\n" +" background-color: #363A3E;\n" +" border-color: #5A5F65;\n" +"}\n" +"\n" +"QPushButton:pressed,\n" +"QToolButton:pressed {\n" +" background-color: #232527;\n" +" border-color: #6F7B84;\n" +"}\n" +"\n" +"QPushButton:checked,\n" +"QToolButton:checked {\n" +" background-color: #3A4248;\n" +" border-color: #6F7B84;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"QPushButton:default,\n" +"#primaryAction {\n" +" background-color: #596A76;\n" +" border-color: #73838E;\n" +" color: #FFFFFF;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"QPushButton:default:hover,\n" +"#primaryAction:hover {\n" +" background-color: #667985;\n" +" border-color: #82919B;\n" +"}\n" +"\n" +"QPushButton:disabled,\n" +"QToolButton:disabled {\n" +" background-color: #202224;\n" +" border-color: #303235;\n" +" color: #566174;\n" +"}\n" +"\n" +"QToolButton::menu-indicator {\n" +" image: none;\n" +"}\n" +"\n" +"QLineEdit,\n" +"QTextEdit,\n" +"QPlainTextEdit,\n" +"QComboBox,\n" +"QSpinBox,\n" +"QDoubleSpinBox,\n" +"QDateEdit,\n" +"QTimeEdit,\n" +"QDateTimeEdit {\n" +" background-color: #1B1D1F;\n" +" border: 1px solid #3B3E42;\n" +" border-radius: 8px;\n" +" padding: 4px 6px;\n" +" color: #EEF2F7;\n" +"}\n" +"\n" +"QLineEdit:hover,\n" +"QTextEdit:hover,\n" +"QPlainTextEdit:hover,\n" +"QComboBox:hover,\n" +"QSpinBox:hover,\n" +"QDoubleSpinBox:hover {\n" +" border-color: #54585D;\n" +"}\n" +"\n" +"QLineEdit:focus,\n" +"QTextEdit:focus,\n" +"QPlainTextEdit:focus,\n" +"QComboBox:focus,\n" +"QSpinBox:focus,\n" +"QDoubleSpinBox:focus {\n" +" background-color: #222426;\n" +" border-color: #71808A;\n" +"}\n" +"\n" +"QLineEdit:disabled,\n" +"QTextEdit:disabled,\n" +"QComboBox:disabled,\n" +"QSpinBox:disabled,\n" +"QDoubleSpinBox:disabled {\n" +" background-color: #202224;\n" +" border-color: #303235;\n" +" color: #566174;\n" +"}\n" +"\n" +"QComboBox {\n" +" padding-right: 24px;\n" +"}\n" +"\n" +"QComboBox::drop-down {\n" +" subcontrol-origin: padding;\n" +" subcontrol-position: top right;\n" +" width: 22px;\n" +" border-left: 1px solid #3B3E42;\n" +"}\n" +"\n" +"QComboBox QAbstractItemView {\n" +" background-color: #292C2F;\n" +" border: 1px solid #494D52;\n" +" border-radius: 10px;\n" +" padding: 3px;\n" +" selection-background-color: #3A4248;\n" +"}\n" +"\n" +"QAbstractSpinBox::up-button,\n" +"QAbstractSpinBox::down-button {\n" +" background-color: #292C2F;\n" +" border: none;\n" +" width: 16px;\n" +"}\n" +"\n" +"QAbstractSpinBox::up-button:hover,\n" +"QAbstractSpinBox::down-button:hover {\n" +" background-color: #3E4246;\n" +"}\n" +"\n" +"QCheckBox,\n" +"QRadioButton {\n" +" spacing: 7px;\n" +" color: #C9D1DE;\n" +"}\n" +"\n" +"QCheckBox::indicator,\n" +"QRadioButton::indicator {\n" +" width: 15px;\n" +" height: 15px;\n" +" background-color: #1B1D1F;\n" +" border: 1px solid #55595D;\n" +" border-radius: 5px;\n" +"}\n" +"\n" +"QCheckBox::indicator:hover,\n" +"QRadioButton::indicator:hover {\n" +" border-color: #747B81;\n" +"}\n" +"\n" +"QCheckBox::indicator:checked,\n" +"QRadioButton::indicator:checked {\n" +" background-color: #71808A;\n" +" border-color: #89969F;\n" +"}\n" +"\n" +"QSlider::groove:horizontal {\n" +" background-color: #34373A;\n" +" height: 4px;\n" +" border-radius: 2px;\n" +"}\n" +"\n" +"QSlider::handle:horizontal {\n" +" background-color: #7D8991;\n" +" border: 2px solid #A8AFB4;\n" +" width: 12px;\n" +" margin: -5px 0;\n" +" border-radius: 7px;\n" +"}\n" +"\n" +"QProgressBar {\n" +" background-color: #26282B;\n" +" border: 1px solid #42464A;\n" +" border-radius: 6px;\n" +" height: 8px;\n" +" text-align: center;\n" +"}\n" +"\n" +"QProgressBar::chunk {\n" +" background-color: #71808A;\n" +" border-radius: 6px;\n" +"}\n" +"\n" +"QTreeView,\n" +"QListView,\n" +"QListWidget,\n" +"QTableView,\n" +"QTableWidget {\n" +" background-color: #1E2022;\n" +" alternate-background-color: #232527;\n" +" border: none;\n" +" color: #DCE3EC;\n" +" show-decoration-selected: 1;\n" +"}\n" +"\n" +"QTreeView::item,\n" +"QListView::item,\n" +"QListWidget::item,\n" +"QTableView::item,\n" +"QTableWidget::item {\n" +" border: 1px solid transparent;\n" +" border-radius: 7px;\n" +" padding: 3px 5px;\n" +"}\n" +"\n" +"QTreeView::item:hover,\n" +"QListView::item:hover,\n" +"QListWidget::item:hover,\n" +"QTableView::item:hover,\n" +"QTableWidget::item:hover {\n" +" background-color: #2B2E31;\n" +" border-color: #43474B;\n" +"}\n" +"\n" +"QTreeView::item:selected,\n" +"QListView::item:selected,\n" +"QListWidget::item:selected,\n" +"QTableView::item:selected,\n" +"QTableWidget::item:selected {\n" +" background-color: #393F44;\n" +" border-color: #66737C;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"QHeaderView {\n" +" background-color: #202224;\n" +"}\n" +"\n" +"QHeaderView::section {\n" +" background-color: #292C2F;\n" +" border: none;\n" +" border-right: 1px solid #404347;\n" +" border-bottom: 1px solid #404347;\n" +" padding: 5px 7px;\n" +" color: #98A4B7;\n" +" font-weight: 600;\n" +"}\n" +"\n" +"QTabWidget::pane {\n" +" background-color: #1E2022;\n" +" border: 1px solid #3B3E42;\n" +" border-radius: 0 0 10px 10px;\n" +"}\n" +"\n" +"QTabBar::tab {\n" +" background-color: #202224;\n" +" border: none;\n" +" border-right: 1px solid #383B3F;\n" +" border-bottom: 1px solid #3B3E42;\n" +" color: #7F8B9D;\n" +" min-width: 88px;\n" +" padding: 5px 10px;\n" +" margin: 2px 1px;\n" +" border-radius: 8px;\n" +"}\n" +"\n" +"QTabBar::tab:hover {\n" +" background-color: #2B2E31;\n" +" color: #C8D1DF;\n" +"}\n" +"\n" +"QTabBar::tab:selected {\n" +" background-color: #36393C;\n" +" color: #F2F5F9;\n" +" border-bottom: 2px solid #78858E;\n" +"}\n" +"\n" +"QMenuBar#atlasMenuBar {\n" +" background-color: #141517;\n" +" border-bottom: 1px solid #303236;\n" +" padding: 2px 6px;\n" +"}\n" +"\n" +"QMenuBar#atlasMenuBar::item {\n" +" background: transparent;\n" +" color: #B8C2D0;\n" +" padding: 5px 9px;\n" +" border-radius: 7px;\n" +"}\n" +"\n" +"QMenuBar#atlasMenuBar::item:selected,\n" +"QMenuBar#atlasMenuBar::item:pressed {\n" +" background-color: #34373A;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"QMenu {\n" +" background-color: #292C2F;\n" +" border: 1px solid #494D52;\n" +" border-radius: 10px;\n" +" padding: 3px;\n" +"}\n" +"\n" +"QMenu::item {\n" +" background: transparent;\n" +" border-radius: 7px;\n" +" padding: 5px 30px 5px 8px;\n" +" color: #DCE3EC;\n" +"}\n" +"\n" +"QMenu::item:selected {\n" +" background-color: #3A3E42;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"QMenu::item:disabled {\n" +" color: #596477;\n" +"}\n" +"\n" +"QMenu::separator {\n" +" background-color: #424549;\n" +" height: 1px;\n" +" margin: 5px 8px;\n" +"}\n" +"\n" +"QSplitter::handle {\n" +" background-color: #111214;\n" +"}\n" +"\n" +"QSplitter::handle:horizontal {\n" +" width: 4px;\n" +"}\n" +"\n" +"QSplitter::handle:vertical {\n" +" height: 4px;\n" +"}\n" +"\n" +"QSplitter::handle:hover {\n" +" background-color: #71889A;\n" +"}\n" +"\n" +"QStatusBar {\n" +" background-color: #141517;\n" +" border-top: 1px solid #303236;\n" +" color: #8490A4;\n" +"}\n" +"\n" +"#atlasStatusBar {\n" +" min-height: 21px;\n" +" padding: 0 6px;\n" +"}\n" +"\n" +"#statusRuntimeIcon {\n" +" padding: 0 4px;\n" +"}\n" +"\n" +"#statusRenderer {\n" +" background-color: #2B2E30;\n" +" border: 1px solid #45494D;\n" +" border-radius: 8px;\n" +" color: #B6BCB8;\n" +" font-size: 9px;\n" +" font-weight: 700;\n" +" padding: 2px 6px;\n" +"}\n" +"\n" +"#statusVersion {\n" +" color: #68758A;\n" +" font-size: 9px;\n" +" padding: 0 6px;\n" +"}\n" +"\n" +"#workspaceBar {\n" +" background-color: #1E2022;\n" +" border: none;\n" +" border-bottom: 1px solid #3B3E42;\n" +" spacing: 3px;\n" +" padding: 2px 5px 2px 0;\n" +"}\n" +"\n" +"#workspaceIdentity {\n" +" background: transparent;\n" +" border-right: 1px solid #424549;\n" +"}\n" +"\n" +"#workspaceMark {\n" +" background: transparent;\n" +"}\n" +"\n" +"#workspaceBrand {\n" +" color: #F5F7FA;\n" +" font-size: 12px;\n" +" font-weight: 800;\n" +"}\n" +"\n" +"#workspaceProject {\n" +" color: #7F8B9D;\n" +" font-size: 11px;\n" +"}\n" +"\n" +"#workspaceModeButton {\n" +" background-color: transparent;\n" +" border: 1px solid transparent;\n" +" border-radius: 9px;\n" +" color: #94A0B2;\n" +" padding: 4px 8px;\n" +" margin: 0 1px;\n" +"}\n" +"\n" +"#workspaceModeButton:hover {\n" +" background-color: #303336;\n" +" color: #DDE4ED;\n" +"}\n" +"\n" +"#workspaceModeButton:checked {\n" +" background-color: #3A3E42;\n" +" border-color: #5E656B;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"#workspaceUtilityButton {\n" +" background: transparent;\n" +" border-color: transparent;\n" +" min-width: 26px;\n" +" padding: 3px 5px;\n" +"}\n" +"\n" +"#workspaceBuildButton,\n" +"#workspaceLaunchButton {\n" +" min-width: 26px;\n" +" padding: 3px 5px;\n" +" background-color: #292C2F;\n" +" border-color: #45494D;\n" +" margin-left: 3px;\n" +"}\n" +"\n" +"#workspaceBuildButton:hover,\n" +"#workspaceLaunchButton:hover {\n" +" background-color: #363A3E;\n" +" border-color: #5A5F65;\n" +"}\n" +"\n" +"#sceneTabs {\n" +" background-color: #191A1C;\n" +" border-bottom: 1px solid #3B3E42;\n" +"}\n" +"\n" +"#sceneTabs::tab {\n" +" background-color: #202224;\n" +" border-right: 1px solid #3A3D40;\n" +" color: #7F8B9D;\n" +" min-width: 112px;\n" +" padding: 5px 10px;\n" +" margin: 2px;\n" +" border-radius: 8px;\n" +"}\n" +"\n" +"#sceneTabs::tab:selected {\n" +" background-color: #36393C;\n" +" color: #F3F6FA;\n" +" border-bottom: 2px solid #78858E;\n" +"}\n" +"\n" +"#viewportTools {\n" +" background-color: #141517;\n" +"}\n" +"\n" +"#viewportToolbar {\n" +" background-color: #202224;\n" +" border-bottom: 1px solid #3B3E42;\n" +"}\n" +"\n" +"#viewportPlaybackButton,\n" +"#viewportModeButton,\n" +"#viewportOptionButton,\n" +"#viewportShadingButton {\n" +" background-color: #292C2F;\n" +" border-color: #414549;\n" +" min-width: 25px;\n" +" min-height: 22px;\n" +" padding: 3px 5px;\n" +"}\n" +"\n" +"#viewportPlaybackButton:hover,\n" +"#viewportModeButton:hover,\n" +"#viewportOptionButton:hover,\n" +"#viewportShadingButton:hover {\n" +" background-color: #363A3E;\n" +" border-color: #5A5F65;\n" +"}\n" +"\n" +"#viewportModeButton:checked,\n" +"#viewportShadingButton:checked {\n" +" background-color: #3A4248;\n" +" border-color: #6F7B84;\n" +"}\n" +"\n" +"#viewportFpsLabel {\n" +" color: #9CA5A0;\n" +" font-size: 10px;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#viewportShortcutHint {\n" +" background-color: #141517;\n" +" border-top: 1px solid #303236;\n" +" color: #68758A;\n" +" font-size: 10px;\n" +" padding: 3px 7px;\n" +"}\n" +"\n" +"#panelToolbar,\n" +"#materialEditorHeader,\n" +"#postProcessingToolbar,\n" +"#workspaceToolbar {\n" +" background-color: #232527;\n" +" border-bottom: 1px solid #404347;\n" +" padding: 3px;\n" +"}\n" +"\n" +"#panelAddButton,\n" +"#materialSaveButton,\n" +"#workspaceApplyButton {\n" +" background-color: #303438;\n" +" border-color: #4C5257;\n" +" color: #D8DBDE;\n" +"}\n" +"\n" +"#panelMoreButton,\n" +"#browserNavigationButton,\n" +"#browserRevealButton {\n" +" background-color: transparent;\n" +" border-color: transparent;\n" +" min-width: 24px;\n" +"}\n" +"\n" +"#panelMoreButton:hover,\n" +"#browserNavigationButton:hover,\n" +"#browserRevealButton:hover {\n" +" background-color: #34373A;\n" +" border-color: #484C50;\n" +"}\n" +"\n" +"#contentPathField {\n" +" background-color: #1B1D1F;\n" +" color: #8490A4;\n" +" font-size: 11px;\n" +"}\n" +"\n" +"#contentSearchField {\n" +" min-width: 130px;\n" +"}\n" +"\n" +"QTreeView#hierarchyTree {\n" +" background-color: #1E2022;\n" +" border-top: 1px solid #303236;\n" +" padding: 5px 3px;\n" +"}\n" +"\n" +"QTreeView#hierarchyTree::item {\n" +" min-height: 24px;\n" +" padding: 3px 5px;\n" +"}\n" +"\n" +"QTreeView#hierarchyTree::item:selected {\n" +" background-color: #343C43;\n" +" border-left: 2px solid #8498A8;\n" +"}\n" +"\n" +"QListView#contentGrid {\n" +" background-color: #1A1C1E;\n" +" border-top: 1px solid #303236;\n" +" padding: 6px;\n" +"}\n" +"\n" +"QListView#contentGrid::item {\n" +" background-color: #232527;\n" +" border: 1px solid #34373A;\n" +" border-radius: 11px;\n" +" padding: 7px;\n" +" color: #BCC6D4;\n" +"}\n" +"\n" +"QListView#contentGrid::item:hover {\n" +" background-color: #2E3134;\n" +" border-color: #4B5055;\n" +"}\n" +"\n" +"QListView#contentGrid::item:selected {\n" +" background-color: #393F44;\n" +" border-color: #66737C;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"QScrollArea#inspectorScroll,\n" +"#inspectorContent,\n" +"#materialEditorBody,\n" +"#postProcessingBody {\n" +" background-color: #1E2022;\n" +" border: none;\n" +"}\n" +"\n" +"#inspectorHeader {\n" +" background-color: #25272A;\n" +" border: 1px solid #404347;\n" +" border-radius: 12px;\n" +" padding: 6px;\n" +"}\n" +"\n" +"#inspectorObjectIcon {\n" +" background-color: #34373B;\n" +" border: 1px solid #4E5257;\n" +" border-radius: 10px;\n" +" padding: 4px;\n" +"}\n" +"\n" +"#inspectorNameField {\n" +" background: transparent;\n" +" border: 1px solid transparent;\n" +" color: #F3F6FA;\n" +" font-size: 14px;\n" +" font-weight: 700;\n" +"}\n" +"\n" +"#inspectorNameField:hover,\n" +"#inspectorNameField:focus {\n" +" background-color: #1E2022;\n" +" border-color: #5A5F65;\n" +"}\n" +"\n" +"#inspectorTypeLabel {\n" +" color: #7E8A9E;\n" +" font-size: 10px;\n" +" font-weight: 600;\n" +"}\n" +"\n" +"#inspectorComponent {\n" +" background-color: #242628;\n" +" border: 1px solid #3B3E41;\n" +" border-radius: 11px;\n" +" margin-top: 3px;\n" +"}\n" +"\n" +"#inspectorComponentHeaderRow {\n" +" background-color: #292C2F;\n" +" border-bottom: 1px solid #3B3E41;\n" +" border-radius: 11px 11px 0 0;\n" +"}\n" +"\n" +"#inspectorComponentHeader {\n" +" background: transparent;\n" +" border: none;\n" +" color: #E8ECF2;\n" +" font-weight: 650;\n" +" text-align: left;\n" +" padding: 5px 7px;\n" +"}\n" +"\n" +"#inspectorComponentHeader:hover {\n" +" background-color: #303337;\n" +"}\n" +"\n" +"#inspectorComponentRemoveButton {\n" +" background: transparent;\n" +" border: none;\n" +" min-width: 22px;\n" +"}\n" +"\n" +"#inspectorComponentRemoveButton:hover {\n" +" background-color: #3A3031;\n" +" border: 1px solid #695556;\n" +"}\n" +"\n" +"#inspectorComponentBody {\n" +" background-color: #242628;\n" +" padding: 5px;\n" +"}\n" +"\n" +"#inspectorPropertyRow {\n" +" background: transparent;\n" +" min-height: 27px;\n" +"}\n" +"\n" +"#inspectorPropertyLabel {\n" +" color: #929EB0;\n" +" font-size: 11px;\n" +"}\n" +"\n" +"#inspectorVectorField,\n" +"#inspectorColorField,\n" +"#inspectorNumericField {\n" +" background-color: #1B1D1F;\n" +" border: 1px solid #404448;\n" +" border-radius: 9px;\n" +"}\n" +"\n" +"#inspectorVectorField QDoubleSpinBox,\n" +"#inspectorNumericField QDoubleSpinBox {\n" +" background: transparent;\n" +" border: none;\n" +"}\n" +"\n" +"#inspectorAxisLabel {\n" +" background-color: #34373A;\n" +" border-radius: 7px;\n" +" color: #AAB5C5;\n" +" font-size: 9px;\n" +" font-weight: 750;\n" +" padding: 2px 4px;\n" +"}\n" +"\n" +"#inspectorSyncButton,\n" +"#inspectorArrayButton {\n" +" background: transparent;\n" +" border-color: transparent;\n" +" min-width: 22px;\n" +"}\n" +"\n" +"#inspectorSyncButton[matched=\"true\"] {\n" +" background-color: #343936;\n" +" border-color: #555F59;\n" +" color: #B6BEB9;\n" +"}\n" +"\n" +"#inspectorColorSwatch {\n" +" background-color: #292C2F;\n" +" border: 1px solid #4D5155;\n" +" border-radius: 8px;\n" +" padding: 2px;\n" +"}\n" +"\n" +"#inspectorColorText {\n" +" color: #9CA8BA;\n" +" font-family: \"Menlo\";\n" +" font-size: 10px;\n" +"}\n" +"\n" +"#inspectorNestedGroup {\n" +" background-color: #202224;\n" +" border: 1px solid #383B3F;\n" +" border-radius: 10px;\n" +" margin: 3px 0;\n" +" padding: 5px;\n" +"}\n" +"\n" +"#inspectorNestedTitle,\n" +"#inspectorArrayTitle {\n" +" color: #AEB8C8;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#inspectorEmptyTitle,\n" +"#materialEditorEmpty,\n" +"#postProcessingEmpty {\n" +" color: #C4CDDA;\n" +" font-size: 14px;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#inspectorEmptyHint {\n" +" color: #728096;\n" +"}\n" +"\n" +"#inspectorOpenAssetButton,\n" +"#inspectorAddComponentButton {\n" +" background-color: #292C2F;\n" +" border: 1px dashed #5A5F65;\n" +" color: #C7D0DD;\n" +" padding: 6px;\n" +"}\n" +"\n" +"#inspectorAddComponentButton:hover {\n" +" background-color: #363A3E;\n" +" border-color: #646B70;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"#inspectorAudioControls {\n" +" background-color: #202224;\n" +" border-radius: 9px;\n" +"}\n" +"\n" +"#materialEditorTitle,\n" +"#postProcessingTitle,\n" +"#postProcessingSectionTitle,\n" +"#inspectorCameraTitle,\n" +"#workspaceTitle {\n" +" color: #F3F6FA;\n" +" font-size: 13px;\n" +" font-weight: 700;\n" +"}\n" +"\n" +"#materialEditorStatus,\n" +"#postProcessingStatus,\n" +"#workspaceStatus,\n" +"#workspaceAutoApply {\n" +" color: #7E8A9E;\n" +" font-size: 10px;\n" +"}\n" +"\n" +"#materialPreview {\n" +" background-color: #141517;\n" +" border: 1px solid #44484C;\n" +" border-radius: 12px;\n" +"}\n" +"\n" +"#materialColorButton {\n" +" background-color: #292C2F;\n" +" border-color: #45494D;\n" +" border-radius: 9px;\n" +" color: #C9CDD0;\n" +" text-align: left;\n" +" padding: 4px 8px;\n" +"}\n" +"\n" +"#materialColorButton:hover {\n" +" background-color: #34383B;\n" +" border-color: #5A5F65;\n" +"}\n" +"\n" +"#materialTextureSlot {\n" +" background-color: #232527;\n" +" border: 1px solid #404448;\n" +" border-radius: 10px;\n" +" padding: 4px;\n" +"}\n" +"\n" +"#materialTexturePreview {\n" +" background-color: #1A1C1E;\n" +" border: 1px solid #494D52;\n" +" border-radius: 9px;\n" +" color: #9E897D;\n" +" font-weight: 750;\n" +"}\n" +"\n" +"#materialTextureLabel {\n" +" color: #D6DDE7;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#materialTexturePath {\n" +" color: #7D899C;\n" +"}\n" +"\n" +"#environmentPages,\n" +"#environmentWorkspace,\n" +"#environmentPage {\n" +" background-color: #1E2022;\n" +" border: none;\n" +"}\n" +"\n" +"QListWidget#environmentCategories {\n" +" background-color: #1B1D1F;\n" +" border-right: 1px solid #3B3E42;\n" +" padding: 5px;\n" +"}\n" +"\n" +"QListWidget#environmentCategories::item {\n" +" padding: 6px 8px;\n" +" margin: 2px 0;\n" +"}\n" +"\n" +"QListWidget#environmentCategories::item:selected {\n" +" background-color: #343C43;\n" +" border-left: 2px solid #8498A8;\n" +"}\n" +"\n" +"#environmentPageTitle {\n" +" color: #F3F6FA;\n" +" font-size: 17px;\n" +" font-weight: 750;\n" +"}\n" +"\n" +"#environmentPageSubtitle {\n" +" color: #778499;\n" +"}\n" +"\n" +"QGroupBox#environmentSection {\n" +" background-color: #242628;\n" +" border-color: #3B3E42;\n" +"}\n" +"\n" +"ads--CDockContainerWidget {\n" +" background-color: #111214;\n" +"}\n" +"\n" +"ads--CDockContainerWidget > QSplitter {\n" +" background-color: #111214;\n" +"}\n" +"\n" +"ads--CDockContainerWidget ads--CDockSplitter::handle {\n" +" background-color: #111214;\n" +"}\n" +"\n" +"ads--CDockContainerWidget ads--CDockSplitter::handle:hover {\n" +" background-color: #71889A;\n" +"}\n" +"\n" +"ads--CDockAreaWidget {\n" +" background-color: #1E2022;\n" +" border: 1px solid #34373A;\n" +"}\n" +"\n" +"ads--CDockAreaWidget[focused=\"true\"] {\n" +" border-color: #50545A;\n" +"}\n" +"\n" +"ads--CDockAreaTitleBar {\n" +" background-color: #202224;\n" +" border-bottom: 1px solid #3B3E42;\n" +" min-height: 26px;\n" +"}\n" +"\n" +"ads--CDockAreaWidget[focused=\"true\"] ads--CDockAreaTitleBar {\n" +" background-color: #26282B;\n" +"}\n" +"\n" +"#tabsContainerWidget {\n" +" background-color: transparent;\n" +"}\n" +"\n" +"ads--CTitleBarButton {\n" +" background: transparent;\n" +" border: none;\n" +" border-radius: 7px;\n" +" min-width: 22px;\n" +" min-height: 22px;\n" +" padding: 2px;\n" +"}\n" +"\n" +"ads--CTitleBarButton:hover {\n" +" background-color: #3E4246;\n" +"}\n" +"\n" +"ads--CDockWidgetTab {\n" +" background-color: #202224;\n" +" border: none;\n" +" border-right: 1px solid #3A3D40;\n" +" border-bottom: 1px solid #3B3E42;\n" +" padding: 1px 5px;\n" +" margin: 2px 1px;\n" +" border-radius: 7px;\n" +"}\n" +"\n" +"ads--CDockWidgetTab:hover {\n" +" background-color: #2C2F32;\n" +"}\n" +"\n" +"ads--CDockWidgetTab[activeTab=\"true\"] {\n" +" background-color: #373A3D;\n" +" border-bottom: 2px solid #78858E;\n" +"}\n" +"\n" +"ads--CDockWidgetTab[focused=\"true\"] {\n" +" background-color: #2B2E31;\n" +"}\n" +"\n" +"ads--CDockWidgetTab #dockWidgetTabLabel {\n" +" color: #7F8B9D;\n" +" font-size: 11px;\n" +" padding: 4px 2px;\n" +"}\n" +"\n" +"ads--CDockWidgetTab[activeTab=\"true\"] #dockWidgetTabLabel,\n" +"ads--CDockWidgetTab[focused=\"true\"] #dockWidgetTabLabel {\n" +" color: #F1F4F8;\n" +"}\n" +"\n" +"#tabCloseButton,\n" +"#dockAreaCloseButton,\n" +"#detachGroupButton,\n" +"#dockAreaAutoHideButton,\n" +"#dockAreaMinimizeButton,\n" +"#tabsMenuButton {\n" +" background: transparent;\n" +" border: none;\n" +" min-width: 20px;\n" +" min-height: 20px;\n" +"}\n" +"\n" +"#tabCloseButton:hover,\n" +"#dockAreaCloseButton:hover,\n" +"#detachGroupButton:hover,\n" +"#dockAreaAutoHideButton:hover,\n" +"#dockAreaMinimizeButton:hover,\n" +"#tabsMenuButton:hover {\n" +" background-color: #424549;\n" +"}\n" +"\n" +"ads--CDockWidget {\n" +" background-color: #1E2022;\n" +"}\n" +"\n" +"ads--CAutoHideSideBar,\n" +"#sideTabsContainerWidget {\n" +" background-color: #141517;\n" +"}\n" +"\n" +"ads--CAutoHideTab {\n" +" background-color: #202224;\n" +" border: 1px solid #404448;\n" +" color: #7F8B9D;\n" +" padding: 4px;\n" +" border-radius: 8px;\n" +"}\n" +"\n" +"ads--CAutoHideTab:hover,\n" +"ads--CAutoHideTab[activeTab=\"true\"] {\n" +" background-color: #393F44;\n" +" border-color: #66737C;\n" +" color: #FFFFFF;\n" +"}\n" +"\n" +"#projectBrowserRoot,\n" +"#projectBrowserContent {\n" +" background-color: #18191B;\n" +"}\n" +"\n" +"#projectSidebar {\n" +" background-color: #151618;\n" +" border-right: 1px solid #303236;\n" +"}\n" +"\n" +"#projectBrand {\n" +" color: #F5F7FA;\n" +" font-size: 16px;\n" +" font-weight: 750;\n" +"}\n" +"\n" +"#projectSidebarVersion {\n" +" color: #68758A;\n" +" font-size: 10px;\n" +" font-weight: 600;\n" +"}\n" +"\n" +"#projectNavSelected {\n" +" background-color: #34383C;\n" +" border: 1px solid #51575C;\n" +" border-left: 3px solid #79868F;\n" +" border-radius: 10px;\n" +" color: #DCE9FA;\n" +" padding: 6px 9px;\n" +" text-align: left;\n" +"}\n" +"\n" +"#projectNavSelected:disabled {\n" +" color: #DCE9FA;\n" +"}\n" +"\n" +"#projectBrowserTitle,\n" +"#dialogTitle {\n" +" color: #F4F6FA;\n" +" font-size: 24px;\n" +" font-weight: 750;\n" +"}\n" +"\n" +"#projectBrowserSubtitle,\n" +"#dialogSubtitle,\n" +"#emptyStateSubtitle,\n" +"#templateDescription {\n" +" color: #7F8B9D;\n" +"}\n" +"\n" +"#projectSearch {\n" +" background-color: #202224;\n" +" border: 1px solid #43474B;\n" +" border-radius: 11px;\n" +" padding: 7px 9px;\n" +" font-size: 13px;\n" +"}\n" +"\n" +"#projectSearch:focus {\n" +" border-color: #71808A;\n" +"}\n" +"\n" +"#projectList {\n" +" background-color: transparent;\n" +" border: none;\n" +"}\n" +"\n" +"#projectList::item {\n" +" background: transparent;\n" +" border: none;\n" +" padding: 0;\n" +"}\n" +"\n" +"#projectRow {\n" +" background-color: #242628;\n" +" border: 1px solid #3B3E41;\n" +" border-radius: 13px;\n" +"}\n" +"\n" +"#projectRow:hover {\n" +" background-color: #2C2F32;\n" +" border-color: #565B61;\n" +"}\n" +"\n" +"#projectRow[available=\"false\"] {\n" +" background-color: #202224;\n" +" border-color: #303235;\n" +"}\n" +"\n" +"#projectIcon,\n" +"#templateIcon {\n" +" background-color: #303337;\n" +" border: 1px solid #484C50;\n" +" border-radius: 10px;\n" +"}\n" +"\n" +"#projectName {\n" +" color: #EEF2F7;\n" +" font-size: 14px;\n" +" font-weight: 700;\n" +"}\n" +"\n" +"#projectPath,\n" +"#projectDate {\n" +" color: #768398;\n" +" font-size: 10px;\n" +"}\n" +"\n" +"#rendererBadge {\n" +" background-color: #2C302E;\n" +" border: 1px solid #494F4B;\n" +" border-radius: 8px;\n" +" color: #B4BBB6;\n" +" padding: 3px 7px;\n" +" font-size: 9px;\n" +" font-weight: 700;\n" +"}\n" +"\n" +"#projectMoreButton {\n" +" background: transparent;\n" +" border-color: transparent;\n" +"}\n" +"\n" +"#projectMoreButton:hover {\n" +" background-color: #3E4246;\n" +" border-color: #51565B;\n" +"}\n" +"\n" +"#projectEmptyState {\n" +" background-color: #1E2022;\n" +" border: 1px dashed #484C51;\n" +" border-radius: 14px;\n" +"}\n" +"\n" +"#emptyStateTitle {\n" +" color: #CDD5E1;\n" +" font-size: 16px;\n" +" font-weight: 700;\n" +"}\n" +"\n" +"#createProjectDialog {\n" +" background-color: #1E2022;\n" +"}\n" +"\n" +"QFrame[templateCard=\"true\"] {\n" +" background-color: #242628;\n" +" border: 1px solid #42464A;\n" +" border-radius: 13px;\n" +"}\n" +"\n" +"QFrame[templateCard=\"true\"]:hover {\n" +" background-color: #2B2E31;\n" +" border-color: #5A5F65;\n" +"}\n" +"\n" +"QFrame[templateCard=\"true\"][selected=\"true\"] {\n" +" background-color: #393F44;\n" +" border-color: #66737C;\n" +"}\n" +"\n" +"#templateOption {\n" +" color: #E7ECF3;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#fieldLabel {\n" +" color: #AAB5C5;\n" +" font-size: 11px;\n" +" font-weight: 650;\n" +"}\n" +"\n" +"#dialogError {\n" +" color: #FF7D8A;\n" +" font-size: 11px;\n" +"}\n" +"\n" +"QPushButton[secondary=\"true\"] {\n" +" background-color: #292C2F;\n" +" border-color: #494D52;\n" +"}\n" +"\n" +"QPushButton[secondary=\"true\"]:hover {\n" +" background-color: #363A3E;\n" +" border-color: #5D6268;\n" +"}\n" +"\n" +"#projectSettingsDialog,\n" +"#exportDialog {\n" +" background-color: #18191B;\n" +"}\n" +"\n" +"#dialogHero {\n" +" background-color: #242628;\n" +" border: 1px solid #42464A;\n" +" border-radius: 13px;\n" +" padding: 6px;\n" +"}\n" +"\n" +"#dialogHeroIcon {\n" +" background-color: #2B3034;\n" +" border: 1px solid #50575C;\n" +" border-radius: 10px;\n" +" padding: 6px;\n" +"}\n" +"\n" +"#dialogHeroTitle {\n" +" color: #F3F6FA;\n" +" font-size: 17px;\n" +" font-weight: 750;\n" +"}\n" +"\n" +"#dialogHeroSubtitle,\n" +"#exportSummary {\n" +" color: #7F8B9D;\n" +"}\n" +"\n" +"#exportSummary {\n" +" background-color: #232527;\n" +" border: 1px solid #3B3E42;\n" +" border-radius: 10px;\n" +" padding: 6px;\n" +"}\n" +"\n" +"#exportLog {\n" +" background-color: #141517;\n" +" border-color: #404448;\n" +" color: #B7BDBC;\n" +" font-family: \"Menlo\";\n" +" font-size: 10px;\n" +"}\n" +"\n" +"#commandPaletteDialog,\n" +"#globalSearchDialog {\n" +" background-color: #202224;\n" +" border: 1px solid #5A5F65;\n" +" border-radius: 14px;\n" +"}\n" +"\n" +"#commandPaletteSearch,\n" +"#globalSearchField {\n" +" background-color: #1A1C1E;\n" +" border: 1px solid #505459;\n" +" border-radius: 11px;\n" +" padding: 7px 9px;\n" +" font-size: 14px;\n" +"}\n" +"\n" +"#commandPaletteSearch:focus,\n" +"#globalSearchField:focus {\n" +" border-color: #71808A;\n" +"}\n" +"\n" +"#commandPaletteList,\n" +"#globalSearchResults {\n" +" background-color: #202224;\n" +" border: none;\n" +" padding: 5px;\n" +"}\n" +"\n" +"#commandPaletteList::item,\n" +"#globalSearchResults::item {\n" +" min-height: 30px;\n" +" padding: 6px 8px;\n" +"}\n" +; diff --git a/include/editor/debug.h b/include/editor/debug.h new file mode 100644 index 00000000..3821b0c2 --- /dev/null +++ b/include/editor/debug.h @@ -0,0 +1,35 @@ +/* +* debug.h +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Debug views and components +* Copyright (c) 2026 Max Van den Eynde +*/ + +#ifndef ATLAS_DEBUG_H +#define ATLAS_DEBUG_H + +#include + +class DebugComponentsView final : public QWidget { + Q_OBJECT + +public: + explicit DebugComponentsView(QWidget* parent = nullptr); + +private: + QWidget* createBasicControlsSection(); + QWidget* createInputSection(); + QWidget* createSelectionSection(); + QWidget* createRangeSection(); + QWidget* createTextSection(); + QWidget* createItemViewsSection(); + QWidget* createTabsSection(); + QWidget* createCollapsibleSection(); + QWidget* createStatusSection(); + + QWidget* createSection(const QString& title, QWidget* content); +}; + +#endif //ATLAS_DEBUG_H diff --git a/include/editor/project/projectStore.h b/include/editor/project/projectStore.h new file mode 100644 index 00000000..e47ff71a --- /dev/null +++ b/include/editor/project/projectStore.h @@ -0,0 +1,40 @@ +#ifndef ATLAS_PROJECTSTORE_H +#define ATLAS_PROJECTSTORE_H + +#include +#include +#include + +#include + +enum class AtlasProjectTemplate { + Pbr, + PbrDdgi, + PathTracing +}; + +struct AtlasProjectInfo { + QString name; + QString projectFile; + QString directory; + QString renderer; + QDateTime lastModified; + bool available = false; +}; + +class ProjectStore { +public: + static QList recentProjects(); + static std::optional projectInfo( + const QString& projectFile); + static QString createProject(const QString& name, + const QString& parentDirectory, + AtlasProjectTemplate projectTemplate, + QString* errorMessage); + static bool isProjectFile(const QString& projectFile); + static void addRecentProject(const QString& projectFile); + static void removeRecentProject(const QString& projectFile); + static QString templateName(AtlasProjectTemplate projectTemplate); +}; + +#endif diff --git a/include/editor/styling/icons.h b/include/editor/styling/icons.h new file mode 100644 index 00000000..784290bb --- /dev/null +++ b/include/editor/styling/icons.h @@ -0,0 +1,93 @@ +#ifndef ATLAS_EDITOR_ICONS_H +#define ATLAS_EDITOR_ICONS_H + +#include +#include +#include + +namespace styling { + +enum class Icon { + Aperture, + ArrowClockwise, + ArrowCounterClockwise, + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowsOutCardinal, + Assign, + BoundingBox, + Camera, + CaretDown, + CaretLeft, + CaretRight, + CaretUp, + Check, + Close, + Cloud, + Code, + Crosshair, + Cube, + CubeFocus, + CubeTransparent, + CursorClick, + Database, + DotsVertical, + DotsNine, + Export, + Eye, + EyeSlash, + File, + FileCode, + FilmStrip, + FloppyDisk, + Folder, + FolderOpen, + GameController, + Gear, + Globe, + Hand, + HardDrives, + Image, + Info, + Layout, + Lightbulb, + MagnifyingGlass, + Material, + Monitor, + MonitorPlay, + Mountains, + MusicNote, + Package, + PaintBrush, + Palette, + Pause, + Play, + Plus, + RocketLaunch, + Rows, + Sidebar, + SkipForward, + SlidersHorizontal, + Sparkle, + SpeakerHigh, + Sphere, + SquaresFour, + Stack, + Stop, + Sun, + TerminalWindow, + Trash, + TreeStructure, + Warning, + Waveform, + Wrench +}; + +bool loadIconFont(); +QIcon icon(Icon icon, const QColor &color = QColor("#AAB4C4")); +QIcon colorSwatch(const QColor &color, const QSize &size = QSize(18, 18)); + +} + +#endif diff --git a/include/editor/views/editorWindow.h b/include/editor/views/editorWindow.h new file mode 100644 index 00000000..f6bf24c1 --- /dev/null +++ b/include/editor/views/editorWindow.h @@ -0,0 +1,102 @@ +/* +* editorWindow.h +* As part of the Atlas project +* Created by Max Van den Eynde in 2026 +* -------------------------------------- +* Description: Main View for the editor's window +* Copyright (c) 2026 Max Van den Eynde +*/ + +#ifndef ATLAS_EDITORWINDOW_H +#define ATLAS_EDITORWINDOW_H + +#include +#include +#include + +#include "editor/application/dockManager.h" + +namespace ads { + class CDockManager; + class CDockWidget; +} + +class ViewportPanel; +class InspectorPanel; +class HierarchyPanel; +class ContentBrowserPanel; +class ViewportTools; +class MaterialEditorPanel; +class PostProcessingPanel; +class QMenu; +class QButtonGroup; +class QStackedWidget; +class QShowEvent; +class QTimer; +class QFileSystemWatcher; +class QEvent; + +class EditorWindow : public QMainWindow { + Q_OBJECT + +public: + explicit EditorWindow(const QString& projectFile, + QWidget* parent = nullptr); + +signals: + void startupStatusChanged(const QString& status); + void startupReady(bool success, const QString& message); + +private: + void setupWindow(); + void setupMenus(); + void setupDocks(); + void setupWorkspaceBar(); + void activateWorkspace(int index); + + void saveLayout(); + void restoreLayout(); + void configureDockSplitters(); + void scheduleLayoutSave(); + void updateWindowTitle(bool dirty); + void createScene(); + void openScene(); + void saveSceneAs(); + void showProjectSettings(); + void showExportDialog(); + void showCommandPalette(); + void showGlobalSearch(); + void runProjectCommand(bool buildOnly); + void takeViewportScreenshot(); + void refreshScriptWatcher(); + bool contentBrowserHasFocus() const; + + EditorDockManager* dockManager = nullptr; + ads::CDockManager* coreManager = nullptr; + ViewportPanel* viewportPanel = nullptr; + InspectorPanel* inspectorPanel = nullptr; + MaterialEditorPanel* materialEditorPanel = nullptr; + PostProcessingPanel* postProcessingPanel = nullptr; + HierarchyPanel* hierarchyPanel = nullptr; + ContentBrowserPanel* contentBrowser = nullptr; + ViewportTools* viewportTools = nullptr; + QStackedWidget* workspaceStack = nullptr; + QButtonGroup* workspaceModeGroup = nullptr; + QMenu* viewMenu = nullptr; + QMenu* windowMenu = nullptr; + QTimer* layoutSaveTimer = nullptr; + QFileSystemWatcher* scriptWatcher = nullptr; + QByteArray defaultDockState; + QString projectFile; + QString projectName; + bool closing = false; + bool restoringLayout = false; + bool startupQueued = false; + bool startupComplete = false; + + void closeEvent(QCloseEvent* event) override; + void showEvent(QShowEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; +}; + +#endif //ATLAS_EDITORWINDOW_H diff --git a/include/editor/views/fileExplorer.h b/include/editor/views/fileExplorer.h new file mode 100644 index 00000000..4577d498 --- /dev/null +++ b/include/editor/views/fileExplorer.h @@ -0,0 +1,81 @@ +/* + * fileExplorer.h + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Content Browser panel + * Copyright (c) 2026 Max Van den Eynde + */ + +#ifndef ATLAS_FILEEXPLORER_H +#define ATLAS_FILEEXPLORER_H + +#include +#include + +class QFileSystemModel; +class QLineEdit; +class QListView; +class QModelIndex; +class QPoint; +class QToolButton; + +class ContentBrowserPanel : public QWidget { + Q_OBJECT + + public: + explicit ContentBrowserPanel(const QString &projectFile, + QWidget *parent = nullptr); + + void setRootPath(const QString &path); + void clearSelection(); + void focusSearch(); + void renameSelection(); + void deleteSelection(); + void duplicateSelection(); + void cutSelection(); + void copySelection(); + void pasteSelection(); + void refreshAssets(); + void selectAllAssets(); + QString selectedPath() const; + + signals: + void selectionChanged(const QString &path); + void assetActivated(const QString &path); + void sceneActivated(const QString &path); + + private: + void navigateTo(const QString &path, bool recordHistory = true); + void openIndex(const QModelIndex &index); + void showContextMenu(const QPoint &position); + void showCreateMenu(const QPoint &position); + void createFolder(); + void createScene(); + void createScript(); + void createMaterial(); + void revealSelection() const; + void copySelectionPath() const; + QString uniquePath(const QString &baseName) const; + bool isInsideProject(const QString &path) const; + void updateNavigationState(); + + QListView *gridView = nullptr; + QFileSystemModel *model = nullptr; + QToolButton *backButton = nullptr; + QToolButton *forwardButton = nullptr; + QToolButton *upButton = nullptr; + QToolButton *createButton = nullptr; + QToolButton *revealButton = nullptr; + QToolButton *moreButton = nullptr; + QLineEdit *pathField = nullptr; + QLineEdit *searchField = nullptr; + QString projectRoot; + QString currentPath; + QStringList history; + int historyIndex = -1; + QStringList clipboardPaths; + bool cutClipboard = false; +}; + +#endif diff --git a/include/editor/views/hierarchyPanel.h b/include/editor/views/hierarchyPanel.h new file mode 100644 index 00000000..c4ce149f --- /dev/null +++ b/include/editor/views/hierarchyPanel.h @@ -0,0 +1,77 @@ +/* + * hierarchyPanel.h + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Hierarchy panel definition + * Copyright (c) 2026 Max Van den Eynde + */ + +#ifndef ATLAS_HIERARCHYPANEL_H +#define ATLAS_HIERARCHYPANEL_H + +#include +#include +#include +#include + +class QJsonArray; +class QEvent; +class QMenu; +class QPoint; +class QStandardItem; +class QStandardItemModel; +class QLineEdit; +class QToolButton; +class QTreeView; +class ViewportPanel; + +class HierarchyPanel : public QWidget { + Q_OBJECT + + public: + explicit HierarchyPanel(ViewportPanel *viewport, QWidget *parent = nullptr); + void createObject(const QString &type, const QString &displayName); + void renameSelectedObject(); + void deleteSelectedObject(); + void focusSelectedObject(); + void moveSelectedObjectToRoot(); + void selectAllObjects(); + void deselectAllObjects(); + void focusSearch(); + void showCreationPopup(); + QList selectedObjectIds() const; + + signals: + void objectActivated(int id); + void cameraActivated(); + void environmentActivated(); + + protected: + bool eventFilter(QObject *watched, QEvent *event) override; + + private: + void applySceneSnapshot(const QString &snapshot); + void rebuildScene(const QString &sceneName, const QJsonArray &objects, + int selectedId); + void appendObjects(QStandardItem *parent, const QJsonArray &objects); + void showAddObjectMenu(const QPoint &position); + void showContextMenu(const QPoint &position); + int selectedObjectId() const; + QString sceneSignature(const QString &sceneName, + const QJsonArray &objects) const; + + ViewportPanel *viewport = nullptr; + QTreeView *treeView = nullptr; + QStandardItemModel *model = nullptr; + QToolButton *addButton = nullptr; + QToolButton *moreButton = nullptr; + QLineEdit *searchField = nullptr; + QHash itemsById; + QHash specialItems; + QString lastStructureSignature; + QString selectedSpecialType; + bool applyingSnapshot = false; +}; + +#endif diff --git a/include/editor/views/inspectorView.h b/include/editor/views/inspectorView.h new file mode 100644 index 00000000..873208c1 --- /dev/null +++ b/include/editor/views/inspectorView.h @@ -0,0 +1,74 @@ +/* + * 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 QLabel; +class QLineEdit; +class QDragEnterEvent; +class QDropEvent; +class QScrollArea; +class QVBoxLayout; +class ViewportPanel; + +class InspectorPanel : public QWidget { + Q_OBJECT + + public: + explicit InspectorPanel(ViewportPanel *viewport, const QString &projectFile, + QWidget *parent = nullptr); + + public slots: + void applySceneSnapshot(const QString &snapshot); + void inspectRuntimeObject(int id); + void inspectCamera(); + void inspectEnvironment(); + void inspectFile(const QString &path); + + protected: + void dragEnterEvent(QDragEnterEvent *event) override; + void dropEvent(QDropEvent *event) override; + + private: + void showEmptyState(); + void showObject(const QJsonObject &object); + void showCamera(); + void showEnvironment(); + void showFile(); + void rebuildBody(); + void commitHeaderName(); + QJsonObject findObject(int id) const; + bool attachAsset(const QString &path, int objectId); + + 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; + QJsonObject inspectedCamera; + QString inspectedFile; + QString projectRoot; + int inspectedObjectId = -1; + int lastRuntimeSelection = -1; + bool fileTarget = false; + bool cameraTarget = false; + bool environmentTarget = false; + bool rebuilding = false; +}; + +#endif // ATLAS_INSPECTORVIEW_H diff --git a/include/editor/views/materialEditor.h b/include/editor/views/materialEditor.h new file mode 100644 index 00000000..c35192c6 --- /dev/null +++ b/include/editor/views/materialEditor.h @@ -0,0 +1,79 @@ +#ifndef ATLAS_MATERIALEDITOR_H +#define ATLAS_MATERIALEDITOR_H + +#include +#include +#include +#include +#include + +class QCheckBox; +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTimer; +class QVBoxLayout; +class MaterialPreviewWidget; +class ViewportPanel; + +class MaterialEditorPanel : public QWidget { + Q_OBJECT + + public: + explicit MaterialEditorPanel(ViewportPanel *viewport, + QWidget *parent = nullptr); + ~MaterialEditorPanel() override; + + public slots: + void openMaterial(const QString &path); + void saveMaterial(); + void undo(); + void redo(); + + signals: + void materialSaved(const QString &path); + + private: + void showEmptyState(); + void showMaterial(); + void rebuildBody(); + void setColor(const QString &key, QPushButton *button); + void chooseTexture(const QString &key); + void clearTexture(const QString &key); + void updateTextureField(const QString &key); + void materialChanged(); + void refreshEditedMaterial(); + void recordHistory(const QJsonObject &previous); + void assignToSelectedObject(); + QJsonObject normalizedMaterial(const QJsonObject &source) const; + + QWidget *body = nullptr; + QVBoxLayout *bodyLayout = nullptr; + MaterialPreviewWidget *preview = nullptr; + QLabel *titleLabel = nullptr; + QLabel *statusLabel = nullptr; + QPushButton *albedoButton = nullptr; + QPushButton *emissiveButton = nullptr; + QDoubleSpinBox *metallicField = nullptr; + QDoubleSpinBox *roughnessField = nullptr; + QDoubleSpinBox *aoField = nullptr; + QDoubleSpinBox *reflectivityField = nullptr; + QDoubleSpinBox *emissiveIntensityField = nullptr; + QDoubleSpinBox *normalStrengthField = nullptr; + QDoubleSpinBox *transmittanceField = nullptr; + QDoubleSpinBox *iorField = nullptr; + QCheckBox *normalMapField = nullptr; + QHash textureFields; + QHash texturePreviews; + QTimer *saveTimer = nullptr; + ViewportPanel *viewport = nullptr; + QString materialPath; + QJsonObject material; + QList undoHistory; + QList redoHistory; + int assignedObjectId = -1; + bool loading = false; +}; + +#endif diff --git a/include/editor/views/postProcessing.h b/include/editor/views/postProcessing.h new file mode 100644 index 00000000..3a3fc128 --- /dev/null +++ b/include/editor/views/postProcessing.h @@ -0,0 +1,49 @@ +#ifndef ATLAS_POSTPROCESSING_H +#define ATLAS_POSTPROCESSING_H + +#include +#include +#include +#include + +class QComboBox; +class QLabel; +class QToolButton; +class QVBoxLayout; +class ViewportPanel; + +class PostProcessingPanel : public QWidget { + Q_OBJECT + + public: + explicit PostProcessingPanel(ViewportPanel *viewport, + QWidget *parent = nullptr); + + public slots: + void applySceneSnapshot(const QString &snapshot); + + private: + void rebuildTargetList(); + void rebuildEditor(); + void addTarget(); + void removeTarget(); + void addEffect(const QString &type); + void removeEffect(int effectIndex); + void moveEffect(int effectIndex, int offset); + void setTargetValue(const QString &path, const QJsonValue &value); + void setEffectValue(int effectIndex, const QString &key, + const QJsonValue &value); + void replaceTargets(); + + ViewportPanel *viewport = nullptr; + QComboBox *targetSelector = nullptr; + QToolButton *removeTargetButton = nullptr; + QLabel *statusLabel = nullptr; + QWidget *body = nullptr; + QVBoxLayout *bodyLayout = nullptr; + QJsonArray targets; + int targetIndex = -1; + bool applying = false; +}; + +#endif diff --git a/include/editor/views/projectBrowser.h b/include/editor/views/projectBrowser.h new file mode 100644 index 00000000..e394197a --- /dev/null +++ b/include/editor/views/projectBrowser.h @@ -0,0 +1,36 @@ +#ifndef ATLAS_PROJECTBROWSER_H +#define ATLAS_PROJECTBROWSER_H + +#include + +class QLabel; +class QLineEdit; +class QListWidget; +class QPoint; +class QStackedWidget; + +class ProjectBrowser : public QMainWindow { + Q_OBJECT + +public: + explicit ProjectBrowser(QWidget* parent = nullptr); + +signals: + void openProjectRequested(const QString& projectFile); + +private: + void setupUi(); + void reloadProjects(); + void filterProjects(const QString& query); + void createProject(); + void openExistingProject(); + void openSelectedProject(); + void showProjectMenu(const QPoint& position); + + QLineEdit* searchField = nullptr; + QListWidget* projectList = nullptr; + QStackedWidget* projectStack = nullptr; + QLabel* emptyTitle = nullptr; +}; + +#endif diff --git a/include/editor/views/splashScreen.h b/include/editor/views/splashScreen.h new file mode 100644 index 00000000..1f88bc7e --- /dev/null +++ b/include/editor/views/splashScreen.h @@ -0,0 +1,24 @@ +#ifndef ATLAS_SPLASHSCREEN_H +#define ATLAS_SPLASHSCREEN_H + +#include + +class QLabel; + +class SplashScreen : public QDialog { + Q_OBJECT + +public: + explicit SplashScreen(QWidget* parent = nullptr); + void start(const QString& statusText); + void setStatus(const QString& statusText); + void finish(); + +signals: + void ready(); + +private: + QLabel* statusLabel = nullptr; +}; + +#endif diff --git a/include/editor/views/viewport.h b/include/editor/views/viewport.h new file mode 100644 index 00000000..c0935f8c --- /dev/null +++ b/include/editor/views/viewport.h @@ -0,0 +1,174 @@ +/* + * viewport.h + * As part of the Atlas project + * Created by Max Van den Eynde in 2026 + * -------------------------------------- + * Description: Viewport declaration + * Copyright (c) 2026 Max Van den Eynde + */ + +#ifndef ATLAS_VIEWPORT_H +#define ATLAS_VIEWPORT_H + +#include + +#include +#include +#include +#include +#include + +class Context; +class QCloseEvent; +class QDragEnterEvent; +class QDropEvent; +class QHideEvent; +class QKeyEvent; +class QJsonValue; +class QMouseEvent; +class QPaintEngine; +class QResizeEvent; +class QSize; +class QShowEvent; +class QTimer; +class QUndoStack; +class QWheelEvent; + +class ViewportPanel : public QWidget { + Q_OBJECT + + public: + explicit ViewportPanel(const QString &projectFile, + QWidget *parent = nullptr); + ~ViewportPanel() override; + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + void setRuntimeStartupEnabled(bool enabled); + void shutdownRuntime(); + bool selectRuntimeObject(int id, bool focusCamera = true); + bool focusRuntimeObjects(const QList &ids); + bool renameRuntimeObject(int id, const QString &name); + bool renameRuntimeObjectDirect(int id, const QString &name); + bool setRuntimeObjectProperty(int id, const QString &component, + int componentIndex, + const QString &propertyPath, + const QJsonValue &value); + bool setRuntimeSceneProperty(const QString §ion, int index, + const QString &propertyPath, + const QJsonValue &value); + bool setRuntimePropertySync(const QJsonObject &target, + const QJsonObject &source); + bool clearRuntimePropertySync(const QJsonObject &target); + bool applyRuntimeObjectProperty(int id, const QString &component, + int componentIndex, + const QString &propertyPath, + const QJsonValue &value); + int addRuntimeObjectComponent(int id, const QString &type, + const QJsonObject &properties); + bool removeRuntimeObjectComponent(int id, int componentIndex); + bool controlRuntimeAudio(int id, int componentIndex, + const QString &action); + bool setRuntimeObjectParent(int childId, int parentId); + bool deleteRuntimeObject(int id); + int createRuntimeObject(const QString &type, const QString &name = {}); + bool duplicateSelectedRuntimeObject(); + bool copySelectedRuntimeObject(); + bool cutSelectedRuntimeObject(); + bool pasteRuntimeObject(); + bool resetSelectedTransform(int mode); + bool saveRuntimeScene(); + bool openRuntimeScene(const QString &path); + bool saveRuntimeSceneAs(const QString &path); + QString currentRuntimeScene() const; + QString currentSceneSnapshot() const { return lastSceneSnapshot; } + int selectedRuntimeObjectId() const; + bool applyRuntimeMaterial(int id, const QString &path); + bool applyRuntimeMaterialDirect(int id, const QString &path); + bool attachRuntimeAsset(int id, const QString &path); + bool importRuntimeModel(const QString &path); + void undo(); + void redo(); + void playRuntime(); + void toggleRuntimePlayback(); + void pauseRuntime(); + void stepRuntimeOnce(); + void stopRuntimePlayback(); + void reloadRuntime(); + void setRuntimeShadingMode(int mode); + void setRuntimeControlMode(int mode); + void toggleTransformSpace(); + void toggleTransformSnapping(); + void changeTransformSnapIncrement(float factor); + + signals: + void sceneSnapshotChanged(const QString &snapshot); + void runtimeAvailabilityChanged(bool available); + void runtimeObjectActivated(int id); + void playbackStateChanged(int state); + void frameRateChanged(float framesPerSecond); + void sceneDirtyChanged(bool dirty); + void runtimeStartupFinished(bool success, const QString &message); + void transformHintChanged(const QString &hint); + void sceneOpened(const QString &path); + void transformSpaceChanged(bool local); + void transformSnappingChanged(bool enabled, float increment); + + protected: + QPaintEngine *paintEngine() const override; + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + void closeEvent(QCloseEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dropEvent(QDropEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void keyReleaseEvent(QKeyEvent *event) override; + + private: + void scheduleRuntimeStart(); + void startRuntime(); + void stopRuntime(); + void stepRuntime(); + void resizeRuntime(); + void sendPointerEvent(int action, float x, float y, int button); + void refreshSceneSnapshot(); + void setSceneDirty(bool dirty); + void beginKeyboardTransform(int mode); + void updateKeyboardTransformAxes(int key, bool exclude); + void finishKeyboardTransform(bool commit); + void pushTransformUndo(int objectId, const QJsonObject &before); + QJsonValue runtimeObjectProperty(int id, const QString &component, + int componentIndex, + const QString &propertyPath) const; + + QTimer *frameTimer = nullptr; + QTimer *resizeTimer = nullptr; + QTimer *environmentReloadTimer = nullptr; + QUndoStack *undoStack = nullptr; + QString projectFile; + std::shared_ptr runtimeContext; + int runtimeWidth = 0; + int runtimeHeight = 0; + float runtimeScale = 0.0f; + QString lastSceneSnapshot; + QString selectionToRestore; + QByteArray objectClipboard; + QJsonObject transformUndoBefore; + bool runtimeStartQueued = false; + bool runtimeStartupEnabled = false; + bool shuttingDown = false; + bool sceneDirty = false; + bool leftPointerMoved = false; + bool keyboardTransformActive = false; + int keyboardTransformMode = 0; + int keyboardTransformAxes = 7; + int playbackState = 0; + int shadingMode = 0; + int rightDragRuntimeButton = 0; +}; + +#endif // ATLAS_VIEWPORT_H diff --git a/include/editor/views/viewportTools.h b/include/editor/views/viewportTools.h new file mode 100644 index 00000000..8215a58e --- /dev/null +++ b/include/editor/views/viewportTools.h @@ -0,0 +1,43 @@ +#ifndef ATLAS_VIEWPORTTOOLS_H +#define ATLAS_VIEWPORTTOOLS_H + +#include +#include + +class QLabel; +class QTabBar; +class QToolButton; +class ViewportPanel; + +class ViewportTools : public QWidget { + Q_OBJECT + + public: + explicit ViewportTools(ViewportPanel *viewport, const QString &projectFile, + QWidget *parent = nullptr); + void openSceneTab(const QString &path); + void closeCurrentSceneTab(); + void refreshSceneTabs(); + + private: + void updatePlaybackState(int state); + void closeSceneTab(int index); + void updateSceneTabs(); + + ViewportPanel *viewport = nullptr; + QToolButton *playButton = nullptr; + QToolButton *pauseButton = nullptr; + QToolButton *stepButton = nullptr; + QToolButton *stopButton = nullptr; + QToolButton *reloadButton = nullptr; + QToolButton *spaceButton = nullptr; + QLabel *fpsLabel = nullptr; + QLabel *shortcutHint = nullptr; + QTabBar *sceneTabs = nullptr; + QString projectRoot; + QStringList scenePaths; + bool runtimeAvailable = false; + int playbackState = 0; +}; + +#endif diff --git a/include/editor/widgets/scrubbableSpinBox.h b/include/editor/widgets/scrubbableSpinBox.h new file mode 100644 index 00000000..e9050584 --- /dev/null +++ b/include/editor/widgets/scrubbableSpinBox.h @@ -0,0 +1,68 @@ +#ifndef ATLAS_SCRUBBABLESPINBOX_H +#define ATLAS_SCRUBBABLESPINBOX_H + +#include +#include +#include +#include + +#include + +template +class ScrubbableSpinBoxBase : public SpinBox { + public: + explicit ScrubbableSpinBoxBase(QWidget *parent = nullptr) + : SpinBox(parent) { + this->lineEdit()->installEventFilter(this); + this->lineEdit()->setCursor(Qt::SizeHorCursor); + this->setAccelerated(true); + this->setButtonSymbols(QAbstractSpinBox::NoButtons); + } + + protected: + bool eventFilter(QObject *watched, QEvent *event) override { + if (watched != this->lineEdit()) + return SpinBox::eventFilter(watched, event); + if (event->type() == QEvent::MouseButtonPress) { + auto *mouse = static_cast(event); + if (mouse->button() == Qt::LeftButton) { + scrubStartX = mouse->globalPosition().x(); + scrubStartValue = this->value(); + scrubbing = false; + } + } else if (event->type() == QEvent::MouseMove) { + auto *mouse = static_cast(event); + if (mouse->buttons().testFlag(Qt::LeftButton)) { + const double distance = + mouse->globalPosition().x() - scrubStartX; + if (std::abs(distance) >= 3.0) + scrubbing = true; + if (scrubbing) { + const double precision = + mouse->modifiers().testFlag(Qt::ShiftModifier) ? 0.1 + : 1.0; + this->setValue(scrubStartValue + + distance * this->singleStep() * precision); + return true; + } + } + } else if (event->type() == QEvent::MouseButtonRelease) { + auto *mouse = static_cast(event); + if (mouse->button() == Qt::LeftButton && scrubbing) { + scrubbing = false; + return true; + } + } + return SpinBox::eventFilter(watched, event); + } + + private: + double scrubStartX = 0.0; + double scrubStartValue = 0.0; + bool scrubbing = false; +}; + +using ScrubbableDoubleSpinBox = ScrubbableSpinBoxBase; +using ScrubbableSpinBox = ScrubbableSpinBoxBase; + +#endif diff --git a/include/finewave/audio.h b/include/finewave/audio.h index da6d45eb..fb46d1ca 100644 --- a/include/finewave/audio.h +++ b/include/finewave/audio.h @@ -13,6 +13,7 @@ #include "atlas/units.h" #include "atlas/workspace.h" #include "finewave/effect.h" +#include #include #include #include @@ -35,6 +36,7 @@ class AudioEngine { * */ void shutdown(); + ~AudioEngine(); /** * @brief Sets the position of the audio listener in 3D space. @@ -66,6 +68,10 @@ class AudioEngine { * @brief Name of the currently selected playback device. */ std::string deviceName; + + private: + ALCdevice *device = nullptr; + ALCcontext *context = nullptr; }; /** diff --git a/include/opal/opal.h b/include/opal/opal.h index d8eb4cd2..6c84aade 100644 --- a/include/opal/opal.h +++ b/include/opal/opal.h @@ -68,6 +68,7 @@ class Context { void setAlwaysOnTop(bool enabled); void setSamples(int value); void setHighPixelDensity(bool enabled); + void setHidden(bool enabled); SDL_Window *makeWindow(int width, int height, const char *title, SDL_DisplayID displayID = 0); @@ -90,6 +91,7 @@ class Context { bool transparent = false; bool alwaysOnTop = false; bool highPixelDensity = true; + bool hidden = false; int samples = 0; #ifdef VULKAN @@ -179,6 +181,9 @@ class Device { std::shared_ptr getDefaultFramebuffer(); DeviceInfo getDeviceInfo(); +#ifdef METAL + MTL::Device *getMetalDevice() const; +#endif private: std::shared_ptr defaultFramebuffer = nullptr; @@ -372,6 +377,9 @@ class Texture { void setParameters3D(TextureWrapMode wrapS, TextureWrapMode wrapT, TextureWrapMode wrapR, TextureFilterMode minFilter, TextureFilterMode magFilter); +#ifdef METAL + MTL::Texture *getMetalTexture() const; +#endif uint textureID = 0; TextureType type = TextureType::Texture2D; diff --git a/justfile b/justfile index e35f5786..3f1b1502 100644 --- a/justfile +++ b/justfile @@ -62,6 +62,12 @@ frametest: cli: cargo build +package-debug-macos: + ./scripts/package_app.py --debug --macOS + +package-release-macos: + ./scripts/package_app.py --release --macOS + release-metal: rm -rf build/release-metal mkdir -p build/release-metal dist/release diff --git a/opal/command_buffer.cpp b/opal/command_buffer.cpp index 174a8d62..2aeb0cd4 100644 --- a/opal/command_buffer.cpp +++ b/opal/command_buffer.cpp @@ -210,6 +210,39 @@ void queryMetalDrawableSizeFromView(void *view, int fallbackWidth, *height = resultHeight; } } + +void updateMetalLayerFrameFromView(Device *device, void *view) { + if (device == nullptr || view == nullptr) { + return; + } + auto &deviceState = metal::deviceState(device); + if (deviceState.context == nullptr) { + return; + } + auto &contextState = metal::contextState(deviceState.context); + if (contextState.layer == nullptr) { + return; + } + + CocoaObj targetView = view; + CocoaRect bounds = sendObjCRect(targetView, "bounds"); + reinterpret_cast(objc_msgSend)( + reinterpret_cast(contextState.layer), + sel_registerName("setFrame:"), bounds); + + double scale = 1.0; + CocoaObj hostWindow = sendObjCId(targetView, "window"); + if (hostWindow != nullptr) { + const double backingScale = + sendObjCDouble(hostWindow, "backingScaleFactor"); + if (backingScale > 0.0) { + scale = backingScale; + } + } + reinterpret_cast(objc_msgSend)( + reinterpret_cast(contextState.layer), + sel_registerName("setContentsScale:"), scale); +} #endif void configureColorAttachmentForClear(MTL::RenderPassDescriptor *pass, @@ -1217,6 +1250,8 @@ void CommandBuffer::beginPass(std::shared_ptr newRenderPass) { queryMetalDrawableSizeFromView( deviceState.context->getMetalTargetView(), fbWidth, fbHeight, &fbWidth, &fbHeight); + updateMetalLayerFrameFromView( + device, deviceState.context->getMetalTargetView()); #endif fbWidth = std::max(1, fbWidth); fbHeight = std::max(1, fbHeight); diff --git a/opal/device.cpp b/opal/device.cpp index 2cd2f26e..bccbb6dd 100644 --- a/opal/device.cpp +++ b/opal/device.cpp @@ -207,6 +207,8 @@ void Context::setSamples(int value) { samples = value; } void Context::setHighPixelDensity(bool enabled) { highPixelDensity = enabled; } +void Context::setHidden(bool enabled) { hidden = enabled; } + void Context::makeCurrent() { if (this->window != nullptr && this->glContext != nullptr) { SDL_GL_MakeCurrent(this->window, this->glContext); @@ -253,6 +255,9 @@ SDL_Window *Context::makeWindow(int width, int height, const char *title, if (highPixelDensity) { windowFlags |= SDL_WINDOW_HIGH_PIXEL_DENSITY; } + if (hidden) { + windowFlags |= SDL_WINDOW_HIDDEN; + } this->window = SDL_CreateWindow(title, width, height, windowFlags); if (this->window == nullptr) { @@ -331,6 +336,12 @@ DeviceInfo Device::getDeviceInfo() { #endif } +#ifdef METAL +MTL::Device *Device::getMetalDevice() const { + return metal::deviceState(const_cast(this)).device; +} +#endif + std::shared_ptr Device::acquire([[maybe_unused]] const std::shared_ptr &context) { #ifdef OPENGL diff --git a/opal/texture.cpp b/opal/texture.cpp index 5d3c03c1..16b68410 100644 --- a/opal/texture.cpp +++ b/opal/texture.cpp @@ -654,6 +654,12 @@ void Texture::updateData3D(const void *data, int width, int height, int depth, #endif } +#ifdef METAL +MTL::Texture *Texture::getMetalTexture() const { + return metal::textureState(const_cast(this)).texture; +} +#endif + void Texture::updateData(const void *data, int width, int height, TextureDataFormat dataFormat) { #ifdef OPENGL diff --git a/runtime/README.md b/runtime/README.md index 535fd1e3..4ded9803 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -22,4 +22,28 @@ Each scene format file has these main sections: * `id`: The ID of the scene, which is a string that can be used to identify the scene. The ID is used internally by the engine to reference the scene, while the name is used for display purposes and debugging. * `objects`: An array of objects that are present in the scene, which can be of different types (solid, compound, model, particle emitter, terrain, etc.). Each object is defined as an object with a `type` property that specifies the type of the object, and other properties that define the values for that object. * `lights`: An array of lights that are present in the scene, which can be of different types (point light, directional light, spotlight, etc.). Each light is defined as an object with a `type` property that specifies the type of the light, and other properties that define the values for that light. -* `camera`: An object that defines the properties of the camera in the scene, such as its position, rotation, field of view, etc. The camera is defined as an object with a `type` property that specifies the type of the camera (e.g., perspective, orthographic, etc.), and other properties that define the values for that camera. \ No newline at end of file +* `camera`: An object that defines the properties of the camera in the scene, such as its position, rotation, field of view, etc. The camera is defined as an object with a `type` property that specifies the type of the camera (e.g., perspective, orthographic, etc.), and other properties that define the values for that camera. +* `property_syncs`: An array of persistent property bindings. Each entry contains a `target` endpoint and a `source` endpoint. Atlas resolves these bindings whenever the scene is loaded, after objects exist and before components are initialized, so physics and scripts receive the synchronized values from their first frame. + +Property endpoints use `section` (`object`, `camera`, or `environment`) and a JSON-pointer `path`. Object endpoints also store the stable object reference, component type, and component index. The special `bounds` component exposes the rendered object size. For example, a rigidbody collider can follow its object's bounds: + +```json +"property_syncs": [ + { + "target": { + "section": "object", + "object": "Cube", + "component": "rigidbody", + "componentIndex": 0, + "path": "/collider/size" + }, + "source": { + "section": "object", + "object": "Cube", + "component": "bounds", + "componentIndex": -1, + "path": "" + } + } +] +``` diff --git a/runtime/docs/components.md b/runtime/docs/components.md index ade977f5..8b1da24c 100644 --- a/runtime/docs/components.md +++ b/runtime/docs/components.md @@ -35,6 +35,9 @@ An audio player component is a component that can be added to an object to make * `source`: The source of the audio, which is a reference to an audio file (e.g., `.mp3`, `.wav`, etc.) that defines the sound that will be played by the audio player. * `position`: The position of the audio player in the scene, defined as an array of three numbers representing the x, y and z coordinates. This can be used to create spatial audio effects, where the sound will be louder when the listener is closer to the audio player and quieter when the listener is farther away. * `useSpatialization`: A boolean value that indicates whether to use spatialization for the audio or not. If `true`, the audio will be spatialized based on the position of the audio player and the listener in the scene. If `false`, the audio will be played as a non-spatialized sound, which means that it will be heard at the same volume regardless of the position of the audio player and the listener in the scene. +* `volume`: A number that controls the playback volume. +* `loop`: A boolean value that controls whether playback repeats after reaching the end. +* `autoplay`: A boolean value that controls whether playback starts when the component is initialized. ## Joint (`type = "joint"`) @@ -133,4 +136,4 @@ have properties and behavior related to driving and controlling a vehicle. It ha * `limitedSlipRatio`: The limited slip ratio of the differential, defined as a single number representing the maximum difference in speed between the left and right wheels before the limited slip mechanism engages. A higher value means that the limited slip mechanism will engage at a higher speed difference, while a lower value means that the limited slip mechanism will engage at a lower speed difference. * `engineTorqueRatio`: The engine torque ratio of the differential, defined as a single number representing the percentage of engine torque that will be sent to the wheels through the differential (values between 0 and 100). A higher value means that more engine torque will be sent to the wheels, while a lower value means that less engine torque will be sent to the wheels. * `differentialLimitedSlipRatio`: The limited slip ratio for the entire vehicle, defined as a single number representing the maximum difference in speed between the left and right wheels before the limited slip mechanism engages for the entire vehicle. A higher value means that the limited slip mechanism will engage at a higher speed difference, while a lower value means that the limited slip mechanism will engage at a lower speed difference for the entire vehicle. - * `maxSlopeAngleDeg`: The maximum slope angle in degrees that the vehicle can climb, defined as a single number representing the maximum slope angle in degrees. A higher value means that the vehicle will be able to climb steeper slopes, while a lower value means that the vehicle will not be able to climb as steep slopes. \ No newline at end of file + * `maxSlopeAngleDeg`: The maximum slope angle in degrees that the vehicle can climb, defined as a single number representing the maximum slope angle in degrees. A higher value means that the vehicle will be able to climb steeper slopes, while a lower value means that the vehicle will not be able to climb as steep slopes. diff --git a/runtime/lib/c_api.cpp b/runtime/lib/c_api.cpp index 8ef7192d..800890b6 100644 --- a/runtime/lib/c_api.cpp +++ b/runtime/lib/c_api.cpp @@ -172,6 +172,24 @@ bool atlas_runtime_editor_pointer_event(void *runtimeContext, int action, } } +bool atlas_runtime_editor_scroll_event(void *runtimeContext, float delta, + float scale) { + if (runtimeContext == nullptr) { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->editorScrollEvent(delta, scale); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + bool atlas_runtime_editor_key_event(void *runtimeContext, int key, bool pressed) { if (runtimeContext == nullptr) { @@ -227,6 +245,175 @@ const char *atlas_runtime_get_selected_object_name(void *runtimeContext) { } } +const char *atlas_runtime_get_scene_objects(void *runtimeContext) { + static thread_local std::string sceneObjects; + sceneObjects = "{\"name\":\"Scene\",\"objects\":[],\"selectedId\":-1}"; + if (runtimeContext == nullptr) { + return sceneObjects.c_str(); + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return sceneObjects.c_str(); + } + sceneObjects = (*handle)->sceneObjectsJson(); + return sceneObjects.c_str(); + } catch (const std::exception &) { + return sceneObjects.c_str(); + } catch (...) { + return sceneObjects.c_str(); + } +} + +bool atlas_runtime_select_object(void *runtimeContext, int id, + bool focusCamera) { + if (runtimeContext == nullptr) { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->selectObject(id, focusCamera); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + +bool atlas_runtime_rename_object(void *runtimeContext, int id, + const char *name) { + if (runtimeContext == nullptr || name == nullptr || name[0] == '\0') { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->renameObject(id, name); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + +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; + } +} + +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) { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->setObjectParent(childId, parentId); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + +bool atlas_runtime_delete_object(void *runtimeContext, int id) { + if (runtimeContext == nullptr) { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->deleteObject(id); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + +int atlas_runtime_create_object(void *runtimeContext, const char *type, + const char *name) { + if (runtimeContext == nullptr || type == nullptr) { + return -1; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return -1; + } + return (*handle)->createObject(type, name != nullptr ? name : ""); + } catch (const std::exception &) { + return -1; + } catch (...) { + return -1; + } +} + +bool atlas_runtime_save_current_scene(void *runtimeContext) { + if (runtimeContext == nullptr) { + return false; + } + try { + auto *handle = reinterpret_cast(runtimeContext); + if (*handle == nullptr) { + return false; + } + return (*handle)->saveCurrentScene(); + } catch (const std::exception &) { + return false; + } catch (...) { + return false; + } +} + void atlas_runtime_end_context(void *runtimeContext) { if (runtimeContext == nullptr) { return; diff --git a/runtime/lib/context.cpp b/runtime/lib/context.cpp index 7a0eebf4..746619fd 100644 --- a/runtime/lib/context.cpp +++ b/runtime/lib/context.cpp @@ -23,18 +23,23 @@ #include "aurora/procedural.h" #include "aurora/terrain.h" #include "atlas/runtime/atlasScripts.h" +#include "hydra/fluid.h" #include #include #include #include #include #include +#include +#include #include +#include #include #include #include #include #include +#include #include #include @@ -87,6 +92,7 @@ struct PendingComponent { std::string objectType; std::string baseDir; json data; + int componentIndex = -1; }; struct RuntimeEnvironmentDefinition { @@ -188,6 +194,10 @@ class RuntimeScriptComponent final : public Component { constexpr const char *RUNTIME_SCRIPT_BUNDLE_PATH = "dist/scripts.js"; constexpr const char *RUNTIME_FILE_MODULE_PREFIX = "__atlas_file__/"; +std::string serializableObjectName(const Context &context, GameObject &object); +std::string serializableObjectReference(const Context &context, + GameObject &object); + std::string normalizeScriptPath(std::string path) { std::replace(path.begin(), path.end(), '\\', '/'); return path; @@ -455,9 +465,20 @@ bool isEmptyStringValue(const json &value) { return value.is_string() && value.get().empty(); } +bool isFiniteNumber(const json &value) { + return value.is_number() && std::isfinite(value.get()); +} + +bool isFiniteVector(const Position3d &value) { + return std::isfinite(value.x) && std::isfinite(value.y) && + std::isfinite(value.z); +} + bool tryReadVec3(const json &node, const char *key, Position3d &target) { auto it = node.find(key); - if (it == node.end() || !it->is_array() || it->size() != 3) { + if (it == node.end() || !it->is_array() || it->size() != 3 || + !isFiniteNumber((*it)[0]) || !isFiniteNumber((*it)[1]) || + !isFiniteNumber((*it)[2])) { return false; } target = Position3d((*it)[0].get(), (*it)[1].get(), @@ -477,7 +498,8 @@ bool tryReadVec3Any(const json &node, std::initializer_list keys, bool tryReadVec2(const json &node, const char *key, Position2d &target) { auto it = node.find(key); - if (it == node.end() || !it->is_array() || it->size() != 2) { + if (it == node.end() || !it->is_array() || it->size() != 2 || + !isFiniteNumber((*it)[0]) || !isFiniteNumber((*it)[1])) { return false; } target = Position2d{(*it)[0].get(), (*it)[1].get()}; @@ -1406,17 +1428,40 @@ void registerGameObject(Context &context, GameObject &object, name = objectType + "_" + std::to_string(generatedIndex); } + object.name = name; registerObjectReference(context, name, &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); + context.objectSceneSolidTypes[object.getId()] = + normalizeToken(solidType); + } if (const json *idField = findField(objectData, {"id"}); idField != nullptr) { if (idField->is_string()) { - registerObjectReference(context, idField->get(), - &object); + std::string sceneReference = idField->get(); + context.objectSceneReferences[object.getId()] = sceneReference; + registerObjectReference(context, sceneReference, &object); } else if (idField->is_number_integer()) { - registerObjectReference( - context, std::to_string(idField->get()), &object); + std::string sceneReference = std::to_string(idField->get()); + context.objectSceneReferences[object.getId()] = sceneReference; + registerObjectReference(context, sceneReference, &object); + } + } + + if (const json *parentField = findField(objectData, {"parent"}); + parentField != nullptr) { + if (parentField->is_string()) { + context.objectParentReferences[object.getId()] = + parentField->get(); + } else if (parentField->is_number_integer()) { + context.objectParentReferences[object.getId()] = + std::to_string(parentField->get()); } } @@ -1446,10 +1491,669 @@ void applyTransform(GameObject &object, const json &objectData) { } } +json vec3ToJson(const Position3d &value) { + return json::array({std::isfinite(value.x) ? value.x : 0.0f, + std::isfinite(value.y) ? value.y : 0.0f, + std::isfinite(value.z) ? value.z : 0.0f}); +} + +json rotationToJson(const Rotation3d &value) { + return json::array({std::isfinite(value.pitch) ? value.pitch : 0.0f, + std::isfinite(value.yaw) ? value.yaw : 0.0f, + std::isfinite(value.roll) ? value.roll : 0.0f}); +} + +json colorToJson(const Color &value) { + return json::array({std::isfinite(value.r) ? value.r : 1.0f, + std::isfinite(value.g) ? value.g : 1.0f, + std::isfinite(value.b) ? value.b : 1.0f, + std::isfinite(value.a) ? value.a : 1.0f}); +} + +json sizeToJson(const Size2d &value) { + return json::array({std::isfinite(value.width) ? value.width : 1.0f, + std::isfinite(value.height) ? value.height : 1.0f}); +} + +Magnitude3d editorForwardDirection(GameObject &object) { + glm::vec3 direction = + object.getRotation().toGlmQuat() * glm::vec3(0.0f, -1.0f, 0.0f); + if (glm::length(direction) < 0.000001f) { + direction = glm::vec3(0.0f, -1.0f, 0.0f); + } + return Magnitude3d::fromGlm(glm::normalize(direction)); +} + +bool isEditorLightObject(const Context &context, GameObject &object) { + const int id = static_cast(object.getId()); + return context.editorPointLights.contains(id) || + context.editorSpotlights.contains(id) || + context.editorAreaLights.contains(id) || + context.editorDirectionalLights.contains(id) || + context.editorLightSourceData.contains(id); +} + +void syncEditorLightObject(Context &context, GameObject &object) { + const int id = static_cast(object.getId()); + const auto sourceIt = context.editorLightSourceData.find(id); + const json *source = + sourceIt != context.editorLightSourceData.end() ? &sourceIt->second + : nullptr; + if (auto it = context.editorPointLights.find(id); + it != context.editorPointLights.end() && it->second != nullptr) { + it->second->position = object.getPosition(); + if (source != nullptr) { + tryReadColorAny(*source, {"color"}, it->second->color); + tryReadColorAny(*source, {"shineColor"}, it->second->shineColor); + tryReadFloatAny(*source, {"intensity"}, it->second->intensity); + tryReadFloatAny(*source, {"distance", "range"}, + it->second->distance); + bool castsShadows = false; + int resolution = 2048; + tryReadBoolAny(*source, {"castsShadows"}, castsShadows); + tryReadIntAny(*source, {"shadowResolution"}, resolution); + if (castsShadows && it->second->shadowRenderTarget == nullptr && + context.window != nullptr) { + it->second->castShadows(*context.window, resolution); + } + } + } + if (auto it = context.editorSpotlights.find(id); + it != context.editorSpotlights.end() && it->second != nullptr) { + it->second->position = object.getPosition(); + it->second->direction = editorForwardDirection(object); + if (source != nullptr) { + Position3d direction; + if (tryReadVec3Any(*source, {"direction"}, direction)) { + it->second->direction = direction.normalized(); + } + tryReadColorAny(*source, {"color"}, it->second->color); + tryReadColorAny(*source, {"shineColor"}, it->second->shineColor); + tryReadFloatAny(*source, {"intensity"}, it->second->intensity); + tryReadFloatAny(*source, {"range", "distance"}, it->second->range); + float cutoff = + glm::degrees(std::acos(std::clamp(it->second->cutOff, -1.0f, + 1.0f))); + float outerCutoff = glm::degrees(std::acos( + std::clamp(it->second->outerCutoff, -1.0f, 1.0f))); + tryReadFloatAny(*source, {"cutoff"}, cutoff); + tryReadFloatAny(*source, {"outerCutoff"}, outerCutoff); + it->second->cutOff = glm::cos(glm::radians(cutoff)); + it->second->outerCutoff = glm::cos(glm::radians(outerCutoff)); + bool castsShadows = false; + int resolution = 2048; + tryReadBoolAny(*source, {"castsShadows"}, castsShadows); + tryReadIntAny(*source, {"shadowResolution"}, resolution); + if (castsShadows && it->second->shadowRenderTarget == nullptr && + context.window != nullptr) { + it->second->castShadows(*context.window, resolution); + } + } + it->second->updateDebugObjectRotation(); + } + if (auto it = context.editorAreaLights.find(id); + it != context.editorAreaLights.end() && it->second != nullptr) { + it->second->position = object.getPosition(); + it->second->setRotation(object.getRotation()); + if (source != nullptr) { + Position3d axis; + if (tryReadVec3Any(*source, {"right"}, axis)) { + it->second->right = axis.normalized(); + } + if (tryReadVec3Any(*source, {"up"}, axis)) { + it->second->up = axis.normalized(); + } + Position2d size; + if (tryReadVec2Any(*source, {"size"}, size)) { + it->second->size = Size2d{size.x, size.y}; + } + tryReadColorAny(*source, {"color"}, it->second->color); + tryReadColorAny(*source, {"shineColor"}, it->second->shineColor); + tryReadFloatAny(*source, {"intensity"}, it->second->intensity); + tryReadFloatAny(*source, {"range", "distance"}, it->second->range); + tryReadFloatAny(*source, {"angle"}, it->second->angle); + tryReadBoolAny(*source, {"castsBothSides"}, + it->second->castsBothSides); + bool castsShadows = false; + int resolution = 2048; + tryReadBoolAny(*source, {"castsShadows"}, castsShadows); + tryReadIntAny(*source, {"shadowResolution"}, resolution); + if (castsShadows && it->second->shadowRenderTarget == nullptr && + context.window != nullptr) { + it->second->castShadows(*context.window, resolution); + } + } + } + if (auto it = context.editorDirectionalLights.find(id); + it != context.editorDirectionalLights.end() && it->second != nullptr) { + it->second->direction = editorForwardDirection(object); + if (source != nullptr) { + Position3d direction; + if (tryReadVec3Any(*source, {"direction"}, direction)) { + it->second->direction = direction.normalized(); + } + tryReadColorAny(*source, {"color"}, it->second->color); + tryReadColorAny(*source, {"shineColor"}, it->second->shineColor); + tryReadFloatAny(*source, {"intensity"}, it->second->intensity); + bool castsShadows = false; + int resolution = 4096; + tryReadBoolAny(*source, {"castsShadows"}, castsShadows); + tryReadIntAny(*source, {"shadowResolution"}, resolution); + if (castsShadows && it->second->shadowRenderTarget == nullptr && + context.window != nullptr) { + it->second->castShadows(*context.window, resolution); + } + } + } + if (source != nullptr && context.scene != nullptr && + !context.editorPointLights.contains(id) && + !context.editorSpotlights.contains(id) && + !context.editorAreaLights.contains(id) && + !context.editorDirectionalLights.contains(id)) { + Color color = context.scene->getAmbientColor(); + float intensity = context.scene->getAmbientIntensity() / 4.0f; + tryReadColorAny(*source, {"color"}, color); + tryReadFloatAny(*source, {"intensity"}, intensity); + context.scene->setAmbientColor(color); + context.scene->setAmbientIntensity(intensity * 4.0f); + } + if (source != nullptr) { + auto *coreObject = dynamic_cast(&object); + if (coreObject == nullptr) { + return; + } + Color color = coreObject->material.albedo; + if (tryReadColorAny(*source, {"color"}, color)) { + coreObject->material.albedo = color; + coreObject->material.emissiveColor = color; + } + } +} + +void applyEditorCameraData(Context &context) { + if (context.camera == nullptr || !context.editorCameraData.is_object()) { + return; + } + tryReadVec3Any(context.editorCameraData, {"position"}, + context.camera->position); + Position3d target = context.camera->target; + if (tryReadVec3Any(context.editorCameraData, {"target"}, target)) { + context.camera->lookAt(target); + } + tryReadFloatAny(context.editorCameraData, {"fov"}, context.camera->fov); + tryReadFloatAny(context.editorCameraData, {"nearClip"}, + context.camera->nearClip); + tryReadFloatAny(context.editorCameraData, {"farClip"}, + context.camera->farClip); + tryReadFloatAny(context.editorCameraData, {"orthoSize"}, + context.camera->orthographicSize); + tryReadFloatAny(context.editorCameraData, {"movementSpeed"}, + context.camera->movementSpeed); + tryReadFloatAny(context.editorCameraData, {"mouseSensitivity"}, + context.camera->mouseSensitivity); + tryReadFloatAny(context.editorCameraData, {"controllerLookSensitivity"}, + context.camera->controllerLookSensitivity); + tryReadFloatAny(context.editorCameraData, {"lookSmoothness"}, + context.camera->lookSmoothness); + tryReadBoolAny(context.editorCameraData, {"orthographic"}, + context.camera->useOrthographic); + tryReadFloatAny(context.editorCameraData, {"focusDepth"}, + context.camera->focusDepth); + tryReadFloatAny(context.editorCameraData, {"focusRange"}, + context.camera->focusRange); + tryReadBoolAny(context.editorCameraData, {"automaticMoving"}, + context.cameraAutomaticMoving); + if (context.editorCameraData.contains("actions") && + context.editorCameraData["actions"].is_array()) { + context.cameraActions.clear(); + for (const auto &action : context.editorCameraData["actions"]) { + if (action.is_string()) + context.cameraActions.push_back(action.get()); + } + } +} + +void repairEditorCamera(Context &context) { + if (context.camera == nullptr) { + return; + } + Position3d position = context.camera->position; + Position3d storedPosition; + const bool storedPositionValid = + tryReadVec3(context.editorCameraData, "position", storedPosition); + if (!isFiniteVector(position) || !storedPositionValid) { + position = storedPositionValid ? storedPosition + : Position3d{0.0f, 0.0f, -5.0f}; + context.camera->position = position; + context.editorCameraData["position"] = vec3ToJson(position); + } + Position3d target = context.camera->target; + Position3d storedTarget; + const bool storedTargetValid = + tryReadVec3(context.editorCameraData, "target", storedTarget); + if (!isFiniteVector(target) || + glm::length(target.toGlm() - position.toGlm()) < 0.000001f) { + target = storedTargetValid ? storedTarget : target; + if (!isFiniteVector(target) || + glm::length(target.toGlm() - position.toGlm()) < 0.000001f) { + target = Position3d{position.x, position.y, position.z + 1.0f}; + } + } + if (!storedTargetValid) { + context.editorCameraData["target"] = vec3ToJson(target); + } + context.camera->lookAt(target); +} + +json serializedEditorCamera(const Context &context) { + json result = context.editorCameraData.is_object() + ? context.editorCameraData + : json::object(); + if (context.camera == nullptr) { + return result; + } + result["position"] = vec3ToJson(context.camera->position); + result["target"] = vec3ToJson(context.camera->target); + result["fov"] = context.camera->fov; + result["nearClip"] = context.camera->nearClip; + result["farClip"] = context.camera->farClip; + result["orthoSize"] = context.camera->orthographicSize; + result["movementSpeed"] = context.camera->movementSpeed; + result["mouseSensitivity"] = context.camera->mouseSensitivity; + result["controllerLookSensitivity"] = + context.camera->controllerLookSensitivity; + result["lookSmoothness"] = context.camera->lookSmoothness; + result["orthographic"] = context.camera->useOrthographic; + result["focusDepth"] = context.camera->focusDepth; + result["focusRange"] = context.camera->focusRange; + result["automaticMoving"] = context.cameraAutomaticMoving; + result["actions"] = context.cameraActions; + return result; +} + +json serializeEditorLightObject(Context &context, GameObject &object) { + syncEditorLightObject(context, object); + + const int id = static_cast(object.getId()); + json node = json::object(); + if (auto source = context.editorLightSourceData.find(id); + source != context.editorLightSourceData.end() && + source->second.is_object()) { + node = source->second; + } + + const std::string name = serializableObjectName(context, object); + if (!name.empty()) { + node["name"] = name; + } + const std::string reference = serializableObjectReference(context, object); + node["id"] = reference.empty() ? std::to_string(id) : reference; + if (auto parent = context.objectParents.find(id); + parent != context.objectParents.end()) { + auto parentName = context.objectNames.find(parent->second); + node["parent"] = parentName != context.objectNames.end() + ? parentName->second + : std::to_string(parent->second); + } else { + node.erase("parent"); + } + + if (auto it = context.editorPointLights.find(id); + it != context.editorPointLights.end() && it->second != nullptr) { + Light &light = *it->second; + node["type"] = "pointLight"; + node["position"] = vec3ToJson(light.position); + node["color"] = colorToJson(light.color); + node["shineColor"] = colorToJson(light.shineColor); + node["intensity"] = light.intensity; + node["distance"] = light.distance; + return node; + } + + if (auto it = context.editorSpotlights.find(id); + it != context.editorSpotlights.end() && it->second != nullptr) { + Spotlight &light = *it->second; + node["type"] = "spotLight"; + node["position"] = vec3ToJson(light.position); + node["direction"] = vec3ToJson(light.direction); + node["color"] = colorToJson(light.color); + node["shineColor"] = colorToJson(light.shineColor); + node["intensity"] = light.intensity; + node["range"] = light.range; + node["cutoff"] = + glm::degrees(std::acos(std::clamp(light.cutOff, -1.0f, 1.0f))); + node["outerCutoff"] = + glm::degrees(std::acos(std::clamp(light.outerCutoff, -1.0f, 1.0f))); + return node; + } + + if (auto it = context.editorAreaLights.find(id); + it != context.editorAreaLights.end() && it->second != nullptr) { + AreaLight &light = *it->second; + node["type"] = "areaLight"; + node["position"] = vec3ToJson(light.position); + node["right"] = vec3ToJson(light.right); + node["up"] = vec3ToJson(light.up); + node["size"] = sizeToJson(light.size); + node["color"] = colorToJson(light.color); + node["shineColor"] = colorToJson(light.shineColor); + node["intensity"] = light.intensity; + node["range"] = light.range; + node["angle"] = light.angle; + node["castsBothSides"] = light.castsBothSides; + return node; + } + + if (auto it = context.editorDirectionalLights.find(id); + it != context.editorDirectionalLights.end() && it->second != nullptr) { + DirectionalLight &light = *it->second; + node["type"] = "directionalLight"; + node["position"] = vec3ToJson(object.getPosition()); + node["direction"] = vec3ToJson(light.direction); + node["color"] = colorToJson(light.color); + node["shineColor"] = colorToJson(light.shineColor); + node["intensity"] = light.intensity; + return node; + } + + node["type"] = "ambientLight"; + node["position"] = vec3ToJson(object.getPosition()); + if (!node.contains("color")) { + node["color"] = colorToJson(Color::white()); + } + if (!node.contains("intensity")) { + node["intensity"] = 0.5f; + } + return node; +} + +std::string serializableObjectName(const Context &context, GameObject &object) { + if (!object.name.empty()) { + return object.name; + } + auto it = context.objectNames.find(static_cast(object.getId())); + if (it != context.objectNames.end()) { + return it->second; + } + return {}; +} + +std::string serializableObjectReference(const Context &context, + GameObject &object) { + auto it = + context.objectSceneReferences.find(static_cast(object.getId())); + if (it != context.objectSceneReferences.end()) { + return it->second; + } + return {}; +} + +bool objectNodeMatches(const json &node, const std::string &name, + const std::string &reference) { + if (!node.is_object()) { + return false; + } + if (!name.empty() || !reference.empty()) { + if (const json *nameField = findField(node, {"name"}); + nameField != nullptr && nameField->is_string()) { + const std::string nodeName = nameField->get(); + if ((!name.empty() && nodeName == name) || + (!reference.empty() && nodeName == reference)) { + return true; + } + } + } + if (!reference.empty()) { + if (const json *idField = findField(node, {"id"}); idField != nullptr) { + if (idField->is_string() && + idField->get() == reference) { + return true; + } + if (idField->is_number_integer() && + std::to_string(idField->get()) == reference) { + return true; + } + } + } + return false; +} + +void writeObjectTransform(json &node, const Context &context, + GameObject &object) { + const std::string name = serializableObjectName(context, object); + if (!name.empty()) { + node["name"] = name; + } + node["position"] = vec3ToJson(object.getPosition()); + node["rotation"] = rotationToJson(object.getRotation()); + node["scale"] = vec3ToJson(object.getScale()); + auto parentIt = + context.objectParents.find(static_cast(object.getId())); + if (parentIt != context.objectParents.end()) { + auto parentName = context.objectNames.find(parentIt->second); + node["parent"] = parentName != context.objectNames.end() + ? parentName->second + : std::to_string(parentIt->second); + } else { + node.erase("parent"); + } +} + +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; + } + + if (const json *children = findField(node, {"objects"}); + children != nullptr && children->is_array()) { + json &mutableChildren = node["objects"]; + for (auto &child : mutableChildren) { + if (updateObjectNode(child, context, object)) { + return true; + } + } + } + + return false; +} + +bool removeObjectNode(json &nodes, const std::string &name, + const std::string &reference) { + if (!nodes.is_array()) { + return false; + } + + bool removed = false; + for (auto it = nodes.begin(); it != nodes.end();) { + if (objectNodeMatches(*it, name, reference)) { + it = nodes.erase(it); + removed = true; + continue; + } + + if (it->is_object()) { + if (const json *children = findField(*it, {"objects"}); + children != nullptr && children->is_array()) { + removed = removeObjectNode((*it)["objects"], name, reference) || + removed; + } + } + + ++it; + } + return removed; +} + +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; + } + node["id"] = static_cast(object.getId()); + const int id = static_cast(object.getId()); + auto typeIt = context.objectSceneTypes.find(id); + const std::string type = + typeIt != context.objectSceneTypes.end() ? typeIt->second : "solid"; + node["type"] = type; + if (type == "solid") { + auto solidIt = context.objectSceneSolidTypes.find(id); + node["solid_type"] = solidIt != context.objectSceneSolidTypes.end() + ? solidIt->second + : "cube"; + } + writeObjectTransform(node, context, object); + if (auto components = context.editorComponentData.find(object.getId()); + components != context.editorComponentData.end()) { + node["components"] = components->second; + } + return node; +} + +CoreObject createCapsulePrimitive(float radius, float height, Color color) { + constexpr unsigned int sectorCount = 32; + constexpr unsigned int hemisphereSegments = 8; + std::vector vertices; + std::vector indices; + const float halfHeight = std::max(0.0f, height * 0.5f); + const float pi = static_cast(std::numbers::pi); + + auto appendRing = [&](float y, float ringRadius, float centerY, + float vCoord) { + for (unsigned int j = 0; j <= sectorCount; ++j) { + float sector = (static_cast(j) / sectorCount) * pi * 2.0f; + float x = ringRadius * std::cos(sector); + float z = ringRadius * std::sin(sector); + glm::vec3 normal(x, y - centerY, z); + if (glm::length(normal) < 0.000001f) { + normal = glm::vec3(0.0f, y >= 0.0f ? 1.0f : -1.0f, 0.0f); + } else { + normal = glm::normalize(normal); + } + glm::vec3 tangent(-std::sin(sector), 0.0f, std::cos(sector)); + if (glm::length(tangent) < 0.000001f) { + tangent = glm::vec3(1.0f, 0.0f, 0.0f); + } else { + tangent = glm::normalize(tangent); + } + glm::vec3 bitangent = glm::normalize(glm::cross(normal, tangent)); + + CoreVertex vertex; + vertex.position = Position3d(x, y, z); + vertex.color = color; + vertex.textureCoordinate = { + static_cast(j) / sectorCount, + vCoord, + }; + vertex.normal = Normal3d::fromGlm(normal); + vertex.tangent = Normal3d::fromGlm(tangent); + vertex.bitangent = Normal3d::fromGlm(bitangent); + vertices.push_back(vertex); + } + }; + + for (unsigned int i = 0; i <= hemisphereSegments; ++i) { + float t = static_cast(i) / hemisphereSegments; + float angle = (pi * 0.5f) * (1.0f - t); + appendRing(halfHeight + radius * std::sin(angle), + radius * std::cos(angle), halfHeight, t * 0.5f); + } + for (unsigned int i = 1; i <= hemisphereSegments; ++i) { + float t = static_cast(i) / hemisphereSegments; + float angle = -(pi * 0.5f) * t; + appendRing(-halfHeight + radius * std::sin(angle), + radius * std::cos(angle), -halfHeight, 0.5f + t * 0.5f); + } + + const unsigned int ringCount = hemisphereSegments * 2 + 1; + for (unsigned int i = 0; i < ringCount - 1; ++i) { + unsigned int k1 = i * (sectorCount + 1); + unsigned int k2 = k1 + sectorCount + 1; + for (unsigned int j = 0; j < sectorCount; ++j, ++k1, ++k2) { + indices.push_back(k1); + indices.push_back(k2); + indices.push_back(k1 + 1); + indices.push_back(k1 + 1); + indices.push_back(k2); + indices.push_back(k2 + 1); + } + } + + CoreObject capsule; + capsule.attachVertices(vertices); + capsule.attachIndices(indices); + capsule.material.albedo = color; + capsule.initialize(); + return capsule; +} + +void resolveObjectParentReferences(Context &context) { + for (const auto &[childId, parentReference] : + context.objectParentReferences) { + auto parentIt = context.objectReferences.find(parentReference); + if (parentIt == context.objectReferences.end()) { + parentIt = + context.objectReferences.find(normalizeToken(parentReference)); + } + if (parentIt == context.objectReferences.end() || + parentIt->second == nullptr) { + continue; + } + + GameObject *child = nullptr; + for (const auto &renderable : context.objects) { + if (renderable == nullptr) { + continue; + } + auto *object = dynamic_cast(renderable.get()); + if (object != nullptr && + static_cast(object->getId()) == childId) { + child = object; + break; + } + } + if (child == nullptr || child == parentIt->second) { + continue; + } + + context.objectParents[childId] = + static_cast(parentIt->second->getId()); + if (auto *compound = dynamic_cast(parentIt->second); + compound != nullptr && + std::ranges::find(compound->objects, child) == + compound->objects.end()) { + compound->addObject(child); + } + } +} + void applyMaterial(GameObject &object, const MaterialDefinition &material) { if (auto *coreObject = dynamic_cast(&object); coreObject != nullptr) { coreObject->material = material.material; + coreObject->textures.clear(); for (const auto &texture : material.textures) { coreObject->attachTexture(texture); } @@ -1461,6 +2165,7 @@ void applyMaterial(GameObject &object, const MaterialDefinition &material) { for (auto &mesh : model->getObjects()) { if (mesh != nullptr) { mesh->material = material.material; + mesh->textures.clear(); } } for (const auto &texture : material.textures) { @@ -1469,13 +2174,54 @@ void applyMaterial(GameObject &object, const MaterialDefinition &material) { } } -void collectPendingComponents(GameObject &object, const json &objectData, +std::shared_ptr createEditorLightProxy(const std::string &type, + const Color &color, + const Position3d &position) { + auto object = std::make_shared(); + const std::string normalized = normalizeToken(type); + if (normalized == "pointlight") { + *object = createSphere(0.1f, 24, 12, color); + } else if (normalized == "arealight") { + *object = createPlane({0.55f, 0.55f}, color); + } else { + *object = createPyramid({0.35f, 0.35f, 0.35f}, color); + } + object->setPosition(position); + object->material.albedo = color; + object->material.emissiveColor = color; + object->material.emissiveIntensity = 1.5f; + object->castsShadows = false; + object->editorOnly = true; + return object; +} + +int registerEditorLightObject(Context &context, + const std::shared_ptr &object, + const json &sourceData, + const std::string &objectType) { + if (context.window == nullptr || object == nullptr) { + return -1; + } + registerGameObject(context, *object, sourceData, objectType, + context.objects.size()); + const int id = static_cast(object->getId()); + context.editorLightSourceData[id] = + sourceData.is_object() ? sourceData : json::object(); + context.objects.push_back(object); + context.window->addObject(object.get()); + return id; +} + +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(); + context.editorComponentBaseDirs[object.getId()] = {}; return; } @@ -1494,14 +2240,22 @@ void collectPendingComponents(GameObject &object, const json &objectData, } 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; } @@ -1518,6 +2272,7 @@ void collectPendingComponents(GameObject &object, const json &objectData, .objectType = objectType, .baseDir = definition.baseDir, .data = componentData, + .componentIndex = static_cast(componentIndex), }; if (normalizedType == "rigidbody") { @@ -1936,14 +2691,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(); @@ -2021,7 +2791,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { } pending.object->addComponent(component); - return; + return finish(component); } if (token == "rigidbody") { @@ -2080,7 +2850,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { context.context, context.scriptHost, pending.object->getId(), rigidbody); - return; + return finish(rigidbody); } if (token == "audioplayer") { @@ -2109,6 +2879,15 @@ void attachComponent(Context &context, const PendingComponent &pending) { component->setPosition(position); } + float volume = 1.0f; + if (tryReadFloatAny(pending.data, {"volume"}, volume)) { + component->setVolume(volume); + } + bool loop = false; + if (tryReadBoolAny(pending.data, {"loop", "looping"}, loop)) { + component->setLoop(loop); + } + bool autoPlay = false; if (tryReadBoolAny(pending.data, {"autoplay", "autoPlay", "playOnStart"}, autoPlay) && @@ -2116,7 +2895,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { component->play(); } - return; + return finish(component); } if (token == "joint" || token == "fixedjoint") { @@ -2126,7 +2905,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") { @@ -2162,7 +2941,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { context.context, context.scriptHost, pending.object->getId(), component); - return; + return finish(component); } if (token == "springjoint") { @@ -2197,7 +2976,7 @@ void attachComponent(Context &context, const PendingComponent &pending) { context.context, context.scriptHost, pending.object->getId(), component); - return; + return finish(component); } if (token == "vehicle") { @@ -2207,12 +2986,279 @@ 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); + } + float volume = 1.0f; + if (tryReadFloatAny(data, {"volume"}, volume)) { + audio->setVolume(volume); + } + bool loop = false; + if (tryReadBoolAny(data, {"loop", "looping"}, loop)) { + audio->setLoop(loop); + } + 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) { @@ -2701,6 +3747,12 @@ createRenderable(Context &context, const json &objectData, *object = createSphere(radius, static_cast(sectorCount), static_cast(stackCount), color); + } else if (normalizedSolidType == "capsule") { + float radius = 0.35f; + float height = 1.0f; + tryReadFloatAny(objectData, {"radius"}, radius); + tryReadFloatAny(objectData, {"height"}, height); + *object = createCapsulePrimitive(radius, height, color); } else { throw std::runtime_error("Unknown solid type: " + solidType); } @@ -2716,7 +3768,20 @@ createRenderable(Context &context, const json &objectData, } applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, + standard, joints); + return object; + } + + if (normalizedType == "camera") { + auto object = std::make_shared(); + *object = createPyramid({0.65f, 0.45f, 0.65f}, + Color{0.25f, 0.55f, 1.0f, 1.0f}); + registerGameObject(context, *object, objectData, normalizedType, + generatedIndex); + context.objects.push_back(object); + applyTransform(*object, objectData); + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -2742,11 +3807,13 @@ createRenderable(Context &context, const json &objectData, "children"); } object->addObject(childObject.get()); + context.objectParents[static_cast(childObject->getId())] = + static_cast(object->getId()); } } applyTransform(*object, objectData); - collectPendingComponents(*object, objectData, baseDir, rigidbodies, + collectPendingComponents(context, *object, objectData, baseDir, rigidbodies, standard, joints); return object; } @@ -2773,7 +3840,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; } @@ -2857,7 +3924,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; } @@ -2916,7 +3983,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; } @@ -2940,7 +4007,8 @@ createRenderable(Context &context, const json &objectData, static std::shared_ptr makeContextWithWindowOptions(std::string projectFile, void *metalView, - CoreWindowReference sdlInputWindow) { + CoreWindowReference sdlInputWindow, + bool showHostWindow = true) { auto context = std::make_shared(); if (!std::filesystem::exists(projectFile)) { @@ -2948,6 +4016,7 @@ makeContextWithWindowOptions(std::string projectFile, void *metalView, } toml::table configTable = toml::parse_file(projectFile); + context->editorRuntime = metalView != nullptr; int resWidth = 1280; int resHeight = 720; @@ -2972,18 +4041,22 @@ makeContextWithWindowOptions(std::string projectFile, void *metalView, editorControls = (*editorTable)["controls"].value_or(false); } Logger::getInstance().setConsoleFilter(false, true, true); + const bool embedded = metalView != nullptr; context->window = std::make_unique(WindowConfiguration{ .title = "Atlas Runtime", - .width = resWidth, - .height = resHeight, + .width = embedded ? 1 : resWidth, + .height = embedded ? 1 : resHeight, .renderScale = 1.f, - .mouseCaptured = mouseCaptured, + .mouseCaptured = embedded ? false : mouseCaptured, .multisampling = multisampling, + .decorations = !embedded, + .resizable = !embedded, .ssaoScale = ssaoScale, .metalTargetView = metalView, .sdlInputWindow = sdlInputWindow, .editorControls = editorControls, + .showHostWindow = showHostWindow && !embedded, }); context->projectFile = @@ -3002,6 +4075,12 @@ std::shared_ptr runtime::makeContext(std::string projectFile) { nullptr); } +std::shared_ptr +runtime::makeHiddenContext(std::string projectFile) { + return makeContextWithWindowOptions(std::move(projectFile), nullptr, + nullptr, false); +} + std::shared_ptr runtime::makeContextForMetalView(std::string projectFile, void *metalView, CoreWindowReference sdlInputWindow) { @@ -3115,8 +4194,27 @@ bool Context::stepFrame() { if (scene == nullptr) { throw std::runtime_error("Scene is not initialized"); } - window->setScene(scene.get()); - return window->stepFrame(); + if (editorRuntime) { + repairEditorCamera(*this); + } + for (const auto &renderable : objects) { + auto *object = dynamic_cast( + renderable != nullptr ? renderable.get() : nullptr); + if (object != nullptr && isEditorLightObject(*this, *object)) { + syncEditorLightObject(*this, *object); + } + } + window->setScene(scene.get()); + auto previousRetiredObjects = std::move(retiredObjects); + try { + return window->stepFrame(); + } catch (...) { + retiredObjects.insert( + retiredObjects.end(), + std::make_move_iterator(previousRetiredObjects.begin()), + std::make_move_iterator(previousRetiredObjects.end())); + throw; + } } bool Context::resize(int width, int height, float scale) { @@ -3154,6 +4252,21 @@ bool Context::setEditorControlMode(int mode) { return true; } +bool Context::setEditorShadingMode(int mode) { + if (window == nullptr) { + throw std::runtime_error("Window is not initialized"); + } + if (mode < 0 || mode > 2) { + return false; + } + window->setEditorShadingMode(static_cast(mode)); + return true; +} + +float Context::frameRate() const { + return window != nullptr ? window->getFramesPerSecond() : 0.0f; +} + bool Context::editorPointerEvent(int action, float x, float y, int button, float scale) { if (window == nullptr) { @@ -3163,6 +4276,14 @@ bool Context::editorPointerEvent(int action, float x, float y, int button, return true; } +bool Context::editorScrollEvent(float delta, float scale) { + if (window == nullptr) { + throw std::runtime_error("Window is not initialized"); + } + window->editorScrollEvent(delta, scale); + return true; +} + bool Context::editorKeyEvent(int key, bool pressed) { if (window == nullptr) { throw std::runtime_error("Window is not initialized"); @@ -3171,6 +4292,41 @@ bool Context::editorKeyEvent(int key, bool pressed) { return true; } +bool Context::beginEditorKeyboardTransform(int mode, float x, float y, + float scale) { + if (window == nullptr || mode < 1 || mode > 3) + return false; + return window->beginEditorKeyboardTransform( + static_cast(mode), x, y, scale); +} + +bool Context::setEditorKeyboardTransformAxes(int axes) { + if (window == nullptr || axes < 1 || axes > 7) + return false; + window->setEditorKeyboardTransformAxes(axes); + return true; +} + +bool Context::finishEditorKeyboardTransform(bool commit) { + if (window == nullptr) + return false; + window->finishEditorKeyboardTransform(commit); + return true; +} + +bool Context::toggleEditorTransformSpace() { + return window != nullptr && window->toggleEditorTransformSpace(); +} + +bool Context::toggleEditorTransformSnapping() { + return window != nullptr && window->toggleEditorTransformSnapping(); +} + +float Context::changeEditorTransformSnapIncrement(float factor) { + return window != nullptr ? window->changeEditorTransformSnapIncrement(factor) + : 0.0f; +} + int Context::selectedObjectId() const { if (window == nullptr || window->getSelectedEditorObject() == nullptr) { return -1; @@ -3187,9 +4343,1322 @@ std::string Context::selectedObjectName() const { if (it != objectNames.end()) { return it->second; } + if (window != nullptr && window->getSelectedEditorObject() != nullptr && + !window->getSelectedEditorObject()->name.empty()) { + return window->getSelectedEditorObject()->name; + } return std::to_string(id); } +namespace { +GameObject *findContextObject(const Context &context, int id) { + for (const auto &renderable : context.objects) { + if (renderable == nullptr) { + continue; + } + auto *object = dynamic_cast(renderable.get()); + if (object != nullptr && static_cast(object->getId()) == id) { + return object; + } + } + 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; + } + auto it = context.objectNames.find(static_cast(object.getId())); + if (it != context.objectNames.end()) { + return it->second; + } + return std::to_string(object.getId()); +} + +std::string editorObjectType(const Context &context, GameObject &object) { + const int id = static_cast(object.getId()); + auto sceneType = context.objectSceneTypes.find(id); + if (sceneType != context.objectSceneTypes.end()) { + if (sceneType->second == "solid") { + auto solidType = context.objectSceneSolidTypes.find(id); + if (solidType != context.objectSceneSolidTypes.end() && + !solidType->second.empty()) { + return solidType->second; + } + } + return sceneType->second; + } + if (dynamic_cast(&object) != nullptr) { + return "compound"; + } + if (dynamic_cast(&object) != nullptr) { + return "model"; + } + if (dynamic_cast(&object) != nullptr) { + return "terrain"; + } + if (dynamic_cast(&object) != nullptr) { + return "particleEmitter"; + } + if (dynamic_cast(&object) != nullptr) { + return "fluid"; + } + if (dynamic_cast(&object) != nullptr) { + return "uiObject"; + } + if (dynamic_cast(&object) != nullptr) { + return "solid"; + } + return "gameObject"; +} + +json editorObjectBoundsSize(GameObject &object) { + const std::vector vertices = object.getVertices(); + glm::vec3 size = glm::abs(object.getScale().toGlm()); + if (!vertices.empty()) { + glm::vec3 minimum(std::numeric_limits::max()); + glm::vec3 maximum(std::numeric_limits::lowest()); + for (const CoreVertex &vertex : vertices) { + const glm::vec3 position = vertex.position.toGlm(); + minimum = glm::min(minimum, position); + maximum = glm::max(maximum, position); + } + size *= maximum - minimum; + } + size.x = std::max(size.x, 0.05f); + size.y = std::max(size.y, 0.05f); + size.z = std::max(size.z, 0.05f); + return json::array({size.x, size.y, size.z}); +} + +GameObject *propertySyncObject(Context &context, const json &reference) { + if (reference.is_number_integer()) + return findContextObject(context, reference.get()); + if (!reference.is_string()) + return nullptr; + const std::string key = reference.get(); + auto object = context.objectReferences.find(key); + if (object == context.objectReferences.end()) + object = context.objectReferences.find(normalizeToken(key)); + return object != context.objectReferences.end() ? object->second : nullptr; +} + +std::optional propertySyncJsonValue(const json &value, + const std::string &path) { + try { + if (path.empty()) + return value; + const std::string pointerPath = + path.front() == '/' ? path : '/' + path; + return value.at(json::json_pointer(pointerPath)); + } catch (const json::exception &) { + return std::nullopt; + } +} + +std::optional propertySyncSourceValue(Context &context, + const json &source) { + if (!source.is_object()) + return std::nullopt; + const std::string section = + normalizeToken(source.value("section", std::string())); + const std::string path = source.value("path", std::string()); + auto withFallback = [&source](std::optional value) { + return value.has_value() + ? value + : source.contains("fallback") + ? std::optional(source["fallback"]) + : std::nullopt; + }; + if (section == "camera") + return withFallback( + propertySyncJsonValue(context.editorCameraData, path)); + if (section == "environment") + return withFallback( + propertySyncJsonValue(context.editorEnvironmentData, path)); + if (section != "object" || !source.contains("object")) + return std::nullopt; + GameObject *object = propertySyncObject(context, source["object"]); + if (object == nullptr) + return std::nullopt; + const int id = static_cast(object->getId()); + const std::string component = + normalizeToken(source.value("component", std::string())); + if (component == "bounds") + return editorObjectBoundsSize(*object); + if (component == "transform") { + const std::string property = normalizeToken(path); + if (property == "position") + return vec3ToJson(object->getPosition()); + if (property == "rotation") + return rotationToJson(object->getRotation()); + if (property == "scale") + return vec3ToJson(object->getScale()); + return std::nullopt; + } + if (component == "object") { + auto sourceData = context.editorLightSourceData.find(id); + if (sourceData != context.editorLightSourceData.end()) + return withFallback( + propertySyncJsonValue(sourceData->second, path)); + auto objectData = context.editorObjectSourceData.find(id); + return withFallback(objectData != context.editorObjectSourceData.end() + ? propertySyncJsonValue(objectData->second, + path) + : std::nullopt); + } + const int index = source.value("componentIndex", -1); + auto components = context.editorComponentData.find(id); + if (components == context.editorComponentData.end() || + !components->second.is_array() || index < 0 || + index >= static_cast(components->second.size())) { + return source.contains("fallback") + ? std::optional(source["fallback"]) + : std::nullopt; + } + return withFallback( + propertySyncJsonValue(components->second[index], path)); +} + +bool applyPropertySyncTarget(Context &context, const json &target, + const json &value, bool attachComponents) { + if (!target.is_object()) + return false; + const std::string section = + normalizeToken(target.value("section", std::string())); + const std::string path = target.value("path", std::string()); + if (section == "camera") + return context.setSceneProperty("camera", -1, path, value); + if (section == "environment") + return context.setSceneProperty("environment", -1, path, value); + if (section != "object" || !target.contains("object")) + return false; + GameObject *object = propertySyncObject(context, target["object"]); + if (object == nullptr) + return false; + const int id = static_cast(object->getId()); + const std::string component = + target.value("component", std::string()); + const std::string normalized = normalizeToken(component); + const int index = target.value("componentIndex", -1); + if (!attachComponents && normalized != "transform" && + normalized != "object") { + auto components = context.editorComponentData.find(id); + if (components == context.editorComponentData.end() || + !components->second.is_array() || index < 0 || + index >= static_cast(components->second.size())) { + return false; + } + return setJsonProperty(components->second[index], path, value); + } + return context.setObjectProperty(id, component, index, path, value); +} + +void applyPropertySyncs(Context &context, bool attachComponents) { + if (!context.editorPropertySyncs.is_array() || + context.applyingPropertySyncs) + return; + context.applyingPropertySyncs = true; + for (const json &binding : context.editorPropertySyncs) { + if (!binding.is_object() || !binding.contains("target") || + !binding.contains("source")) { + continue; + } + const std::optional value = + propertySyncSourceValue(context, binding["source"]); + if (value.has_value()) + applyPropertySyncTarget(context, binding["target"], *value, + attachComponents); + } + context.applyingPropertySyncs = false; +} + +json canonicalPropertySyncEndpoint(Context &context, json endpoint) { + if (!endpoint.is_object() || + normalizeToken(endpoint.value("section", std::string())) != "object" || + !endpoint.contains("object")) { + return endpoint; + } + GameObject *object = propertySyncObject(context, endpoint["object"]); + if (object == nullptr) + return endpoint; + const int id = static_cast(object->getId()); + auto reference = context.objectSceneReferences.find(id); + endpoint["object"] = + reference != context.objectSceneReferences.end() + ? reference->second + : editorObjectName(context, *object); + return endpoint; +} + +json editorObjectJson(const Context &context, GameObject &object, + const std::unordered_map> &children, + std::unordered_set &visiting) { + const int id = static_cast(object.getId()); + json node = json::object(); + node["id"] = id; + node["viewportId"] = id; + node["name"] = editorObjectName(context, object); + node["type"] = editorObjectType(context, object); + node["position"] = vec3ToJson(object.getPosition()); + node["rotation"] = rotationToJson(object.getRotation()); + node["scale"] = vec3ToJson(object.getScale()); + node["boundsSize"] = editorObjectBoundsSize(object); + 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; + } + visiting.insert(id); + + auto childrenIt = children.find(id); + if (childrenIt != children.end() && !childrenIt->second.empty()) { + node["children"] = json::array(); + for (int childId : childrenIt->second) { + GameObject *child = findContextObject(context, childId); + if (child != nullptr) { + node["children"].push_back( + editorObjectJson(context, *child, children, visiting)); + } + } + } + + visiting.erase(id); + return node; +} + +std::string uniqueEditorObjectName(const Context &context, + const std::string &baseName) { + std::string base = baseName.empty() ? "Object" : baseName; + auto exists = [&](const std::string &name) { + const std::string normalized = normalizeToken(name); + for (const auto &[id, existingName] : context.objectNames) { + (void)id; + if (existingName == name || + normalizeToken(existingName) == normalized) { + return true; + } + } + if (context.objectReferences.contains(name) || + context.objectReferences.contains(normalized)) { + return true; + } + return false; + }; + + if (!exists(base)) { + return base; + } + + for (int index = 2; index < 100000; ++index) { + std::string candidate = base + " " + std::to_string(index); + if (!exists(candidate)) { + return candidate; + } + } + return base + " " + std::to_string(context.objects.size() + 1); +} +} // namespace + +std::string Context::sceneObjectsJson() const { + json snapshot = json::object(); + snapshot["name"] = + currentSceneName.empty() + ? std::filesystem::path(currentSceneFile).stem().string() + : currentSceneName; + snapshot["selectedId"] = selectedObjectId(); + snapshot["objects"] = json::array(); + snapshot["camera"] = serializedEditorCamera(*this); + snapshot["targets"] = editorTargetData; + snapshot["environment"] = editorEnvironmentData; + snapshot["propertySyncs"] = editorPropertySyncs; + + std::unordered_map> children; + for (const auto &[childId, parentId] : objectParents) { + children[parentId].push_back(childId); + } + + std::unordered_set visiting; + for (const auto &renderable : objects) { + if (renderable == nullptr) { + continue; + } + auto *object = dynamic_cast(renderable.get()); + if (object == nullptr || + objectParents.contains(static_cast(object->getId()))) { + continue; + } + snapshot["objects"].push_back( + editorObjectJson(*this, *object, children, visiting)); + } + + return snapshot.dump(); +} + +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; + } + window->selectEditorObject(object, focusCamera); + return true; +} + +bool Context::focusObjects(const std::vector &ids) { + if (window == nullptr || ids.empty()) + return false; + std::vector selected; + for (int id : ids) { + if (GameObject *object = findContextObject(*this, id)) + selected.push_back(object); + } + if (selected.empty()) + return false; + window->focusEditorObjects(selected); + return true; +} + +bool Context::renameObject(int id, const std::string &name) { + if (name.empty()) { + return false; + } + GameObject *object = findContextObject(*this, id); + if (object == nullptr) { + return false; + } + + const std::string normalized = normalizeToken(name); + for (const auto &[key, referenced] : objectReferences) { + if ((key == name || key == normalized) && referenced != object) { + return false; + } + } + + 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; + } + setJsonProperty(editorObjectSourceData[id], "/" + property, value); + syncEditorLightObject(*this, *object); + applyPropertySyncs(*this, true); + return true; + } + + if (normalizedComponent == "object") { + json &source = editorLightSourceData.contains(id) + ? editorLightSourceData[id] + : editorObjectSourceData[id]; + if (!setJsonProperty(source, propertyPath, value)) { + return false; + } + syncEditorLightObject(*this, *object); + applyPropertySyncs(*this, true); + return true; + } + + 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; + } + 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())); + } + applyPropertySyncs(*this, true); + return true; +} + +bool Context::setSceneProperty(const std::string §ion, int index, + const std::string &propertyPath, + const json &value) { + const std::string normalizedSection = normalizeToken(section); + if (normalizedSection == "camera") { + if (!setJsonProperty(editorCameraData, propertyPath, value)) { + return false; + } + applyEditorCameraData(*this); + applyPropertySyncs(*this, true); + return true; + } + if (normalizedSection == "environment") { + const bool changed = + setJsonProperty(editorEnvironmentData, propertyPath, value); + if (changed) + applyPropertySyncs(*this, true); + return changed; + } + if (normalizedSection == "target" || normalizedSection == "targets") { + if (index < 0 && propertyPath.empty() && value.is_array()) { + editorTargetData = value; + return true; + } + if (!editorTargetData.is_array() || index < 0 || + index >= static_cast(editorTargetData.size()) || + !editorTargetData[index].is_object()) { + return false; + } + return setJsonProperty(editorTargetData[index], propertyPath, value); + } + return false; +} + +bool Context::setPropertySync(const json &target, const json &source) { + if (!target.is_object() || !source.is_object()) + return false; + const json canonicalTarget = canonicalPropertySyncEndpoint(*this, target); + const json canonicalSource = canonicalPropertySyncEndpoint(*this, source); + if (!editorPropertySyncs.is_array()) + editorPropertySyncs = json::array(); + for (json &binding : editorPropertySyncs) { + if (binding.is_object() && binding.value("target", json()) == + canonicalTarget) { + binding["source"] = canonicalSource; + applyPropertySyncs(*this, true); + return true; + } + } + editorPropertySyncs.push_back( + json{{"target", canonicalTarget}, {"source", canonicalSource}}); + applyPropertySyncs(*this, true); + return true; +} + +bool Context::clearPropertySync(const json &target) { + if (!target.is_object() || !editorPropertySyncs.is_array()) + return false; + const json canonicalTarget = canonicalPropertySyncEndpoint(*this, target); + const auto previousSize = editorPropertySyncs.size(); + editorPropertySyncs.erase( + std::remove_if(editorPropertySyncs.begin(), editorPropertySyncs.end(), + [&canonicalTarget](const json &binding) { + return binding.is_object() && + binding.value("target", json()) == + canonicalTarget; + }), + editorPropertySyncs.end()); + return editorPropertySyncs.size() != previousSize; +} + +bool Context::setObjectMaterial(int id, const std::string &path) { + GameObject *object = findContextObject(*this, id); + if (object == nullptr || path.empty() || + (dynamic_cast(object) == nullptr && + dynamic_cast(object) == nullptr)) { + return false; + } + try { + applyMaterial(*object, loadMaterialDefinition(path, sceneDir)); + } catch (const std::exception &error) { + RUNTIME_LOG("Material could not be applied: " + + std::string(error.what())); + return false; + } + std::string storedPath = path; + std::error_code error; + const std::filesystem::path relative = + std::filesystem::relative(path, sceneDir, error); + if (!error && !relative.empty()) { + storedPath = relative.generic_string(); + } + editorObjectSourceData[id]["material"] = storedPath; + return true; +} + +static json inheritedRigidbodyCollider(GameObject &object) { + return json{{"type", "box"}, {"size", editorObjectBoundsSize(object)}}; +} + +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 storedComponent = component; + if (normalizedType == "rigidbody") { + json &collider = storedComponent["collider"]; + const bool inheritObjectSize = + collider.is_object() && + collider.value("inheritObjectSize", false); + if (inheritObjectSize) { + collider = inheritedRigidbodyCollider(*object); + } + } + + 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(storedComponent); + editorComponentBaseDirs[id].resize(static_cast(index)); + editorComponentBaseDirs[id].push_back(sceneDir); + PendingComponent pending{ + .object = object, + .objectType = objectSceneTypes.contains(id) ? objectSceneTypes[id] : "", + .baseDir = sceneDir, + .data = storedComponent, + .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::removeObjectComponent(int id, int componentIndex) { + auto components = editorComponentData.find(id); + if (components == editorComponentData.end() || + !components->second.is_array() || componentIndex < 0 || + componentIndex >= static_cast(components->second.size())) { + return false; + } + + components->second.erase(components->second.begin() + componentIndex); + if (auto baseDirs = editorComponentBaseDirs.find(id); + baseDirs != editorComponentBaseDirs.end() && + componentIndex < static_cast(baseDirs->second.size())) { + baseDirs->second.erase(baseDirs->second.begin() + componentIndex); + } + if (auto runtimeComponents = editorRuntimeComponents.find(id); + runtimeComponents != editorRuntimeComponents.end() && + componentIndex < static_cast(runtimeComponents->second.size())) { + runtimeComponents->second.erase(runtimeComponents->second.begin() + + componentIndex); + } + return true; +} + +bool Context::controlObjectAudio(int id, int componentIndex, + const std::string &action) { + auto components = editorRuntimeComponents.find(id); + if (components == editorRuntimeComponents.end() || componentIndex < 0 || + componentIndex >= static_cast(components->second.size())) { + return false; + } + auto component = components->second[componentIndex].lock(); + auto audio = std::dynamic_pointer_cast(component); + if (audio == nullptr) { + return false; + } + const std::string normalizedAction = normalizeToken(action); + if (normalizedAction == "play") { + audio->play(); + } else if (normalizedAction == "pause") { + audio->pause(); + } else if (normalizedAction == "stop") { + audio->stop(); + } else { + return false; + } + return true; +} + +bool Context::setObjectParent(int childId, int parentId) { + GameObject *child = findContextObject(*this, childId); + if (child == nullptr) { + return false; + } + + GameObject *parent = nullptr; + if (parentId >= 0) { + parent = findContextObject(*this, parentId); + if (parent == nullptr || parent == child) { + return false; + } + + int cursor = parentId; + while (cursor >= 0) { + if (cursor == childId) { + return false; + } + auto parentIt = objectParents.find(cursor); + if (parentIt == objectParents.end()) { + break; + } + cursor = parentIt->second; + } + } + + if (parentId < 0) { + objectParents.erase(childId); + objectParentReferences.erase(childId); + editorObjectSourceData[childId].erase("parent"); + if (window != nullptr) { + window->setEditorObjectParent(child, nullptr); + } + return true; + } + + 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); + } + + return true; +} + +bool Context::deleteObject(int id) { + GameObject *object = findContextObject(*this, id); + if (object == nullptr) { + return false; + } + + std::vector childrenToDelete; + for (const auto &[childId, parentId] : objectParents) { + if (parentId == id) { + childrenToDelete.push_back(childId); + } + } + for (int childId : childrenToDelete) { + deleteObject(childId); + } + + const std::string name = serializableObjectName(*this, *object); + const std::string reference = serializableObjectReference(*this, *object); + deletedObjectReferences.push_back({name, reference}); + + setObjectParent(id, -1); + for (auto it = objectParents.begin(); it != objectParents.end();) { + if (it->second == id) { + objectParentReferences.erase(it->first); + it = objectParents.erase(it); + } else { + ++it; + } + } + + for (auto it = objectReferences.begin(); it != objectReferences.end();) { + if (it->second == object) { + it = objectReferences.erase(it); + } else { + ++it; + } + } + + if (auto it = editorPointLights.find(id); it != editorPointLights.end()) { + Light *light = it->second; + if (scene != nullptr) { + scene->pointLights.erase(std::remove(scene->pointLights.begin(), + scene->pointLights.end(), + light), + scene->pointLights.end()); + } + pointLights.erase(std::remove_if(pointLights.begin(), pointLights.end(), + [&](const auto &entry) { + return entry != nullptr && + entry.get() == light; + }), + pointLights.end()); + editorPointLights.erase(it); + } + if (auto it = editorSpotlights.find(id); it != editorSpotlights.end()) { + Spotlight *light = it->second; + if (scene != nullptr) { + scene->spotlights.erase(std::remove(scene->spotlights.begin(), + scene->spotlights.end(), light), + scene->spotlights.end()); + } + spotlights.erase(std::remove_if(spotlights.begin(), spotlights.end(), + [&](const auto &entry) { + return entry != nullptr && + entry.get() == light; + }), + spotlights.end()); + editorSpotlights.erase(it); + } + if (auto it = editorAreaLights.find(id); it != editorAreaLights.end()) { + AreaLight *light = it->second; + if (scene != nullptr) { + scene->areaLights.erase(std::remove(scene->areaLights.begin(), + scene->areaLights.end(), light), + scene->areaLights.end()); + } + areaLights.erase(std::remove_if(areaLights.begin(), areaLights.end(), + [&](const auto &entry) { + return entry != nullptr && + entry.get() == light; + }), + areaLights.end()); + editorAreaLights.erase(it); + } + if (auto it = editorDirectionalLights.find(id); + it != editorDirectionalLights.end()) { + DirectionalLight *light = it->second; + if (scene != nullptr) { + scene->directionalLights.erase( + std::remove(scene->directionalLights.begin(), + scene->directionalLights.end(), light), + scene->directionalLights.end()); + } + directionalLights.erase( + std::remove_if(directionalLights.begin(), directionalLights.end(), + [&](const auto &entry) { + return entry != nullptr && entry.get() == light; + }), + directionalLights.end()); + editorDirectionalLights.erase(it); + } + editorLightSourceData.erase(id); + editorObjectSourceData.erase(id); + editorComponentData.erase(id); + editorComponentBaseDirs.erase(id); + editorRuntimeComponents.erase(id); + objectNames.erase(id); + objectSceneReferences.erase(id); + objectSceneTypes.erase(id); + objectSceneSolidTypes.erase(id); + objectParentReferences.erase(id); + + for (const auto &renderable : objects) { + auto *compound = dynamic_cast(renderable.get()); + if (compound == nullptr) { + continue; + } + auto &compoundObjects = compound->objects; + compoundObjects.erase( + std::remove(compoundObjects.begin(), compoundObjects.end(), object), + compoundObjects.end()); + } + + if (window != nullptr) { + window->removeObject(object); + } + + auto objectIt = std::find_if(objects.begin(), objects.end(), + [&](const auto &renderable) { + return renderable != nullptr && + renderable.get() == object; + }); + if (objectIt != objects.end()) { + retiredObjects.push_back(std::move(*objectIt)); + objects.erase(objectIt); + } + return true; +} + +int Context::createObject(const std::string &type, const std::string &name) { + if (window == nullptr) { + return -1; + } + + const std::string normalized = normalizeToken(type); + std::shared_ptr object; + std::string sceneType = "solid"; + std::string solidType = normalized.empty() ? "cube" : normalized; + std::string fallbackName = solidType; + + Position3d position = Position3d::zero(); + if (window->getSelectedEditorObject() != nullptr) { + position = window->getSelectedEditorObject()->getPosition(); + position.x += 1.25f; + } else if (window->getCamera() != nullptr) { + position = window->getCamera()->target; + } + + if (solidType == "cube" || solidType == "box") { + solidType = "cube"; + auto core = std::make_shared(); + *core = createBox({1.0f, 1.0f, 1.0f}, Color::white()); + object = core; + } else if (solidType == "sphere") { + auto core = std::make_shared(); + *core = createSphere(0.5f, 36, 18, Color::white()); + object = core; + } else if (solidType == "plane") { + auto core = std::make_shared(); + *core = createPlane({1.0f, 1.0f}, Color::white()); + object = core; + } else if (solidType == "pyramid") { + auto core = std::make_shared(); + *core = createPyramid({1.0f, 1.0f, 1.0f}, Color::white()); + object = core; + } else if (solidType == "capsule") { + auto core = std::make_shared(); + *core = createCapsulePrimitive(0.35f, 1.0f, Color::white()); + object = core; + } else if (solidType == "group" || solidType == "empty" || + solidType == "emptygameobject") { + sceneType = "compound"; + fallbackName = "Group"; + object = std::make_shared(); + solidType.clear(); + } else if (solidType == "camera") { + sceneType = "camera"; + fallbackName = "Camera"; + auto core = std::make_shared(); + *core = createPyramid({0.65f, 0.45f, 0.65f}, + Color{0.25f, 0.55f, 1.0f, 1.0f}); + object = core; + solidType.clear(); + } else if (solidType == "particleemitter" || solidType == "particles" || + solidType == "particlegenerator") { + sceneType = "particleEmitter"; + fallbackName = "Particle Emitter"; + auto emitter = std::make_shared(100); + emitter->setPosition(position); + emitter->setSpawnRate(10.0f); + object = emitter; + solidType.clear(); + } else if (solidType == "terrain" || solidType == "landscape") { + sceneType = "terrain"; + fallbackName = "Terrain"; + auto terrain = std::make_shared(); + terrain->width = 32; + terrain->height = 32; + terrain->resolution = 64; + terrain->maxPeak = 1.0f; + terrain->seaLevel = 0.0f; + object = terrain; + solidType.clear(); + } else if (solidType == "pointlight" || solidType == "light") { + auto light = std::make_unique(position, Color::white(), 50.0f, + Color::white(), 1.0f); + light->createDebugObject(); + int id = registerEditorLightObject(*this, light->debugObject, + json::object(), "pointLight"); + if (id < 0) { + return -1; + } + const std::string displayName = + uniqueEditorObjectName(*this, name.empty() ? "Point Light" : name); + light->debugObject->name = displayName; + objectNames[id] = displayName; + objectSceneReferences[id] = std::to_string(id); + registerObjectReference(*this, displayName, light->debugObject.get()); + editorPointLights[id] = light.get(); + scene->addLight(light.get()); + pointLights.push_back(std::move(light)); + window->selectEditorObject(light->debugObject.get(), true); + return id; + } else if (solidType == "spotlight" || solidType == "spot") { + auto light = std::make_unique(position, Position3d::down(), + Color::white(), 35.0f, 40.0f, + Color::white(), 1.0f, 50.0f); + light->createDebugObject(); + int id = registerEditorLightObject(*this, light->debugObject, + json::object(), "spotLight"); + if (id < 0) { + return -1; + } + const std::string displayName = + uniqueEditorObjectName(*this, name.empty() ? "Spot Light" : name); + light->debugObject->name = displayName; + objectNames[id] = displayName; + objectSceneReferences[id] = std::to_string(id); + registerObjectReference(*this, displayName, light->debugObject.get()); + editorSpotlights[id] = light.get(); + scene->addSpotlight(light.get()); + spotlights.push_back(std::move(light)); + window->selectEditorObject(light->debugObject.get(), true); + return id; + } else if (solidType == "directionallight" || solidType == "directional" || + solidType == "sun") { + auto light = std::make_unique( + Position3d::down(), Color::white(), Color::white(), 1.0f); + auto proxy = createEditorLightProxy("directionalLight", Color::white(), + position); + proxy->lookAt(position + Position3d::down(), Position3d::up()); + int id = registerEditorLightObject(*this, proxy, json::object(), + "directionalLight"); + if (id < 0) { + return -1; + } + const std::string displayName = uniqueEditorObjectName( + *this, name.empty() ? "Directional Light" : name); + proxy->name = displayName; + objectNames[id] = displayName; + objectSceneReferences[id] = std::to_string(id); + registerObjectReference(*this, displayName, proxy.get()); + editorDirectionalLights[id] = light.get(); + scene->addDirectionalLight(light.get()); + directionalLights.push_back(std::move(light)); + window->selectEditorObject(proxy.get(), true); + return id; + } else if (solidType == "arealight" || solidType == "area") { + auto light = std::make_unique(); + light->position = position; + light->createDebugObject(); + int id = registerEditorLightObject(*this, light->debugObject, + json::object(), "areaLight"); + if (id < 0) { + return -1; + } + const std::string displayName = + uniqueEditorObjectName(*this, name.empty() ? "Area Light" : name); + light->debugObject->name = displayName; + objectNames[id] = displayName; + objectSceneReferences[id] = std::to_string(id); + registerObjectReference(*this, displayName, light->debugObject.get()); + editorAreaLights[id] = light.get(); + scene->addAreaLight(light.get()); + areaLights.push_back(std::move(light)); + window->selectEditorObject(light->debugObject.get(), true); + return id; + } else if (solidType == "ambientlight" || solidType == "ambient") { + scene->setAmbientColor(Color::white()); + scene->setAmbientIntensity(2.0f); + auto proxy = + createEditorLightProxy("ambientLight", Color::white(), position); + int id = registerEditorLightObject(*this, proxy, json::object(), + "ambientLight"); + if (id < 0) { + return -1; + } + const std::string displayName = uniqueEditorObjectName( + *this, name.empty() ? "Ambient Light" : name); + proxy->name = displayName; + objectNames[id] = displayName; + objectSceneReferences[id] = std::to_string(id); + registerObjectReference(*this, displayName, proxy.get()); + window->selectEditorObject(proxy.get(), true); + return id; + } else { + return -1; + } + + object->setPosition(position); + + const int id = static_cast(object->getId()); + const std::string displayName = + uniqueEditorObjectName(*this, name.empty() ? fallbackName : name); + object->name = displayName; + objectNames[id] = displayName; + objectSceneReferences[id] = std::to_string(id); + objectSceneTypes[id] = sceneType; + if (!solidType.empty()) { + objectSceneSolidTypes[id] = solidType; + } + 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(); + editorComponentBaseDirs[id] = {}; + + objects.push_back(object); + window->addObject(object.get()); + window->selectEditorObject(object.get(), true); + return id; +} + +std::string Context::objectDefinitionJson(int id) const { + GameObject *object = findContextObject(*this, id); + return object != nullptr ? serializeNewObject(*this, *object).dump() + : std::string(); +} + +int Context::pasteObjectDefinition(const std::string &definition) { + if (window == nullptr || currentSceneFile.empty() || definition.empty() || + !saveCurrentScene()) { + return -1; + } + try { + json objectData = json::parse(definition); + if (!objectData.is_object()) + return -1; + const std::string baseName = + objectData.value("name", std::string("Object")); + const std::string name = uniqueEditorObjectName(*this, baseName); + objectData["name"] = name; + objectData.erase("id"); + objectData.erase("parent"); + if (objectData.contains("position") && + objectData["position"].is_array() && + objectData["position"].size() >= 3) { + objectData["position"][0] = + objectData["position"][0].get() + 0.5; + objectData["position"][2] = + objectData["position"][2].get() + 0.5; + } + json sceneData = loadJsonFile(currentSceneFile); + if (!sceneData.is_object()) + return -1; + if (!sceneData.contains("objects") || !sceneData["objects"].is_array()) + sceneData["objects"] = json::array(); + sceneData["objects"].push_back(objectData); + std::ofstream output(currentSceneFile, std::ios::trunc); + if (!output.is_open()) + return -1; + output << sceneData.dump(4) << '\n'; + if (!output.good()) + return -1; + output.close(); + loadScene(*window, sceneData); + auto pasted = objectReferences.find(name); + if (pasted == objectReferences.end()) + pasted = objectReferences.find(normalizeToken(name)); + if (pasted == objectReferences.end() || pasted->second == nullptr) + return -1; + window->selectEditorObject(pasted->second, false); + return static_cast(pasted->second->getId()); + } catch (const std::exception &error) { + RUNTIME_LOG("Could not paste object: " + std::string(error.what())); + return -1; + } +} + +bool Context::saveCurrentScene() { + if (currentSceneFile.empty()) { + return false; + } + + json sceneData = loadJsonFile(currentSceneFile); + if (!sceneData.is_object()) { + return false; + } + if (!sceneData.contains("objects") || !sceneData["objects"].is_array()) { + sceneData["objects"] = json::array(); + } + if (!sceneData.contains("lights") || !sceneData["lights"].is_array()) { + sceneData["lights"] = json::array(); + } + + for (const auto &[name, reference] : deletedObjectReferences) { + removeObjectNode(sceneData["objects"], name, reference); + } + + json serializedLights = json::array(); + for (const auto &renderable : objects) { + if (renderable == nullptr) { + continue; + } + + auto *object = dynamic_cast(renderable.get()); + if (object == nullptr) { + continue; + } + + if (isEditorLightObject(*this, *object)) { + serializedLights.push_back( + serializeEditorLightObject(*this, *object)); + continue; + } + + bool updated = false; + for (auto &objectNode : sceneData["objects"]) { + if (updateObjectNode(objectNode, *this, *object)) { + updated = true; + break; + } + } + + if (!updated) { + sceneData["objects"].push_back(serializeNewObject(*this, *object)); + } + } + sceneData["lights"] = serializedLights; + sceneData["camera"] = serializedEditorCamera(*this); + sceneData["targets"] = editorTargetData; + if (!editorEnvironmentData.empty()) { + sceneData["environment"] = editorEnvironmentData; + } + sceneData["property_syncs"] = editorPropertySyncs; + + std::ofstream output(currentSceneFile, std::ios::trunc); + if (!output.is_open()) { + return false; + } + output << sceneData.dump(4) << '\n'; + bool good = output.good(); + if (good) { + deletedObjectReferences.clear(); + } + return good; +} + +bool Context::openSceneFile(const std::string &path) { + if (window == nullptr || path.empty()) + return false; + try { + const std::filesystem::path requested(path); + const std::string resolved = + requested.is_absolute() + ? requested.lexically_normal().string() + : resolveRuntimePath(projectDir, path); + json sceneData = loadJsonFile(resolved); + if (!sceneData.is_object()) + return false; + currentSceneFile = resolved; + sceneDir = std::filesystem::path(resolved).parent_path().string(); + loadScene(*window, sceneData); + return true; + } catch (const std::exception &error) { + RUNTIME_LOG("Could not open scene: " + std::string(error.what())); + return false; + } +} + +std::string Context::currentScenePath() const { return currentSceneFile; } + void Context::end() { if (window == nullptr) { return; @@ -3198,6 +5667,35 @@ void Context::end() { window->endRunLoop(); } +Context::~Context() { + try { + end(); + } catch (...) { + } + if (context != nullptr) { + runtime::scripting::clearSceneBindings(context, scriptHost); + editorRuntimeComponents.clear(); + objects.clear(); + renderTargets.clear(); + directionalLights.clear(); + pointLights.clear(); + spotlights.clear(); + areaLights.clear(); + if (runtime != nullptr) { + JS_RunGC(runtime); + } + JS_SetContextOpaque(context, nullptr); + JS_FreeContext(context); + context = nullptr; + } + if (runtime != nullptr) { + JS_RunGC(runtime); + JS_FreeRuntime(runtime); + runtime = nullptr; + } + scriptHost.context = nullptr; +} + void Context::loadProject() { if (!std::filesystem::exists(projectFile)) { throw std::runtime_error("Project file does not exist: " + projectFile); @@ -3252,33 +5750,36 @@ void Context::loadProject() { } void RuntimeScene::update(Window &window) { - if (context == nullptr || context->camera == nullptr || - !context->cameraAutomaticMoving) { - if (context != nullptr && context->context != nullptr) { + auto runtimeContext = context.lock(); + if (runtimeContext == nullptr || runtimeContext->camera == nullptr || + !runtimeContext->cameraAutomaticMoving) { + if (runtimeContext != nullptr && runtimeContext->context != nullptr) { runtime::scripting::dispatchInteractiveFrame( - context->context, context->scriptHost, window, + runtimeContext->context, runtimeContext->scriptHost, window, window.getDeltaTime()); } return; } - if (context->cameraActions.size() >= 3) { - context->camera->updateWithActions(window, context->cameraActions[0], - context->cameraActions[1], - context->cameraActions[2]); + if (runtimeContext->cameraActions.size() >= 3) { + runtimeContext->camera->updateWithActions(window, + runtimeContext->cameraActions[0], + runtimeContext->cameraActions[1], + runtimeContext->cameraActions[2]); } else { - context->camera->update(window); + runtimeContext->camera->update(window); } - if (context->context != nullptr) { + if (runtimeContext->context != nullptr) { runtime::scripting::dispatchInteractiveFrame( - context->context, context->scriptHost, window, + runtimeContext->context, runtimeContext->scriptHost, window, window.getDeltaTime()); } } void RuntimeScene::onMouseMove(Window &window, Movement2d movement) { - if (context != nullptr && context->context != nullptr) { + auto runtimeContext = context.lock(); + if (runtimeContext != nullptr && runtimeContext->context != nullptr) { const auto [x, y] = window.getCursorPosition(); MousePacket packet; packet.xpos = static_cast(x); @@ -3286,32 +5787,35 @@ void RuntimeScene::onMouseMove(Window &window, Movement2d movement) { packet.xoffset = movement.x; packet.yoffset = movement.y; packet.constrainPitch = true; - packet.firstMouse = context->scriptHost.interactiveFirstMouse; + packet.firstMouse = runtimeContext->scriptHost.interactiveFirstMouse; runtime::scripting::dispatchInteractiveMouseMove( - context->context, context->scriptHost, window, packet, + runtimeContext->context, runtimeContext->scriptHost, window, packet, window.getDeltaTime()); } - if (context == nullptr || context->camera == nullptr || - !context->cameraAutomaticMoving || context->cameraActions.size() >= 3) { + if (runtimeContext == nullptr || runtimeContext->camera == nullptr || + !runtimeContext->cameraAutomaticMoving || + runtimeContext->cameraActions.size() >= 3) { return; } - context->camera->updateLook(window, movement); + runtimeContext->camera->updateLook(window, movement); } void RuntimeScene::onMouseScroll(Window &window, Movement2d offset) { - if (context != nullptr && context->context != nullptr) { + auto runtimeContext = context.lock(); + if (runtimeContext != nullptr && runtimeContext->context != nullptr) { MouseScrollPacket packet{offset.x, offset.y}; runtime::scripting::dispatchInteractiveMouseScroll( - context->context, context->scriptHost, packet, + runtimeContext->context, runtimeContext->scriptHost, packet, window.getDeltaTime()); } - if (context == nullptr || context->camera == nullptr || - !context->cameraAutomaticMoving || context->cameraActions.size() >= 3) { + if (runtimeContext == nullptr || runtimeContext->camera == nullptr || + !runtimeContext->cameraAutomaticMoving || + runtimeContext->cameraActions.size() >= 3) { return; } - context->camera->updateZoom(window, offset); + runtimeContext->camera->updateZoom(window, offset); } void Context::loadMainScene(Window &window) { @@ -3319,6 +5823,7 @@ void Context::loadMainScene(Window &window) { const std::string resolvedScenePath = resolveRuntimePath(projectDir, config.mainScene); json sceneData = loadJsonFile(resolvedScenePath); + currentSceneFile = resolvedScenePath; sceneDir = std::filesystem::path(resolvedScenePath).parent_path().string(); currentSceneName = std::filesystem::path(resolvedScenePath).stem().string(); auto sceneNameIt = sceneData.find("name"); @@ -3333,6 +5838,24 @@ void Context::loadScene(Window &window, const json &sceneData) { if (sceneNameIt != sceneData.end() && sceneNameIt->is_string()) { currentSceneName = sceneNameIt->get(); } + editorCameraData = + sceneData.contains("camera") && sceneData["camera"].is_object() + ? sceneData["camera"] + : json::object(); + editorTargetData = + sceneData.contains("targets") && sceneData["targets"].is_array() + ? sceneData["targets"] + : json::array(); + editorEnvironmentData = + sceneData.contains("environment") && + sceneData["environment"].is_object() + ? sceneData["environment"] + : json::object(); + editorPropertySyncs = + sceneData.contains("property_syncs") && + sceneData["property_syncs"].is_array() + ? sceneData["property_syncs"] + : json::array(); scene->atmosphere.resetRuntimeState(); scene->setUseAtmosphereSkybox(false); @@ -3366,9 +5889,27 @@ void Context::loadScene(Window &window, const json &sceneData) { } } + retiredObjects.insert(retiredObjects.end(), + std::make_move_iterator(objects.begin()), + std::make_move_iterator(objects.end())); objects.clear(); objectReferences.clear(); objectNames.clear(); + objectSceneReferences.clear(); + objectSceneTypes.clear(); + objectSceneSolidTypes.clear(); + objectParentReferences.clear(); + objectParents.clear(); + editorObjectSourceData.clear(); + editorComponentData.clear(); + editorComponentBaseDirs.clear(); + editorRuntimeComponents.clear(); + editorPointLights.clear(); + editorSpotlights.clear(); + editorAreaLights.clear(); + editorDirectionalLights.clear(); + editorLightSourceData.clear(); + deletedObjectReferences.clear(); renderTargets.clear(); directionalLights.clear(); pointLights.clear(); @@ -3496,6 +6037,8 @@ void Context::loadScene(Window &window, const json &sceneData) { } } + applyEditorCameraData(*this); + window.setCamera(camera.get()); if (sceneData.contains("lights") && sceneData["lights"].is_array()) { @@ -3519,6 +6062,15 @@ void Context::loadScene(Window &window, const json &sceneData) { JSON_READ_FLOAT(lightData, "intensity", intensity); scene->setAmbientColor(ambientColor); scene->setAmbientIntensity(intensity * 4.0f); + if (editorRuntime) { + Position3d position = Position3d::zero(); + tryReadVec3(lightData, "position", position); + auto object = createEditorLightProxy( + "ambientLight", ambientColor, position); + int id = registerEditorLightObject(*this, object, lightData, + "ambientLight"); + (void)id; + } continue; } @@ -3543,6 +6095,19 @@ void Context::loadScene(Window &window, const json &sceneData) { if (castsShadows) { light->castShadows(window, shadowResolution); } + if (editorRuntime) { + Position3d position = Position3d::zero(); + tryReadVec3(lightData, "position", position); + auto object = createEditorLightProxy("directionalLight", + color, position); + object->lookAt(position + direction.normalized(), + Position3d::up()); + int id = registerEditorLightObject(*this, object, lightData, + "directionalLight"); + if (id >= 0) { + editorDirectionalLights[id] = light.get(); + } + } scene->addDirectionalLight(light.get()); directionalLights.push_back(std::move(light)); continue; @@ -3572,9 +6137,19 @@ void Context::loadScene(Window &window, const json &sceneData) { if (castsShadows) { light->castShadows(window, shadowResolution); } - if (addDebugObject) { + if (editorRuntime) { light->createDebugObject(); - light->addDebugObject(window); + if (light->debugObject != nullptr) { + int id = registerEditorLightObject( + *this, light->debugObject, lightData, "pointLight"); + if (id >= 0) { + editorPointLights[id] = light.get(); + if (addDebugObject) { + editorLightSourceData[id]["addDebugObject"] = + true; + } + } + } } scene->addLight(light.get()); pointLights.push_back(std::move(light)); @@ -3612,9 +6187,19 @@ void Context::loadScene(Window &window, const json &sceneData) { if (castsShadows) { light->castShadows(window, shadowResolution); } - if (addDebugObject) { + if (editorRuntime) { light->createDebugObject(); - light->addDebugObject(window); + if (light->debugObject != nullptr) { + int id = registerEditorLightObject( + *this, light->debugObject, lightData, "spotLight"); + if (id >= 0) { + editorSpotlights[id] = light.get(); + if (addDebugObject) { + editorLightSourceData[id]["addDebugObject"] = + true; + } + } + } } scene->addSpotlight(light.get()); spotlights.push_back(std::move(light)); @@ -3655,9 +6240,19 @@ void Context::loadScene(Window &window, const json &sceneData) { if (castsShadows) { light->castShadows(window, shadowResolution); } - if (addDebugObject) { + if (editorRuntime) { light->createDebugObject(); - light->addDebugObject(window); + if (light->debugObject != nullptr) { + int id = registerEditorLightObject( + *this, light->debugObject, lightData, "areaLight"); + if (id >= 0) { + editorAreaLights[id] = light.get(); + if (addDebugObject) { + editorLightSourceData[id]["addDebugObject"] = + true; + } + } + } } scene->addAreaLight(light.get()); areaLights.push_back(std::move(light)); @@ -3689,6 +6284,40 @@ void Context::loadScene(Window &window, const json &sceneData) { } } + resolveObjectParentReferences(*this); + for (const auto &[childId, parentId] : objectParents) { + GameObject *child = findContextObject(*this, childId); + GameObject *parent = findContextObject(*this, parentId); + if (auto *compound = dynamic_cast(parent); + compound != nullptr && + std::ranges::find(compound->objects, child) != + compound->objects.end()) { + continue; + } + window.setEditorObjectParent(child, parent); + } + + applyPropertySyncs(*this, false); + + auto refreshPendingSyncValues = [this](std::vector &list) { + for (PendingComponent &pending : list) { + if (pending.object == nullptr) + continue; + const int id = static_cast(pending.object->getId()); + auto components = editorComponentData.find(id); + if (components == editorComponentData.end() || + !components->second.is_array() || pending.componentIndex < 0 || + pending.componentIndex >= + static_cast(components->second.size())) { + continue; + } + pending.data = components->second[pending.componentIndex]; + } + }; + refreshPendingSyncValues(rigidbodyComponents); + refreshPendingSyncValues(standardComponents); + refreshPendingSyncValues(jointComponents); + for (const auto &pending : rigidbodyComponents) { try { attachComponent(*this, pending); @@ -3725,6 +6354,19 @@ void Context::loadScene(Window &window, const json &sceneData) { continue; } + if (auto object = std::dynamic_pointer_cast(renderable); + object != nullptr) { + auto parentIt = + objectParents.find(static_cast(object->getId())); + if (parentIt != objectParents.end()) { + if (auto *parentObject = + findContextObject(*this, parentIt->second); + dynamic_cast(parentObject) != nullptr) { + continue; + } + } + } + window.addObject(renderable.get()); } } diff --git a/runtime/lib/runtime.cpp b/runtime/lib/runtime.cpp index 1009dd1d..a2f569bf 100644 --- a/runtime/lib/runtime.cpp +++ b/runtime/lib/runtime.cpp @@ -12,21 +12,26 @@ void RuntimeScene::initialize(Window &window) { // Set the properties of the project - if (context->config.renderer == "deferred") { + auto runtimeContext = context.lock(); + if (runtimeContext == nullptr) { + return; + } + + if (runtimeContext->config.renderer == "deferred") { window.useDeferredRendering(); - if (context->config.globalIllumination) { + if (runtimeContext->config.globalIllumination) { window.enableGlobalIllumination(); } - } else if (context->config.renderer == "pathtracing") { + } else if (runtimeContext->config.renderer == "pathtracing") { window.enablePathTracing(); } - if (context->config.useUpscaling) { + if (runtimeContext->config.useUpscaling) { #ifdef METAL window.useMetalUpscaling(); #endif } - context->loadMainScene(window); + runtimeContext->loadMainScene(window); } diff --git a/runtime/lib/scripting.cpp b/runtime/lib/scripting.cpp index 5ee9f97f..066cf3f9 100644 --- a/runtime/lib/scripting.cpp +++ b/runtime/lib/scripting.cpp @@ -604,6 +604,8 @@ void assignObjectName(Context &context, GameObject &object, if (name.empty()) { context.objectNames.erase(objectId); + context.objectSceneReferences.erase(objectId); + object.name.clear(); return; } @@ -624,6 +626,7 @@ void assignObjectName(Context &context, GameObject &object, context.objectReferences[name] = &object; context.objectReferences[normalized] = &object; context.objectNames[objectId] = name; + object.name = name; } void cachePrototype(JSContext *ctx, JSValueConst ns, const char *exportName, @@ -4207,6 +4210,9 @@ JSValue syncObjectWrapper(JSContext *ctx, ScriptHost &host, name = nameIt->second; } } + if (name.empty()) { + name = object.name; + } setProperty(ctx, wrapper, "name", JS_NewString(ctx, name.c_str())); if (auto *emitter = dynamic_cast(&object); @@ -15329,6 +15335,47 @@ void runtime::scripting::clearSceneBindings(JSContext *ctx, ScriptHost &host) { host.springJointPrototype = JS_UNDEFINED; } + auto freeHostValue = [ctx](JSValue &value) { + if (!JS_IsUndefined(value)) { + JS_FreeValue(ctx, value); + value = JS_UNDEFINED; + } + }; + freeHostValue(host.atlasNamespace); + freeHostValue(host.atlasInputNamespace); + freeHostValue(host.atlasUnitsNamespace); + freeHostValue(host.atlasGraphicsNamespace); + freeHostValue(host.componentPrototype); + freeHostValue(host.gameObjectPrototype); + freeHostValue(host.coreObjectPrototype); + freeHostValue(host.modelPrototype); + freeHostValue(host.materialPrototype); + freeHostValue(host.instancePrototype); + freeHostValue(host.coreVertexPrototype); + freeHostValue(host.resourcePrototype); + freeHostValue(host.windowPrototype); + freeHostValue(host.monitorPrototype); + freeHostValue(host.gamepadPrototype); + freeHostValue(host.joystickPrototype); + freeHostValue(host.cameraPrototype); + freeHostValue(host.scenePrototype); + freeHostValue(host.texturePrototype); + freeHostValue(host.cubemapPrototype); + freeHostValue(host.skyboxPrototype); + freeHostValue(host.renderTargetPrototype); + freeHostValue(host.pointLightPrototype); + freeHostValue(host.directionalLightPrototype); + freeHostValue(host.spotLightPrototype); + freeHostValue(host.areaLightPrototype); + freeHostValue(host.position3dPrototype); + freeHostValue(host.position2dPrototype); + freeHostValue(host.colorPrototype); + freeHostValue(host.size2dPrototype); + freeHostValue(host.quaternionPrototype); + freeHostValue(host.triggerPrototype); + freeHostValue(host.axisTriggerPrototype); + freeHostValue(host.inputActionPrototype); + for (JSValue &interactive : host.interactiveValues) { JS_FreeValue(ctx, interactive); } diff --git a/migrate_shaders.sh b/scripts/migrate_shaders.sh similarity index 100% rename from migrate_shaders.sh rename to scripts/migrate_shaders.sh diff --git a/scripts/pack_qt_themes.py b/scripts/pack_qt_themes.py new file mode 100644 index 00000000..2c44dbe0 --- /dev/null +++ b/scripts/pack_qt_themes.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys +import re + + +def constant_name(path: Path) -> str: + name = path.stem.upper() + name = re.sub(r"[^A-Z0-9]+", "_", name) + return f"{name}_THEME" + + +def cpp_string_literal(text: str) -> str: + text = text.replace("\\", "\\\\") + text = text.replace("\"", "\\\"") + text = text.replace("\r\n", "\n") + text = text.replace("\r", "\n") + + return "\n".join(f'"{line}\\n"' for line in text.splitlines()) + + +def main() -> int: + if len(sys.argv) < 3: + print("usage: embed_qss.py ") + return 1 + + output = Path(sys.argv[1]) + qss_files = [Path(p) for p in sys.argv[2:]] + + output.parent.mkdir(parents=True, exist_ok=True) + + lines: list[str] = [ + "#pragma once", + "", + "// This file is generated. Do not edit manually.", + "// Generated from .qss theme files.", + "", + ] + + for qss_file in qss_files: + text = qss_file.read_text(encoding="utf-8") + constant = constant_name(qss_file) + + lines.append(f"// Source: {qss_file.as_posix()}") + lines.append(f"inline constexpr const char* {constant} =") + lines.append(cpp_string_literal(text)) + lines.append(";") + lines.append("") + + output.write_text("\n".join(lines), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pack_runtime_scripts.py b/scripts/pack_runtime_scripts.py similarity index 100% rename from pack_runtime_scripts.py rename to scripts/pack_runtime_scripts.py diff --git a/pack_shaders.py b/scripts/pack_shaders.py similarity index 100% rename from pack_shaders.py rename to scripts/pack_shaders.py diff --git a/scripts/package_app.py b/scripts/package_app.py new file mode 100755 index 00000000..734bada9 --- /dev/null +++ b/scripts/package_app.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 + +import argparse +import os +import platform +import plistlib +import shutil +import subprocess +import sys +from pathlib import Path + + +def run(command, cwd=None, capture=False): + print("+", " ".join(str(part) for part in command), flush=True) + return subprocess.run( + [str(part) for part in command], + cwd=cwd, + check=True, + text=True, + capture_output=capture, + ) + + +def require(name, override=None): + candidate = override or shutil.which(name) + if not candidate: + raise RuntimeError(f"Required tool was not found: {name}") + return Path(candidate) + + +def compile_icon(config, artwork, output_directory, work_directory, + deployment_target): + source = work_directory / "AtlasEngine.icon" + if source.exists(): + shutil.rmtree(source) + (source / "Assets").mkdir(parents=True) + shutil.copy2(config, source / "icon.json") + shutil.copy2(artwork, source / "Assets" / "atlas_ball_bright.png") + if output_directory.exists(): + shutil.rmtree(output_directory) + output_directory.mkdir(parents=True) + partial_plist = output_directory / "icon-info.plist" + run([ + "/usr/bin/xcrun", + "actool", + "--compile", + output_directory, + "--platform", + "macosx", + "--minimum-deployment-target", + deployment_target, + "--app-icon", + "AtlasEngine", + "--output-partial-info-plist", + partial_plist, + source, + ]) + icon = output_directory / "AtlasEngine.icns" + assets = output_directory / "Assets.car" + if not icon.is_file() or not assets.is_file(): + raise RuntimeError("Xcode did not compile the complete Atlas app icon") + return icon, assets + + +def locate_macdeployqt(): + configured = os.environ.get("ATLAS_MACDEPLOYQT") + if configured: + return require("macdeployqt", configured) + found = shutil.which("macdeployqt") + if found: + return Path(found) + qtpaths = shutil.which("qtpaths6") or shutil.which("qtpaths") + if qtpaths: + result = run([qtpaths, "--query", "QT_INSTALL_BINS"], capture=True) + candidate = Path(result.stdout.strip()) / "macdeployqt" + if candidate.is_file(): + return candidate + raise RuntimeError("macdeployqt was not found. Install Qt 6 or set ATLAS_MACDEPLOYQT.") + + +def macho_dependencies(bundle): + file_tool = require("file", "/usr/bin/file") + otool = require("otool", "/usr/bin/otool") + invalid = [] + for path in bundle.rglob("*"): + if not path.is_file() or path.is_symlink(): + continue + kind = subprocess.run( + [file_tool, "-b", path], + check=True, + text=True, + capture_output=True, + ).stdout + if "Mach-O" not in kind: + continue + install_ids = set( + subprocess.run( + [otool, "-D", path], + check=False, + text=True, + capture_output=True, + ).stdout.splitlines()[1:] + ) + output = subprocess.run( + [otool, "-L", path], + check=True, + text=True, + capture_output=True, + ).stdout.splitlines()[1:] + for line in output: + dependency = line.strip().split(" (", 1)[0] + if dependency in install_ids: + continue + if dependency.startswith(("@", "/System/Library/", "/usr/lib/")): + continue + invalid.append((path, dependency)) + return invalid + + +def sign_bundle(bundle, identity): + codesign = require("codesign", "/usr/bin/codesign") + command = [codesign, "--force", "--deep"] + if identity != "-": + command.extend(["--options", "runtime", "--timestamp"]) + command.extend(["--sign", identity, bundle]) + run(command) + run([codesign, "--verify", "--deep", "--strict", "--verbose=2", bundle]) + + +def archive_bundle(bundle, archive): + if archive.exists(): + archive.unlink() + run([ + "/usr/bin/ditto", + "-c", + "-k", + "--sequesterRsrc", + "--keepParent", + bundle, + archive, + ]) + + +def create_dmg(bundle, dmg, staging_directory): + if staging_directory.exists(): + shutil.rmtree(staging_directory) + staging_directory.mkdir(parents=True) + run(["/usr/bin/ditto", bundle, staging_directory / bundle.name]) + os.symlink("/Applications", staging_directory / "Applications") + if dmg.exists(): + dmg.unlink() + run([ + "/usr/bin/hdiutil", + "create", + "-volname", + "Atlas Engine", + "-srcfolder", + staging_directory, + "-format", + "UDZO", + "-ov", + dmg, + ]) + + +def sign_dmg(dmg, identity): + if identity == "-": + return + run([ + "/usr/bin/codesign", + "--force", + "--timestamp", + "--sign", + identity, + dmg, + ]) + run(["/usr/bin/codesign", "--verify", "--verbose=2", dmg]) + + +def validate_release_identity(identity): + result = subprocess.run( + ["/usr/bin/security", "find-identity", "-p", "codesigning", "-v"], + check=True, + text=True, + capture_output=True, + ) + matches = [line for line in result.stdout.splitlines() if identity in line] + if not any('"Developer ID Application:' in line for line in matches): + raise RuntimeError( + "ATLAS_SIGNING_IDENTITY must select an installed Developer ID " + "Application certificate for a publishable release" + ) + + +def notarize(artifact, profile): + run([ + "/usr/bin/xcrun", + "notarytool", + "submit", + artifact, + "--keychain-profile", + profile, + "--wait", + ]) + run(["/usr/bin/xcrun", "stapler", "staple", artifact]) + run(["/usr/bin/xcrun", "stapler", "validate", artifact]) + + +def validate_dmg(dmg, mountpoint): + if mountpoint.exists(): + shutil.rmtree(mountpoint) + mountpoint.mkdir(parents=True) + run([ + "/usr/bin/hdiutil", + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mountpoint, + dmg, + ]) + try: + mounted_app = mountpoint / "Atlas Engine.app" + if not mounted_app.is_dir(): + raise RuntimeError("DMG does not contain Atlas Engine.app") + if not (mountpoint / "Applications").is_symlink(): + raise RuntimeError("DMG does not contain the Applications link") + run([ + "/usr/bin/codesign", + "--verify", + "--deep", + "--strict", + "--verbose=2", + mounted_app, + ]) + finally: + run(["/usr/bin/hdiutil", "detach", mountpoint]) + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="Build and package Atlas Engine as a self-contained macOS app." + ) + configuration = parser.add_mutually_exclusive_group(required=True) + configuration.add_argument("--debug", action="store_true") + configuration.add_argument("--release", action="store_true") + parser.add_argument("--macOS", dest="macos", action="store_true", required=True) + return parser.parse_args() + + +def main(): + args = parse_arguments() + if platform.system() != "Darwin": + raise RuntimeError("--macOS packaging must run on macOS") + + root = Path(__file__).resolve().parent.parent + mode = "release" if args.release else "debug" + configuration = mode.capitalize() + architectures = os.environ.get("ATLAS_MACOS_ARCHITECTURES", platform.machine()) + architecture_tag = "universal" if ";" in architectures else architectures + deployment_target = os.environ.get("ATLAS_MACOS_DEPLOYMENT_TARGET", "14.0") + signing_identity = os.environ.get("ATLAS_SIGNING_IDENTITY", "-") + notary_profile = os.environ.get("ATLAS_NOTARY_PROFILE") + allow_unnotarized = os.environ.get("ATLAS_ALLOW_UNNOTARIZED_RELEASE") == "1" + if args.release and (signing_identity == "-" or not notary_profile): + if not allow_unnotarized: + raise RuntimeError( + "A publishable release requires ATLAS_SIGNING_IDENTITY and " + "ATLAS_NOTARY_PROFILE. Set ATLAS_ALLOW_UNNOTARIZED_RELEASE=1 " + "only to create a local test DMG." + ) + if args.release and not allow_unnotarized: + validate_release_identity(signing_identity) + build_directory = root / "build" / "package" / f"macos-{mode}-{architecture_tag}" + assets_directory = build_directory / "package-assets" + dist_directory = root / "dist" / "macOS" / mode + app_name = "Atlas Engine.app" + built_app = build_directory / "bin" / app_name + packaged_app = dist_directory / app_name + icon_config = root / "editor" / "assets" / ( + "AtlasEngine.icon.json" if args.release else "AtlasEngineDev.icon.json" + ) + icon_artwork = root / "editor" / "assets" / "atlas_ball_bright.png" + + assets_directory.mkdir(parents=True, exist_ok=True) + dist_directory.mkdir(parents=True, exist_ok=True) + icon, icon_assets = compile_icon( + icon_config, + icon_artwork, + assets_directory / "compiled-icon", + assets_directory, + deployment_target, + ) + + run([ + require("cmake"), + "-S", + root, + "-B", + build_directory, + "-G", + "Ninja", + f"-DCMAKE_BUILD_TYPE={configuration}", + "-DBACKEND=METAL", + f"-DCMAKE_OSX_ARCHITECTURES={architectures}", + f"-DCMAKE_OSX_DEPLOYMENT_TARGET={deployment_target}", + f"-DATLAS_APP_ICON={icon}", + ]) + run([ + require("cmake"), + "--build", + build_directory, + "--target", + "AtlasEditor", + "--parallel", + str(os.cpu_count() or 4), + ]) + if not built_app.is_dir(): + raise RuntimeError(f"Atlas Engine app bundle was not produced at {built_app}") + + if packaged_app.exists(): + shutil.rmtree(packaged_app) + run(["/usr/bin/ditto", built_app, packaged_app]) + + deploy = [ + locate_macdeployqt(), + packaged_app, + "-always-overwrite", + f"-libpath={build_directory / 'lib'}", + ] + if signing_identity == "-": + deploy.append("-codesign=-") + elif notary_profile: + deploy.append(f"-sign-for-notarization={signing_identity}") + else: + deploy.extend([ + f"-codesign={signing_identity}", + "-hardened-runtime", + "-timestamp", + ]) + run(deploy) + + plist_path = packaged_app / "Contents" / "Info.plist" + resources_directory = packaged_app / "Contents" / "Resources" + shutil.copy2(icon_assets, resources_directory / "Assets.car") + with plist_path.open("rb") as stream: + plist = plistlib.load(stream) + plist["CFBundleIconFile"] = "AtlasEngine" + plist["CFBundleIconName"] = "AtlasEngine" + with plist_path.open("wb") as stream: + plistlib.dump(plist, stream) + sign_bundle(packaged_app, signing_identity) + + if plist.get("CFBundleIdentifier") != "neutralsoftware.atlas": + raise RuntimeError("Packaged app has the wrong bundle identifier") + if plist.get("LSMinimumSystemVersion") != deployment_target: + raise RuntimeError("Packaged app has the wrong minimum macOS version") + if not (packaged_app / "Contents" / "Helpers" / "atlas").is_file(): + raise RuntimeError("Packaged app is missing the Atlas CLI") + if not (packaged_app / "Contents" / "Frameworks" / "runtime.dylib").is_file(): + raise RuntimeError("Packaged app is missing the Atlas runtime") + if not (resources_directory / "AtlasEngine.icns").is_file(): + raise RuntimeError("Packaged app is missing the legacy macOS icon") + if not (resources_directory / "Assets.car").is_file(): + raise RuntimeError("Packaged app is missing the modern macOS icon") + + invalid_dependencies = macho_dependencies(packaged_app) + if invalid_dependencies: + details = "\n".join( + f"{path.relative_to(packaged_app)}: {dependency}" + for path, dependency in invalid_dependencies + ) + raise RuntimeError(f"The app contains non-portable library paths:\n{details}") + + archive = dist_directory / ( + f"Atlas-Engine-alpha9-macOS-{architecture_tag}-{mode}.zip" + ) + archive_bundle(packaged_app, archive) + dmg_suffix = "" + if args.release and allow_unnotarized and not notary_profile: + dmg_suffix = "-UNNOTARIZED" + dmg = dist_directory / ( + f"Atlas-Engine-alpha9-macOS-{architecture_tag}-{mode}{dmg_suffix}.dmg" + ) + create_dmg(packaged_app, dmg, build_directory / "dmg-root") + sign_dmg(dmg, signing_identity) + if notary_profile: + if signing_identity == "-": + raise RuntimeError("ATLAS_NOTARY_PROFILE requires ATLAS_SIGNING_IDENTITY") + notarize(dmg, notary_profile) + run([ + "/usr/sbin/spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + dmg, + ]) + validate_dmg(dmg, build_directory / "dmg-mount") + signature = "ad-hoc development signature" + if notary_profile: + signature = "Developer ID signature and notarization" + elif signing_identity != "-": + signature = "Developer ID signature" + print(f"Packaged app: {packaged_app}") + print(f"Archive: {archive}") + print(f"DMG: {dmg}") + print(f"Architecture: {architectures}") + print(f"Minimum macOS: {deployment_target}") + print(f"Trust: {signature}") + + +if __name__ == "__main__": + try: + main() + except (RuntimeError, subprocess.CalledProcessError) as error: + print(f"Packaging failed: {error}", file=sys.stderr) + raise SystemExit(1)