diff --git a/Axiom/Assets/CookedAssetRuntime.cpp b/Axiom/Assets/CookedAssetRuntime.cpp index 8dc588b5..18063b53 100644 --- a/Axiom/Assets/CookedAssetRuntime.cpp +++ b/Axiom/Assets/CookedAssetRuntime.cpp @@ -7,6 +7,16 @@ namespace Axiom::Assets { +bool IsCookedOnlyContentPath(const std::filesystem::path &Path) { + const auto ContentRoot = FindContentRootForPath(Path); + if (!ContentRoot.has_value()) { + return false; + } + + const auto PackageManifestPath = ContentRoot->parent_path() / "package.wraith.json"; + return std::filesystem::exists(PackageManifestPath); +} + std::optional FindContentRootForPath(const std::filesystem::path &Path) { if (Path.empty()) { diff --git a/Axiom/Assets/CookedAssetRuntime.h b/Axiom/Assets/CookedAssetRuntime.h index 0fd229f0..3db4b397 100644 --- a/Axiom/Assets/CookedAssetRuntime.h +++ b/Axiom/Assets/CookedAssetRuntime.h @@ -12,6 +12,8 @@ namespace Axiom::Assets { std::optional FindContentRootForPath(const std::filesystem::path &Path); +bool IsCookedOnlyContentPath(const std::filesystem::path &Path); + std::optional LoadCookedMeshAssetIfAvailable(const std::filesystem::path &Path); diff --git a/Axiom/Assets/MeshAsset.cpp b/Axiom/Assets/MeshAsset.cpp index 332d4258..d152c3c5 100644 --- a/Axiom/Assets/MeshAsset.cpp +++ b/Axiom/Assets/MeshAsset.cpp @@ -534,6 +534,13 @@ std::optional LoadBasicMeshAsset(const std::filesystem::path &Pat return CookedScene; } + if (IsCookedOnlyContentPath(Path)) { + A_CORE_WARN( + "Cooked runtime: missing cooked mesh asset for '{}' and source fallback is disabled", + Path.string()); + return std::nullopt; + } + return LoadBasicMeshAssetFromSource(Path); } @@ -556,6 +563,13 @@ TextureSourceDataRef LoadTextureFromFile(const std::filesystem::path &Path) { return CookedTexture; } + if (IsCookedOnlyContentPath(Path)) { + A_CORE_WARN( + "Cooked runtime: missing cooked texture asset for '{}' and source fallback is disabled", + Path.string()); + return nullptr; + } + return LoadTextureFromSourceFile(Path); } diff --git a/Axiom/Assets/SceneFile.cpp b/Axiom/Assets/SceneFile.cpp index 03c93ff0..acdf83a2 100644 --- a/Axiom/Assets/SceneFile.cpp +++ b/Axiom/Assets/SceneFile.cpp @@ -617,6 +617,7 @@ EditorSceneItemKind KindFromStr(std::string_view S) { std::optional LoadSceneFromFile(const std::filesystem::path &Path) { const std::filesystem::path ContentRoot = ResolveContentRootForScenePath(Path); + const bool CookedOnlyContent = IsCookedOnlyContentPath(ContentRoot); std::ifstream File(Path); if (!File.is_open()) return std::nullopt; @@ -827,7 +828,9 @@ LoadSceneFromFile(const std::filesystem::path &Path) { std::unordered_set LoadedByAssetPath; for (const auto &[ObjId, Data] : Objects) { if (Data.Kind != EditorSceneItemKind::Mesh || Data.AssetRelativePath.empty()) continue; - CookMeshAsset(ContentRoot, Data.AssetRelativePath); + if (!CookedOnlyContent) { + CookMeshAsset(ContentRoot, Data.AssetRelativePath); + } const auto FullPath = ContentRoot / Data.AssetRelativePath; auto SceneData = LoadBasicMeshAsset(FullPath); if (!SceneData.has_value() || SceneData->Instances.empty()) { @@ -857,7 +860,9 @@ LoadSceneFromFile(const std::filesystem::path &Path) { } } if (!Data.TextureAssetPath.empty()) { - CookTextureAsset(ContentRoot, Data.TextureAssetPath); + if (!CookedOnlyContent) { + CookTextureAsset(ContentRoot, Data.TextureAssetPath); + } const auto TexPath = ContentRoot / Data.TextureAssetPath; auto Tex = LoadTextureFromFile(TexPath); if (Tex) { @@ -902,7 +907,9 @@ LoadSceneFromFile(const std::filesystem::path &Path) { // --- Stage 4b: reload remaining mesh instances from the global mesh asset --- if (!MeshAsset.empty() && !MeshNameToObjectId.empty()) { - CookMeshAsset(ContentRoot, MeshAsset); + if (!CookedOnlyContent) { + CookMeshAsset(ContentRoot, MeshAsset); + } const auto MeshPath = ContentRoot / MeshAsset; const auto SceneData = LoadBasicMeshAsset(MeshPath); if (SceneData.has_value()) { diff --git a/Axiom/CMakeLists.txt b/Axiom/CMakeLists.txt index 5b183273..577b1d8f 100644 --- a/Axiom/CMakeLists.txt +++ b/Axiom/CMakeLists.txt @@ -1,4 +1,5 @@ set(ENGINE_SOURCES + Project/ProjectSystem.cpp Scripting/ScriptHost.cpp Scripting/InternalCalls.cpp Assets/AssetCookManifest.cpp @@ -303,6 +304,8 @@ target_link_libraries(AxiomCore PUBLIC target_compile_definitions(AxiomCore PUBLIC VK_NO_PROTOTYPES AXIOM_CONTENT_DIR="${CMAKE_SOURCE_DIR}/Content" + AXIOM_PROJECTS_DIR="${CMAKE_SOURCE_DIR}/Projects" + AXIOM_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) if(AXIOM_ENABLE_WEBRTC) diff --git a/Axiom/Project/ProjectSystem.cpp b/Axiom/Project/ProjectSystem.cpp new file mode 100644 index 00000000..1ec3c5ea --- /dev/null +++ b/Axiom/Project/ProjectSystem.cpp @@ -0,0 +1,1107 @@ +#include "Project/ProjectSystem.h" + +#include "Assets/AssetCookManifest.h" +#include "Assets/AssetCooker.h" +#include "Core/Log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef AXIOM_PROJECTS_DIR +#define AXIOM_PROJECTS_DIR "Projects" +#endif + +#ifndef AXIOM_SOURCE_DIR +#define AXIOM_SOURCE_DIR "." +#endif + +#ifndef AXIOM_CONTENT_DIR +#define AXIOM_CONTENT_DIR "Content" +#endif + +namespace Axiom::Project { +namespace { +constexpr std::string_view kDefaultStarterScriptClassName = "StarterScript"; +constexpr std::string_view kCsProjectTypeGuid = + "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; + +std::string EscapeJsonString(std::string_view Value) { + std::string Result; + Result.reserve(Value.size() + 4); + for (const char Character : Value) { + switch (Character) { + case '\\': + Result += "\\\\"; + break; + case '"': + Result += "\\\""; + break; + case '\n': + Result += "\\n"; + break; + case '\r': + Result += "\\r"; + break; + case '\t': + Result += "\\t"; + break; + default: + Result.push_back(Character); + break; + } + } + return Result; +} + +class JsonObjectParser { +public: + explicit JsonObjectParser(std::string_view Text) : m_Text(Text) {} + + bool ParseObject(std::unordered_map &Out) { + SkipWhitespace(); + if (!Consume('{')) { + return false; + } + + SkipWhitespace(); + if (Consume('}')) { + return true; + } + + while (m_Index < m_Text.size()) { + const auto Key = ParseString(); + if (!Key.has_value()) { + return false; + } + + SkipWhitespace(); + if (!Consume(':')) { + return false; + } + + SkipWhitespace(); + std::string Value; + if (Peek() == '"') { + const auto Parsed = ParseString(); + if (!Parsed.has_value()) { + return false; + } + Value = *Parsed; + } else { + const size_t Start = m_Index; + while (m_Index < m_Text.size()) { + const char Character = m_Text[m_Index]; + if (Character == ',' || Character == '}' || + std::isspace(static_cast(Character))) { + break; + } + ++m_Index; + } + Value = std::string(m_Text.substr(Start, m_Index - Start)); + } + Out.emplace(*Key, std::move(Value)); + + SkipWhitespace(); + if (Consume('}')) { + return true; + } + if (!Consume(',')) { + return false; + } + SkipWhitespace(); + } + + return false; + } + +private: + std::optional ParseString() { + if (!Consume('"')) { + return std::nullopt; + } + + std::string Result; + while (m_Index < m_Text.size()) { + const char Character = m_Text[m_Index++]; + if (Character == '"') { + return Result; + } + if (Character == '\\') { + if (m_Index >= m_Text.size()) { + return std::nullopt; + } + const char Escaped = m_Text[m_Index++]; + switch (Escaped) { + case '\\': + case '"': + case '/': + Result.push_back(Escaped); + break; + case 'n': + Result.push_back('\n'); + break; + case 'r': + Result.push_back('\r'); + break; + case 't': + Result.push_back('\t'); + break; + default: + return std::nullopt; + } + continue; + } + Result.push_back(Character); + } + return std::nullopt; + } + + void SkipWhitespace() { + while (m_Index < m_Text.size() && + std::isspace(static_cast(m_Text[m_Index]))) { + ++m_Index; + } + } + + bool Consume(char Expected) { + if (Peek() != Expected) { + return false; + } + ++m_Index; + return true; + } + + char Peek() const { + if (m_Index >= m_Text.size()) { + return '\0'; + } + return m_Text[m_Index]; + } + + std::string_view m_Text; + size_t m_Index{0}; +}; + +std::string BuildProjectId(std::string_view Slug) { + // Stable enough for v1 scaffold creation without adding a UUID dependency. + std::hash Hasher; + const auto Value = static_cast(Hasher(Slug)); + std::ostringstream Stream; + Stream << "project-" << std::hex << Value; + return Stream.str(); +} + +bool ReadFileToString(const std::filesystem::path &Path, std::string &Out) { + std::ifstream File(Path); + if (!File.is_open()) { + return false; + } + + Out.assign(std::istreambuf_iterator(File), std::istreambuf_iterator()); + return true; +} + +std::optional ParseUint32(std::string_view Value) { + std::uint32_t Parsed = 0; + const auto *Begin = Value.data(); + const auto *End = Begin + Value.size(); + const auto Result = std::from_chars(Begin, End, Parsed); + if (Result.ec != std::errc{} || Result.ptr != End) { + return std::nullopt; + } + return Parsed; +} + +std::string BuildIdentifierToken(std::string_view Value) { + std::string Result; + Result.reserve(Value.size()); + bool Started = false; + bool CapitalizeNext = true; + + for (const char Character : Value) { + const unsigned char UnsignedCharacter = + static_cast(Character); + if (std::isalnum(UnsignedCharacter) == 0) { + CapitalizeNext = true; + continue; + } + + if (!Started) { + if (std::isdigit(UnsignedCharacter) != 0) { + Result += "Project"; + } + Result.push_back( + static_cast(std::toupper(UnsignedCharacter))); + Started = true; + CapitalizeNext = false; + continue; + } + + if (CapitalizeNext) { + Result.push_back( + static_cast(std::toupper(UnsignedCharacter))); + CapitalizeNext = false; + } else { + Result.push_back( + static_cast(std::tolower(UnsignedCharacter))); + } + } + + if (Result.empty()) { + return "ProjectScripts"; + } + + return Result; +} + +std::string BuildStableGuid(std::string_view Seed) { + const auto FirstHash = + static_cast(std::hash{}(Seed)); + const auto SecondHash = static_cast( + std::hash{}(std::string(Seed) + "::wraith")); + + std::array Bytes{}; + for (size_t Index = 0; Index < 8; ++Index) { + Bytes[Index] = + static_cast((FirstHash >> ((7 - Index) * 8)) & 0xffu); + Bytes[Index + 8] = + static_cast((SecondHash >> ((7 - Index) * 8)) & 0xffu); + } + + Bytes[6] = static_cast((Bytes[6] & 0x0fu) | 0x40u); + Bytes[8] = static_cast((Bytes[8] & 0x3fu) | 0x80u); + + std::ostringstream Stream; + Stream << std::uppercase << std::hex << std::setfill('0') + << "{" + << std::setw(2) << static_cast(Bytes[0]) + << std::setw(2) << static_cast(Bytes[1]) + << std::setw(2) << static_cast(Bytes[2]) + << std::setw(2) << static_cast(Bytes[3]) + << "-" + << std::setw(2) << static_cast(Bytes[4]) + << std::setw(2) << static_cast(Bytes[5]) + << "-" + << std::setw(2) << static_cast(Bytes[6]) + << std::setw(2) << static_cast(Bytes[7]) + << "-" + << std::setw(2) << static_cast(Bytes[8]) + << std::setw(2) << static_cast(Bytes[9]) + << "-" + << std::setw(2) << static_cast(Bytes[10]) + << std::setw(2) << static_cast(Bytes[11]) + << std::setw(2) << static_cast(Bytes[12]) + << std::setw(2) << static_cast(Bytes[13]) + << std::setw(2) << static_cast(Bytes[14]) + << std::setw(2) << static_cast(Bytes[15]) + << "}"; + return Stream.str(); +} + +bool WriteTextFile(const std::filesystem::path &Path, + std::string_view Contents) { + std::error_code Error; + std::filesystem::create_directories(Path.parent_path(), Error); + if (Error) { + A_CORE_ERROR("ProjectSystem: failed to create parent directory '{}'", + Path.parent_path().string()); + return false; + } + + std::ofstream File(Path); + if (!File.is_open()) { + A_CORE_ERROR("ProjectSystem: failed to open file '{}'", Path.string()); + return false; + } + + File << Contents; + return File.good(); +} + +bool SaveDefaultScriptProject( + const std::filesystem::path &ProjectPath, + const ProjectScriptWorkspace &ScriptWorkspace) { + const auto EngineManagedPath = + std::filesystem::path(AXIOM_SOURCE_DIR) / "Scripting" / + "WraithEngine.Managed" / "bin" / "Debug" / "WraithEngine.Managed.dll"; + + std::ostringstream Stream; + Stream << "\n" + << " \n" + << " Library\n" + << " " << ScriptWorkspace.AssemblyName + << "\n" + << " " << ScriptWorkspace.RootNamespace + << "\n" + << " net9.0\n" + << " enable\n" + << " enable\n" + << " false\n" + << " \n\n" + << " \n" + << " \n" + << " " << EngineManagedPath.string() + << "\n" + << " false\n" + << " \n" + << " \n" + << "\n"; + return WriteTextFile(ProjectPath, Stream.str()); +} + +bool SaveDefaultScriptSolution( + const std::filesystem::path &SolutionPath, + const ProjectScriptWorkspace &ScriptWorkspace) { + const auto ProjectGuid = + BuildStableGuid(ScriptWorkspace.AssemblyName + "::scripts-project"); + + std::ostringstream Stream; + Stream << "Microsoft Visual Studio Solution File, Format Version 12.00\n" + << "# Visual Studio Version 17\n" + << "VisualStudioVersion = 17.0.31903.59\n" + << "MinimumVisualStudioVersion = 10.0.40219.1\n" + << "Project(\"" << kCsProjectTypeGuid << "\") = \"" + << ScriptWorkspace.AssemblyName << "\", \"Scripts/" + << ScriptWorkspace.ScriptProjectPath.filename().string() << "\", \"" + << ProjectGuid << "\"\n" + << "EndProject\n" + << "Global\n" + << "\tGlobalSection(SolutionConfigurationPlatforms) = preSolution\n" + << "\t\tDebug|Any CPU = Debug|Any CPU\n" + << "\t\tRelease|Any CPU = Release|Any CPU\n" + << "\tEndGlobalSection\n" + << "\tGlobalSection(ProjectConfigurationPlatforms) = postSolution\n" + << "\t\t" << ProjectGuid + << ".Debug|Any CPU.ActiveCfg = Debug|Any CPU\n" + << "\t\t" << ProjectGuid + << ".Debug|Any CPU.Build.0 = Debug|Any CPU\n" + << "\t\t" << ProjectGuid + << ".Release|Any CPU.ActiveCfg = Release|Any CPU\n" + << "\t\t" << ProjectGuid + << ".Release|Any CPU.Build.0 = Release|Any CPU\n" + << "\tEndGlobalSection\n" + << "\tGlobalSection(SolutionProperties) = preSolution\n" + << "\t\tHideSolutionNode = FALSE\n" + << "\tEndGlobalSection\n" + << "EndGlobal\n"; + return WriteTextFile(SolutionPath, Stream.str()); +} + +bool SaveDefaultStarterScript( + const std::filesystem::path &ScriptPath, + const ProjectScriptWorkspace &ScriptWorkspace) { + std::ostringstream Stream; + Stream << "using WraithEngine;\n\n" + << "namespace " << ScriptWorkspace.RootNamespace << ";\n\n" + << "public class " << ScriptWorkspace.StarterScriptClassName + << " : Script\n" + << "{\n" + << " public override void OnCreate()\n" + << " {\n" + << " }\n\n" + << " public override void OnTick(float dt)\n" + << " {\n" + << " }\n" + << "}\n"; + return WriteTextFile(ScriptPath, Stream.str()); +} + +bool EnsureOutputLayoutScaffold(const ProjectOutputLayout &Output) { + std::error_code Error; + std::filesystem::create_directories(Output.CookedDir, Error); + if (Error) { + return false; + } + Error.clear(); + std::filesystem::create_directories(Output.BuildDir, Error); + if (Error) { + return false; + } + Error.clear(); + std::filesystem::create_directories(Output.PackageDir, Error); + return !Error; +} + +bool EnsureScriptWorkspaceScaffold(ProjectDescriptor &Descriptor) { + bool ManifestChanged = false; + if (Descriptor.Manifest.Version < 2) { + Descriptor.Manifest.Version = 2; + ManifestChanged = true; + } + if (Descriptor.Manifest.ScriptAssemblyName.empty()) { + Descriptor.Manifest.ScriptAssemblyName = + BuildScriptAssemblyName(Descriptor.Manifest.Name); + ManifestChanged = true; + } + if (Descriptor.Manifest.ScriptRootNamespace.empty()) { + Descriptor.Manifest.ScriptRootNamespace = + BuildScriptRootNamespace(Descriptor.Manifest.Name); + ManifestChanged = true; + } + + Descriptor.ScriptWorkspace = + ResolveProjectScriptWorkspace(Descriptor.Root, Descriptor.Manifest); + Descriptor.Output = ResolveProjectOutputLayout(Descriptor.Root); + + if (ManifestChanged && + !SaveProjectManifest(Descriptor.Root.ManifestPath, Descriptor.Manifest)) { + return false; + } + + if (!std::filesystem::exists(Descriptor.ScriptWorkspace.ScriptProjectPath) && + !SaveDefaultScriptProject(Descriptor.ScriptWorkspace.ScriptProjectPath, + Descriptor.ScriptWorkspace)) { + return false; + } + if (!std::filesystem::exists(Descriptor.ScriptWorkspace.ScriptSolutionPath) && + !SaveDefaultScriptSolution(Descriptor.ScriptWorkspace.ScriptSolutionPath, + Descriptor.ScriptWorkspace)) { + return false; + } + if (!std::filesystem::exists(Descriptor.ScriptWorkspace.StarterScriptPath) && + !SaveDefaultStarterScript(Descriptor.ScriptWorkspace.StarterScriptPath, + Descriptor.ScriptWorkspace)) { + return false; + } + if (!EnsureOutputLayoutScaffold(Descriptor.Output)) { + return false; + } + + return true; +} + +bool IsCookableContentPath(const std::filesystem::path &RelativePath) { + const std::string Extension = RelativePath.extension().string(); + return Extension == ".glb" || Extension == ".gltf" || Extension == ".fbx" || + Extension == ".obj" || Extension == ".png" || Extension == ".jpg" || + Extension == ".jpeg"; +} + +std::vector +CollectCookableAssets(const std::filesystem::path &ContentDir) { + std::vector Results; + if (!std::filesystem::exists(ContentDir)) { + return Results; + } + + for (const auto &Entry : + std::filesystem::recursive_directory_iterator(ContentDir)) { + if (!Entry.is_regular_file()) { + continue; + } + const auto RelativePath = + std::filesystem::relative(Entry.path(), ContentDir).lexically_normal(); + if (!IsCookableContentPath(RelativePath)) { + continue; + } + Results.push_back(RelativePath); + } + + std::sort(Results.begin(), Results.end()); + return Results; +} + +std::size_t CountPackagedFiles(const std::filesystem::path &RootPath) { + if (!std::filesystem::exists(RootPath)) { + return 0; + } + + std::size_t Count = 0; + for (const auto &Entry : + std::filesystem::recursive_directory_iterator(RootPath)) { + if (Entry.is_regular_file()) { + ++Count; + } + } + return Count; +} + +bool CopyDirectoryTree(const std::filesystem::path &Source, + const std::filesystem::path &Destination) { + if (!std::filesystem::exists(Source)) { + return true; + } + + std::error_code Error; + std::filesystem::create_directories(Destination, Error); + if (Error) { + return false; + } + + for (const auto &Entry : std::filesystem::recursive_directory_iterator(Source)) { + const auto Relative = + std::filesystem::relative(Entry.path(), Source).lexically_normal(); + const auto TargetPath = Destination / Relative; + if (Entry.is_directory()) { + std::filesystem::create_directories(TargetPath, Error); + if (Error) { + return false; + } + continue; + } + if (!Entry.is_regular_file()) { + continue; + } + std::filesystem::create_directories(TargetPath.parent_path(), Error); + if (Error) { + return false; + } + std::filesystem::copy_file(Entry.path(), TargetPath, + std::filesystem::copy_options::overwrite_existing, + Error); + if (Error) { + return false; + } + } + + return true; +} + +bool SavePackageManifestFile(const ProjectDescriptor &Project, + const ProjectPackageResult &PackageResult) { + std::ostringstream Stream; + Stream << "{\n" + << " \"version\": 1,\n" + << " \"projectId\": \"" << EscapeJsonString(Project.Manifest.ProjectId) + << "\",\n" + << " \"name\": \"" << EscapeJsonString(Project.Manifest.Name) << "\",\n" + << " \"slug\": \"" << EscapeJsonString(Project.Manifest.Slug) << "\",\n" + << " \"contentMode\": \"transitional-scene-plus-cooked-assets\",\n" + << " \"sceneFile\": \"Content/scene.json\",\n" + << " \"cookedDir\": \"Content/Cooked\",\n" + << " \"assetCookManifest\": \"Content/Cooked/AssetCookManifest.json\",\n" + << " \"engineContentDir\": \"Content/Engine\",\n" + << " \"cookedSourceAssetCount\": " + << PackageResult.Cook.CookedSourceAssetCount << ",\n" + << " \"manifestEntryCount\": " << PackageResult.Cook.ManifestEntryCount + << "\n" + << "}\n"; + return WriteTextFile(Project.Output.PackageManifestPath, Stream.str()); +} + +} // namespace + +std::filesystem::path GetDefaultProjectsRoot() { + return std::filesystem::path(AXIOM_PROJECTS_DIR); +} + +ProjectRoot ResolveProjectRoot(const std::filesystem::path &RootPath) { + const auto AbsoluteRoot = std::filesystem::absolute(RootPath).lexically_normal(); + return { + .RootPath = AbsoluteRoot, + .ManifestPath = AbsoluteRoot / "project.wraith.json", + .ContentDir = AbsoluteRoot / "Content", + .SceneFilePath = AbsoluteRoot / "Content" / "scene.json", + }; +} + +ProjectScriptWorkspace ResolveProjectScriptWorkspace(const ProjectRoot &Root, + const ProjectManifest &Manifest) { + const std::string AssemblyName = + Manifest.ScriptAssemblyName.empty() + ? BuildScriptAssemblyName(Manifest.Name) + : Manifest.ScriptAssemblyName; + const std::string RootNamespace = + Manifest.ScriptRootNamespace.empty() + ? BuildScriptRootNamespace(Manifest.Name) + : Manifest.ScriptRootNamespace; + const std::string StarterScriptClassName = + std::string(kDefaultStarterScriptClassName); + + return { + .ScriptsDir = Root.RootPath / "Scripts", + .ScriptProjectPath = Root.RootPath / "Scripts" / (AssemblyName + ".csproj"), + .ScriptSolutionPath = Root.RootPath / (AssemblyName + ".sln"), + .StarterScriptPath = + Root.RootPath / "Scripts" / (StarterScriptClassName + ".cs"), + .AssemblyName = AssemblyName, + .RootNamespace = RootNamespace, + .StarterScriptClassName = StarterScriptClassName, + .StarterScriptQualifiedClassName = + RootNamespace + "." + StarterScriptClassName, + }; +} + +ProjectOutputLayout ResolveProjectOutputLayout(const ProjectRoot &Root) { + return { + .CookedDir = Root.ContentDir / "Cooked", + .CookManifestPath = Root.ContentDir / "Cooked" / "AssetCookManifest.json", + .BuildDir = Root.RootPath / "Build", + .PackageDir = Root.RootPath / "Package", + .PackagedContentDir = Root.RootPath / "Package" / "Content", + .PackagedCookedDir = Root.RootPath / "Package" / "Content" / "Cooked", + .PackagedCookManifestPath = + Root.RootPath / "Package" / "Content" / "Cooked" / + "AssetCookManifest.json", + .PackagedSceneFilePath = Root.RootPath / "Package" / "Content" / "scene.json", + .PackagedEngineContentDir = + Root.RootPath / "Package" / "Content" / "Engine", + .PackageManifestPath = Root.RootPath / "Package" / "package.wraith.json", + }; +} + +bool IsPathWithinRoot(const std::filesystem::path &RootPath, + const std::filesystem::path &CandidatePath) { + std::error_code Error; + const auto CanonicalRoot = + std::filesystem::weakly_canonical(RootPath, Error).lexically_normal(); + if (Error) { + return false; + } + + Error.clear(); + const auto CanonicalCandidate = + std::filesystem::weakly_canonical(CandidatePath, Error).lexically_normal(); + if (Error) { + return false; + } + + auto RootIt = CanonicalRoot.begin(); + auto CandidateIt = CanonicalCandidate.begin(); + for (; RootIt != CanonicalRoot.end() && CandidateIt != CanonicalCandidate.end(); + ++RootIt, ++CandidateIt) { + if (*RootIt != *CandidateIt) { + return false; + } + } + + return RootIt == CanonicalRoot.end(); +} + +bool IsValidProjectSlug(std::string_view Slug) { + if (Slug.empty()) { + return false; + } + if (Slug.front() == '-' || Slug.back() == '-') { + return false; + } + + for (const char Character : Slug) { + if ((Character >= 'a' && Character <= 'z') || + (Character >= '0' && Character <= '9') || Character == '-') { + continue; + } + return false; + } + return true; +} + +std::string SlugifyProjectName(std::string_view Name) { + std::string Slug; + Slug.reserve(Name.size()); + bool PreviousWasDash = false; + + for (const char Character : Name) { + const unsigned char UnsignedCharacter = + static_cast(Character); + if (std::isalnum(UnsignedCharacter)) { + Slug.push_back( + static_cast(std::tolower(UnsignedCharacter))); + PreviousWasDash = false; + continue; + } + + if (!Slug.empty() && !PreviousWasDash) { + Slug.push_back('-'); + PreviousWasDash = true; + } + } + + while (!Slug.empty() && Slug.back() == '-') { + Slug.pop_back(); + } + + return Slug; +} + +std::string BuildScriptAssemblyName(std::string_view ProjectName) { + return BuildIdentifierToken(ProjectName) + ".Scripts"; +} + +std::string BuildScriptRootNamespace(std::string_view ProjectName) { + return BuildIdentifierToken(ProjectName) + ".Scripts"; +} + +bool SaveProjectManifest(const std::filesystem::path &ManifestPath, + const ProjectManifest &Manifest) { + std::error_code Error; + std::filesystem::create_directories(ManifestPath.parent_path(), Error); + if (Error) { + A_CORE_ERROR("ProjectSystem: failed to create manifest directory '{}'", + ManifestPath.parent_path().string()); + return false; + } + + std::ofstream File(ManifestPath); + if (!File.is_open()) { + A_CORE_ERROR("ProjectSystem: failed to open manifest '{}'", + ManifestPath.string()); + return false; + } + + File << "{\n" + << " \"version\": " << Manifest.Version << ",\n" + << " \"projectId\": \"" << EscapeJsonString(Manifest.ProjectId) << "\",\n" + << " \"name\": \"" << EscapeJsonString(Manifest.Name) << "\",\n" + << " \"slug\": \"" << EscapeJsonString(Manifest.Slug) << "\",\n" + << " \"scriptAssemblyName\": \"" + << EscapeJsonString(Manifest.ScriptAssemblyName) << "\",\n" + << " \"scriptRootNamespace\": \"" + << EscapeJsonString(Manifest.ScriptRootNamespace) << "\"\n" + << "}\n"; + return File.good(); +} + +std::optional +LoadProjectManifest(const std::filesystem::path &ManifestPath) { + std::string Text; + if (!ReadFileToString(ManifestPath, Text)) { + return std::nullopt; + } + + JsonObjectParser Parser(Text); + std::unordered_map Fields; + if (!Parser.ParseObject(Fields)) { + A_CORE_WARN("ProjectSystem: failed to parse manifest '{}'", + ManifestPath.string()); + return std::nullopt; + } + + const auto VersionIt = Fields.find("version"); + const auto ProjectIdIt = Fields.find("projectId"); + const auto NameIt = Fields.find("name"); + const auto SlugIt = Fields.find("slug"); + if (VersionIt == Fields.end() || ProjectIdIt == Fields.end() || + NameIt == Fields.end() || SlugIt == Fields.end()) { + return std::nullopt; + } + + const auto Version = ParseUint32(VersionIt->second); + if (!Version.has_value() || !IsValidProjectSlug(SlugIt->second)) { + return std::nullopt; + } + + return ProjectManifest{ + .Version = *Version, + .ProjectId = ProjectIdIt->second, + .Name = NameIt->second, + .Slug = SlugIt->second, + .ScriptAssemblyName = [&Fields, &NameIt]() { + const auto ScriptAssemblyIt = Fields.find("scriptAssemblyName"); + return ScriptAssemblyIt != Fields.end() + ? ScriptAssemblyIt->second + : BuildScriptAssemblyName(NameIt->second); + }(), + .ScriptRootNamespace = [&Fields, &NameIt]() { + const auto ScriptNamespaceIt = Fields.find("scriptRootNamespace"); + return ScriptNamespaceIt != Fields.end() + ? ScriptNamespaceIt->second + : BuildScriptRootNamespace(NameIt->second); + }(), + }; +} + +bool SaveDefaultSceneFile(const std::filesystem::path &SceneFilePath) { + std::error_code Error; + std::filesystem::create_directories(SceneFilePath.parent_path(), Error); + if (Error) { + A_CORE_ERROR("ProjectSystem: failed to create scene directory '{}'", + SceneFilePath.parent_path().string()); + return false; + } + + std::ofstream File(SceneFilePath); + if (!File.is_open()) { + A_CORE_ERROR("ProjectSystem: failed to open scene file '{}'", + SceneFilePath.string()); + return false; + } + + File << "{\n" + << " \"version\": 1,\n" + << " \"meshAsset\": \"\",\n" + << " \"nodes\": [\n" + << " {\n" + << " \"id\": \"world\",\n" + << " \"parentId\": null,\n" + << " \"displayName\": \"World\",\n" + << " \"kind\": \"Folder\",\n" + << " \"visible\": true\n" + << " }\n" + << " ],\n" + << " \"objects\": [\n" + << " {\n" + << " \"id\": \"world\",\n" + << " \"displayName\": \"World\",\n" + << " \"kind\": \"Folder\",\n" + << " \"visible\": true,\n" + << " \"supportsTransform\": false,\n" + << " \"transformReadOnly\": true\n" + << " }\n" + << " ]\n" + << "}\n"; + return File.good(); +} + +std::optional +LoadProjectDescriptor(const std::filesystem::path &RootPath) { + const ProjectRoot Root = ResolveProjectRoot(RootPath); + const auto Manifest = LoadProjectManifest(Root.ManifestPath); + if (!Manifest.has_value()) { + return std::nullopt; + } + + return ProjectDescriptor{ + .Manifest = *Manifest, + .Root = Root, + .ScriptWorkspace = ResolveProjectScriptWorkspace(Root, *Manifest), + .Output = ResolveProjectOutputLayout(Root), + }; +} + +std::vector +DiscoverProjects(const std::filesystem::path &ProjectsRoot) { + std::vector Results; + if (!std::filesystem::exists(ProjectsRoot)) { + return Results; + } + + for (const auto &Entry : std::filesystem::directory_iterator(ProjectsRoot)) { + if (!Entry.is_directory()) { + continue; + } + if (const auto Descriptor = LoadProjectDescriptor(Entry.path()); + Descriptor.has_value()) { + Results.push_back(*Descriptor); + } + } + + std::sort(Results.begin(), Results.end(), + [](const ProjectDescriptor &Left, const ProjectDescriptor &Right) { + return Left.Manifest.Name < Right.Manifest.Name; + }); + return Results; +} + +std::optional +OpenProjectBySlug(const std::filesystem::path &ProjectsRoot, + std::string_view ProjectSlug) { + if (!IsValidProjectSlug(ProjectSlug)) { + return std::nullopt; + } + + const auto Root = ResolveProjectRoot(ProjectsRoot / std::string(ProjectSlug)); + if (!IsPathWithinRoot(ProjectsRoot, Root.RootPath)) { + return std::nullopt; + } + + const auto Descriptor = LoadProjectDescriptor(Root.RootPath); + if (!Descriptor.has_value()) { + return std::nullopt; + } + auto Result = *Descriptor; + if (Result.Manifest.Slug != ProjectSlug) { + return std::nullopt; + } + if (!EnsureScriptWorkspaceScaffold(Result)) { + return std::nullopt; + } + return Result; +} + +std::optional +CreateProjectScaffold(const std::filesystem::path &ProjectsRoot, + std::string_view ProjectName, + std::string *FailureReason) { + const std::string Slug = SlugifyProjectName(ProjectName); + if (!IsValidProjectSlug(Slug)) { + if (FailureReason != nullptr) { + *FailureReason = "Project name must contain at least one letter or number."; + } + return std::nullopt; + } + + std::error_code Error; + std::filesystem::create_directories(ProjectsRoot, Error); + if (Error) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to create projects root directory."; + } + return std::nullopt; + } + + const ProjectRoot Root = ResolveProjectRoot(ProjectsRoot / Slug); + if (std::filesystem::exists(Root.RootPath)) { + if (FailureReason != nullptr) { + *FailureReason = "A project with that slug already exists."; + } + return std::nullopt; + } + + if (!IsPathWithinRoot(ProjectsRoot, Root.RootPath.parent_path()) && + Root.RootPath.parent_path() != std::filesystem::absolute(ProjectsRoot).lexically_normal()) { + if (FailureReason != nullptr) { + *FailureReason = "Project root must remain inside the managed projects directory."; + } + return std::nullopt; + } + + std::filesystem::create_directories(Root.ContentDir, Error); + if (Error) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to create project content directory."; + } + return std::nullopt; + } + + const ProjectManifest Manifest{ + .Version = 2, + .ProjectId = BuildProjectId(Slug), + .Name = std::string(ProjectName), + .Slug = Slug, + .ScriptAssemblyName = BuildScriptAssemblyName(ProjectName), + .ScriptRootNamespace = BuildScriptRootNamespace(ProjectName), + }; + const auto ScriptWorkspace = ResolveProjectScriptWorkspace(Root, Manifest); + const auto Output = ResolveProjectOutputLayout(Root); + if (!SaveProjectManifest(Root.ManifestPath, Manifest) || + !SaveDefaultSceneFile(Root.SceneFilePath) || + !SaveDefaultScriptProject(ScriptWorkspace.ScriptProjectPath, + ScriptWorkspace) || + !SaveDefaultScriptSolution(ScriptWorkspace.ScriptSolutionPath, + ScriptWorkspace) || + !SaveDefaultStarterScript(ScriptWorkspace.StarterScriptPath, + ScriptWorkspace) || + !EnsureOutputLayoutScaffold(Output)) { + std::filesystem::remove_all(Root.RootPath, Error); + if (FailureReason != nullptr) { + *FailureReason = "Failed to write the initial project scaffold."; + } + return std::nullopt; + } + + return ProjectDescriptor{ + .Manifest = Manifest, + .Root = Root, + .ScriptWorkspace = ScriptWorkspace, + .Output = Output, + }; +} + +std::optional +CookProjectContent(const ProjectDescriptor &Project, std::string *FailureReason) { + if (!EnsureOutputLayoutScaffold(Project.Output)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to create the project's output directories."; + } + return std::nullopt; + } + + const auto AssetsToCook = CollectCookableAssets(Project.Root.ContentDir); + for (const auto &RelativeAssetPath : AssetsToCook) { + const auto Extension = RelativeAssetPath.extension().string(); + if (Extension == ".glb" || Extension == ".gltf" || Extension == ".fbx" || + Extension == ".obj") { + if (!Assets::CookMeshAsset(Project.Root.ContentDir, RelativeAssetPath) + .has_value()) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to cook mesh asset '" + + RelativeAssetPath.generic_string() + "'."; + } + return std::nullopt; + } + continue; + } + + if (!Assets::CookTextureAsset(Project.Root.ContentDir, RelativeAssetPath) + .has_value()) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to cook texture asset '" + + RelativeAssetPath.generic_string() + "'."; + } + return std::nullopt; + } + } + + const auto Manifest = + Assets::LoadAssetCookManifest(Project.Output.CookManifestPath) + .value_or(Assets::AssetCookManifest{}); + + return ProjectCookResult{ + .Output = Project.Output, + .CookedSourceAssetCount = AssetsToCook.size(), + .ManifestEntryCount = Manifest.Entries.size(), + }; +} + +std::optional +PackageProjectContent(const ProjectDescriptor &Project, + std::string *FailureReason) { + const auto CookResult = CookProjectContent(Project, FailureReason); + if (!CookResult.has_value()) { + return std::nullopt; + } + + std::error_code Error; + std::filesystem::remove_all(Project.Output.PackageDir, Error); + Error.clear(); + std::filesystem::create_directories(Project.Output.PackagedContentDir, Error); + if (Error) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to create the package output directory."; + } + return std::nullopt; + } + + if (!CopyDirectoryTree(Project.Output.CookedDir, Project.Output.PackagedCookedDir)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to copy cooked assets into the package output."; + } + return std::nullopt; + } + + if (std::filesystem::exists(Project.Root.SceneFilePath)) { + std::filesystem::copy_file( + Project.Root.SceneFilePath, Project.Output.PackagedSceneFilePath, + std::filesystem::copy_options::overwrite_existing, Error); + if (Error) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to copy the project scene into the package."; + } + return std::nullopt; + } + } + + const auto EngineContentDir = + std::filesystem::path(AXIOM_CONTENT_DIR) / "Engine"; + if (!CopyDirectoryTree(EngineContentDir, Project.Output.PackagedEngineContentDir)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to copy shared engine content into the package."; + } + return std::nullopt; + } + + ProjectPackageResult Result{ + .Cook = *CookResult, + .PackagedFileCount = CountPackagedFiles(Project.Output.PackageDir), + .IncludedSceneFile = std::filesystem::exists(Project.Output.PackagedSceneFilePath), + .IncludedEngineContent = + std::filesystem::exists(Project.Output.PackagedEngineContentDir), + }; + if (!SavePackageManifestFile(Project, Result)) { + if (FailureReason != nullptr) { + *FailureReason = "Failed to write the package manifest."; + } + return std::nullopt; + } + Result.PackagedFileCount = CountPackagedFiles(Project.Output.PackageDir); + return Result; +} + +} // namespace Axiom::Project diff --git a/Axiom/Project/ProjectSystem.h b/Axiom/Project/ProjectSystem.h new file mode 100644 index 00000000..de6d640c --- /dev/null +++ b/Axiom/Project/ProjectSystem.h @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace Axiom::Project { + +struct ProjectManifest { + std::uint32_t Version{2}; + std::string ProjectId; + std::string Name; + std::string Slug; + std::string ScriptAssemblyName; + std::string ScriptRootNamespace; +}; + +struct ProjectRoot { + std::filesystem::path RootPath; + std::filesystem::path ManifestPath; + std::filesystem::path ContentDir; + std::filesystem::path SceneFilePath; +}; + +struct ProjectScriptWorkspace { + std::filesystem::path ScriptsDir; + std::filesystem::path ScriptProjectPath; + std::filesystem::path ScriptSolutionPath; + std::filesystem::path StarterScriptPath; + std::string AssemblyName; + std::string RootNamespace; + std::string StarterScriptClassName; + std::string StarterScriptQualifiedClassName; +}; + +struct ProjectOutputLayout { + std::filesystem::path CookedDir; + std::filesystem::path CookManifestPath; + std::filesystem::path BuildDir; + std::filesystem::path PackageDir; + std::filesystem::path PackagedContentDir; + std::filesystem::path PackagedCookedDir; + std::filesystem::path PackagedCookManifestPath; + std::filesystem::path PackagedSceneFilePath; + std::filesystem::path PackagedEngineContentDir; + std::filesystem::path PackageManifestPath; +}; + +struct ProjectCookResult { + ProjectOutputLayout Output; + std::size_t CookedSourceAssetCount{0}; + std::size_t ManifestEntryCount{0}; +}; + +struct ProjectPackageResult { + ProjectCookResult Cook; + std::size_t PackagedFileCount{0}; + bool IncludedSceneFile{false}; + bool IncludedEngineContent{false}; +}; + +struct ProjectDescriptor { + ProjectManifest Manifest; + ProjectRoot Root; + ProjectScriptWorkspace ScriptWorkspace; + ProjectOutputLayout Output; +}; + +std::filesystem::path GetDefaultProjectsRoot(); +ProjectRoot ResolveProjectRoot(const std::filesystem::path &RootPath); +ProjectScriptWorkspace ResolveProjectScriptWorkspace(const ProjectRoot &Root, + const ProjectManifest &Manifest); +ProjectOutputLayout ResolveProjectOutputLayout(const ProjectRoot &Root); + +bool IsPathWithinRoot(const std::filesystem::path &RootPath, + const std::filesystem::path &CandidatePath); +bool IsValidProjectSlug(std::string_view Slug); +std::string SlugifyProjectName(std::string_view Name); +std::string BuildScriptAssemblyName(std::string_view ProjectName); +std::string BuildScriptRootNamespace(std::string_view ProjectName); + +bool SaveProjectManifest(const std::filesystem::path &ManifestPath, + const ProjectManifest &Manifest); +std::optional +LoadProjectManifest(const std::filesystem::path &ManifestPath); + +bool SaveDefaultSceneFile(const std::filesystem::path &SceneFilePath); + +std::optional +LoadProjectDescriptor(const std::filesystem::path &RootPath); +std::vector +DiscoverProjects(const std::filesystem::path &ProjectsRoot); +std::optional +OpenProjectBySlug(const std::filesystem::path &ProjectsRoot, + std::string_view ProjectSlug); + +std::optional +CreateProjectScaffold(const std::filesystem::path &ProjectsRoot, + std::string_view ProjectName, + std::string *FailureReason = nullptr); + +std::optional +CookProjectContent(const ProjectDescriptor &Project, + std::string *FailureReason = nullptr); + +std::optional +PackageProjectContent(const ProjectDescriptor &Project, + std::string *FailureReason = nullptr); + +} // namespace Axiom::Project diff --git a/Axiom/Session/EditorSession.cpp b/Axiom/Session/EditorSession.cpp index e2560eb5..304c9541 100644 --- a/Axiom/Session/EditorSession.cpp +++ b/Axiom/Session/EditorSession.cpp @@ -438,6 +438,40 @@ Instance *EditorSession::FindWorldFolder() const { return nullptr; } +Instance *EditorSession::EnsureWorldFolder() { + if (!m_SceneRoot) { + InitSceneRoot(); + } + + auto EnsureWorldDetails = [this]() { + if (m_State.Scene.ObjectDetailsById.find("world") != + m_State.Scene.ObjectDetailsById.end()) { + return; + } + m_State.Scene.ObjectDetailsById.emplace( + "world", EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }); + }; + + if (Instance *World = FindWorldFolder(); World != nullptr) { + EnsureWorldDetails(); + return World; + } + + EnsureWorldDetails(); + + Instance *World = Instance::Create("world"); + World->SetParent(m_SceneRoot.get()); + SyncItemsFromTree(); + return World; +} + void EditorSession::RebuildInstanceTree(const std::vector &Items, Instance *Parent) { if (!Parent) return; @@ -1138,10 +1172,6 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, FailureReason = "Unknown TemplateId: " + CreateCmd->TemplateId + "."; return false; } - if (FindWorldFolder() == nullptr) { - FailureReason = "No world folder found in scene root."; - return false; - } } if (const auto *CreateMeshCmd = @@ -1150,10 +1180,6 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, FailureReason = "CreateMeshObject requires a non-empty asset path."; return false; } - if (FindWorldFolder() == nullptr) { - FailureReason = "No world folder found in scene root."; - return false; - } if (CreateMeshCmd->Scale.x <= 0.0f || CreateMeshCmd->Scale.y <= 0.0f || CreateMeshCmd->Scale.z <= 0.0f) { FailureReason = "Scale values must be greater than zero."; @@ -1288,8 +1314,9 @@ bool EditorSession::ValidateCommand(const QueuedEditorCommand &QueuedCommand, FailureReason = "SetMeshAsset targeted an unknown object."; return false; } - if (Details->Kind != EditorSceneItemKind::Mesh) { - FailureReason = "SetMeshAsset target must be a Mesh object."; + if (Details->Kind != EditorSceneItemKind::Mesh && + Details->Kind != EditorSceneItemKind::Actor) { + FailureReason = "SetMeshAsset target must be a Mesh or Actor object."; return false; } } @@ -1473,6 +1500,10 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, const CreateObjectCommand &Command) { EnsurePresence(QueuedCommand.Context.User); + Instance *WorldFolder = EnsureWorldFolder(); + if (WorldFolder == nullptr) { + return; + } const EditorSceneItemKind Kind = KindForTemplateId(Command.TemplateId); const std::string ObjectId = BuildUniqueObjectId(Command.TemplateId); const std::string DisplayName = BuildUniqueDisplayName(Command.TemplateId); @@ -1494,7 +1525,7 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, }); if (Instance *Node = CreateInstanceForTemplate(Command.TemplateId, ObjectId)) - Node->SetParent(FindWorldFolder()); + Node->SetParent(WorldFolder); SyncItemsFromTree(); PublishEvent({.Payload = ObjectCreatedEvent{ @@ -1507,6 +1538,10 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, const CreateMeshObjectCommand &Command) { EnsurePresence(QueuedCommand.Context.User); + Instance *WorldFolder = EnsureWorldFolder(); + if (WorldFolder == nullptr) { + return; + } const std::string ObjectId = BuildUniqueObjectId("Mesh"); const std::string DisplayName = BuildUniqueDisplayName("Mesh"); @@ -1530,7 +1565,7 @@ void EditorSession::HandleCommand(const QueuedEditorCommand &QueuedCommand, }); if (Instance *Node = CreateInstanceForTemplate("Mesh", ObjectId)) { - Node->SetParent(FindWorldFolder()); + Node->SetParent(WorldFolder); } SyncItemsFromTree(); diff --git a/Axiom/Session/EditorSession.h b/Axiom/Session/EditorSession.h index cd5026ac..1c44eb86 100644 --- a/Axiom/Session/EditorSession.h +++ b/Axiom/Session/EditorSession.h @@ -76,7 +76,7 @@ struct EditorObjectDetails { bool TransformReadOnly{true}; std::optional Transform; // local-space std::optional WorldTransform; // world-space (computed) - std::optional ScriptClass; // C# script class name (Actors only) + std::optional ScriptClass; // C# script class name (Actor objects only) std::optional Light; // Light objects only std::optional Material; // Mesh objects only std::optional GeneratedFromAssetRootId; @@ -157,6 +157,7 @@ class EditorSession final : public IEditorCommandSink { // Must be called before SetMeshAssetCommand can be processed. void SetContentDir(std::filesystem::path ContentDir); + const std::filesystem::path &GetContentDir() const { return m_ContentDir; } void EnsureViewportState(SessionUserId User); void SetPresenceState(SessionUserId User, EditorUserPresenceState State); @@ -215,6 +216,7 @@ class EditorSession final : public IEditorCommandSink { // Instance tree management void InitSceneRoot(); Instance *FindWorldFolder() const; + Instance *EnsureWorldFolder(); void RebuildInstanceTree(const std::vector &Items, Instance *Parent); void SyncItemsFromTree(); diff --git a/Axiom/Session/StartupScene.cpp b/Axiom/Session/StartupScene.cpp index 0792bc06..2db604c4 100644 --- a/Axiom/Session/StartupScene.cpp +++ b/Axiom/Session/StartupScene.cpp @@ -1,6 +1,7 @@ #include "Session/StartupScene.h" #include "Assets/AssetCooker.h" +#include "Assets/CookedAssetRuntime.h" #include "Assets/IAssetSource.h" #include "Assets/MeshAsset.h" #include "Assets/SceneFile.h" @@ -241,13 +242,43 @@ std::unordered_map BuildObjectDetailsMap( } return DetailsByObjectId; } + +void EnsureWorldFolder(EditorSceneState &SceneState) { + const auto HasWorldItem = std::find_if( + SceneState.Items.begin(), SceneState.Items.end(), + [](const EditorSceneItem &Item) { return Item.Id == "world"; }); + if (HasWorldItem == SceneState.Items.end()) { + SceneState.Items.insert(SceneState.Items.begin(), + EditorSceneItem{ + .Id = "world", + .DisplayName = "World", + .Kind = EditorSceneItemKind::Folder, + .Visible = true, + .Children = {}, + }); + } + + if (SceneState.ObjectDetailsById.find("world") == + SceneState.ObjectDetailsById.end()) { + SceneState.ObjectDetailsById.emplace( + "world", EditorObjectDetails{ + .ObjectId = "world", + .DisplayName = "World", + .Kind = EditorSceneItemKind::Folder, + .Visible = true, + .SupportsTransform = false, + .TransformReadOnly = true, + }); + } +} } // namespace -std::vector BuildStartupSceneMeshInstances() { - const Assets::LocalAssetSource ContentDir{AXIOM_CONTENT_DIR}; +std::vector +BuildStartupSceneMeshInstances(const std::filesystem::path &ContentRoot) { + const Assets::LocalAssetSource ContentDir{ContentRoot}; const std::filesystem::path RelativeMeshPath = "basicmesh.glb"; const auto MeshPath = ContentDir.ResolveRelative(RelativeMeshPath.string()); - Assets::CookMeshAsset(AXIOM_CONTENT_DIR, RelativeMeshPath); + Assets::CookMeshAsset(ContentRoot, RelativeMeshPath); const auto SceneData = Assets::LoadBasicMeshAsset(MeshPath); if (!SceneData.has_value()) { A_CORE_ERROR("Failed to load startup mesh asset scene: {0}", @@ -294,9 +325,13 @@ std::vector BuildStartupSceneMeshInstances() { return Instances; } -EditorSceneState BuildStartupSceneState() { +std::vector BuildStartupSceneMeshInstances() { + return BuildStartupSceneMeshInstances(std::filesystem::path(AXIOM_CONTENT_DIR)); +} + +EditorSceneState BuildStartupSceneState(const std::filesystem::path &ContentDir) { EditorSceneState SceneState{}; - SceneState.MeshInstances = BuildStartupSceneMeshInstances(); + SceneState.MeshInstances = BuildStartupSceneMeshInstances(ContentDir); if (SceneState.MeshInstances.empty()) { return SceneState; } @@ -306,13 +341,22 @@ EditorSceneState BuildStartupSceneState() { return SceneState; } +EditorSceneState BuildStartupSceneState() { + return BuildStartupSceneState(std::filesystem::path(AXIOM_CONTENT_DIR)); +} + bool LoadStartupScene(EditorSession &Session) { - const Assets::LocalAssetSource ContentDir{AXIOM_CONTENT_DIR}; + const std::filesystem::path ContentRoot = + Session.GetContentDir().empty() ? std::filesystem::path(AXIOM_CONTENT_DIR) + : Session.GetContentDir(); + const bool CookedOnlyContent = Assets::IsCookedOnlyContentPath(ContentRoot); + const Assets::LocalAssetSource ContentDir{ContentRoot}; const auto SceneFilePath = ContentDir.ResolveRelative("scene.json"); if (std::filesystem::exists(SceneFilePath)) { auto Loaded = Assets::LoadSceneFromFile(SceneFilePath); - if (Loaded.has_value() && !Loaded->MeshInstances.empty()) { + if (Loaded.has_value()) { + EnsureWorldFolder(*Loaded); A_CORE_INFO("StartupScene: loaded saved scene from {0}", SceneFilePath.string()); Session.SetSceneState(std::move(*Loaded)); @@ -322,7 +366,14 @@ bool LoadStartupScene(EditorSession &Session) { "falling back to defaults"); } - EditorSceneState SceneState = BuildStartupSceneState(); + if (CookedOnlyContent) { + A_CORE_ERROR( + "StartupScene: packaged cooked-only content at '{}' requires a valid scene.json and will not fall back to editor defaults", + ContentRoot.string()); + return false; + } + + EditorSceneState SceneState = BuildStartupSceneState(ContentRoot); if (SceneState.MeshInstances.empty()) { return false; } diff --git a/Axiom/Session/StartupScene.h b/Axiom/Session/StartupScene.h index 7294db82..b468c5ce 100644 --- a/Axiom/Session/StartupScene.h +++ b/Axiom/Session/StartupScene.h @@ -4,7 +4,11 @@ namespace Axiom { EditorSceneState BuildStartupSceneState(); +EditorSceneState BuildStartupSceneState( + const std::filesystem::path &ContentDir); bool LoadStartupScene(EditorSession &Session); std::vector BuildStartupSceneMeshInstances(); +std::vector +BuildStartupSceneMeshInstances(const std::filesystem::path &ContentDir); } // namespace Axiom diff --git a/Content/Cooked/AssetCookManifest.json b/Content/Cooked/AssetCookManifest.json index 618010e1..4ef95a0c 100644 --- a/Content/Cooked/AssetCookManifest.json +++ b/Content/Cooked/AssetCookManifest.json @@ -1,10 +1,48 @@ { "entries": [ - {"assetId":6124137011624734461,"kind":"mesh","relativePath":"basicmesh.glb","cookedPath":"Cooked/basicmesh.wmesh","formatVersion":2,"sourceHash":10769525362242101033}, - {"assetId":18232260332646399803,"kind":"material","relativePath":"Generated/Materials/crate-1","cookedPath":"Cooked/Generated/Materials/crate-1.wmat","formatVersion":1,"sourceHash":7889859340775370464}, - {"assetId":813026631983381292,"kind":"texture","relativePath":"Engine/tf2 coconut.jpg","cookedPath":"Cooked/Engine/tf2 coconut.wtex","formatVersion":1,"sourceHash":2819454596902807771}, + {"assetId":15229175004894892839,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__101","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__101.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":12140336635836207652,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__101","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__101.wmat","formatVersion":1,"sourceHash":3586948860202070916}, + {"assetId":16670649961235633850,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__102","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__102.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":5457201860838636531,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__102","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__102.wmat","formatVersion":1,"sourceHash":6145656221699350966}, + {"assetId":18212675864156437195,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__81","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__81.wmat","formatVersion":1,"sourceHash":10338313927153405416}, + {"assetId":16222585198370233502,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__82","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__82.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":6341542026414383172,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__82","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__82.wmat","formatVersion":1,"sourceHash":10985747164797825642}, + {"assetId":6276292986914166204,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__83","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__83.wtex","formatVersion":1,"sourceHash":1136906439044749114}, {"assetId":13913950202207721753,"kind":"mesh","relativePath":"sponza_atrium_3.glb","cookedPath":"Cooked/sponza_atrium_3.wmesh","formatVersion":2,"sourceHash":13152113367551948137}, - {"assetId":5770549793718642602,"kind":"mesh","relativePath":"structure.glb","cookedPath":"Cooked/structure.wmesh","formatVersion":1,"sourceHash":11410644714278776383}, + {"assetId":3524126900194724051,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__84","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__84.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":1368142407168599961,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__84","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__84.wmat","formatVersion":1,"sourceHash":1573590478991570099}, + {"assetId":8379936892125882364,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__85","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__85.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":17615425434727149779,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__85","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__85.wmat","formatVersion":1,"sourceHash":15021837972009510944}, + {"assetId":5370529907106783966,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":3307378166964972138,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat","formatVersion":1,"sourceHash":713712224417849749}, + {"assetId":11435685726260663774,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__87","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__87.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":1601912636468888724,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__87","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__87.wmat","formatVersion":1,"sourceHash":2836131640602896348}, + {"assetId":1914623039437072610,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__88","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__88.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":6896494829003407116,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__88","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__88.wmat","formatVersion":1,"sourceHash":3677809703873982172}, + {"assetId":4113860169957569099,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__89","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__89.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":6410752746425559445,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__89","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__89.wmat","formatVersion":1,"sourceHash":794871690607076599}, + {"assetId":16292226204569430461,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__90","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__90.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":12546584266307313070,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__90","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__90.wmat","formatVersion":1,"sourceHash":17304015300532786011}, + {"assetId":1161113871088196371,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__91","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__91.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":13140657335267704776,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__91","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__91.wmat","formatVersion":1,"sourceHash":10993084337399955276}, + {"assetId":2153231978189338291,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__92","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__92.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":7574761956857896106,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__92","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__92.wmat","formatVersion":1,"sourceHash":15347192330358828181}, + {"assetId":14593766337526508589,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__93","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":12588103244075146374,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__93","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__93.wmat","formatVersion":1,"sourceHash":11879179696935072141}, + {"assetId":6530629928095013992,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__94","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__94.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":15112736952776741626,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__94","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__94.wmat","formatVersion":1,"sourceHash":7690582110283420096}, + {"assetId":18081150503426162446,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__95","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__95.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":1994779334733726980,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__95","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__95.wmat","formatVersion":1,"sourceHash":3660925423700951973}, + {"assetId":10480157717321104301,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__96","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__96.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":4087356689680254175,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__96","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__96.wmat","formatVersion":1,"sourceHash":4973025491254526861}, + {"assetId":3901345578842322117,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__97","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__97.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":2419053644206384395,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__97","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__97.wmat","formatVersion":1,"sourceHash":15030832607098163724}, + {"assetId":5743276401868917762,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__98","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":10793103097143920867,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__98","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__98.wmat","formatVersion":1,"sourceHash":11605956222055713613}, + {"assetId":17840664353554127551,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__99","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__99.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":6429394278380673583,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__99","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__99.wmat","formatVersion":1,"sourceHash":521088494192120068}, + {"assetId":3968184785818211594,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__100","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__100.wtex","formatVersion":1,"sourceHash":1136906439044749114}, + {"assetId":15000008075878416251,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__100","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__100.wmat","formatVersion":1,"sourceHash":14785741697486435488}, {"assetId":8949008766911302915,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__0","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__0.wtex","formatVersion":1,"sourceHash":1136906439044749114}, {"assetId":6406691723554709426,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__0","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__0.wmat","formatVersion":1,"sourceHash":12105348316115371814}, {"assetId":2047440963454411317,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__1","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__1.wtex","formatVersion":1,"sourceHash":1136906439044749114}, @@ -168,51 +206,6 @@ {"assetId":12363407856602167113,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__80","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__80.wtex","formatVersion":1,"sourceHash":1136906439044749114}, {"assetId":7825203658809297879,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__80","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__80.wmat","formatVersion":1,"sourceHash":3883641405705167589}, {"assetId":10411425554735263391,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__81","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__81.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":18212675864156437195,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__81","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__81.wmat","formatVersion":1,"sourceHash":10338313927153405416}, - {"assetId":16222585198370233502,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__82","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__82.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":6341542026414383172,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__82","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__82.wmat","formatVersion":1,"sourceHash":10985747164797825642}, - {"assetId":6276292986914166204,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__83","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__83.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":9297787003665117867,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__83","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__83.wmat","formatVersion":1,"sourceHash":987993751301634973}, - {"assetId":3524126900194724051,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__84","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__84.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":1368142407168599961,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__84","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__84.wmat","formatVersion":1,"sourceHash":1573590478991570099}, - {"assetId":8379936892125882364,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__85","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__85.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":17615425434727149779,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__85","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__85.wmat","formatVersion":1,"sourceHash":15021837972009510944}, - {"assetId":5370529907106783966,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":3307378166964972138,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__86","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat","formatVersion":1,"sourceHash":16414512736408610493}, - {"assetId":11435685726260663774,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__87","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__87.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":1601912636468888724,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__87","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__87.wmat","formatVersion":1,"sourceHash":2836131640602896348}, - {"assetId":1914623039437072610,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__88","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__88.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":6896494829003407116,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__88","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__88.wmat","formatVersion":1,"sourceHash":3677809703873982172}, - {"assetId":4113860169957569099,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__89","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__89.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":6410752746425559445,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__89","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__89.wmat","formatVersion":1,"sourceHash":794871690607076599}, - {"assetId":16292226204569430461,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__90","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__90.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":12546584266307313070,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__90","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__90.wmat","formatVersion":1,"sourceHash":17304015300532786011}, - {"assetId":1161113871088196371,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__91","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__91.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":13140657335267704776,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__91","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__91.wmat","formatVersion":1,"sourceHash":10993084337399955276}, - {"assetId":2153231978189338291,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__92","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__92.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":7574761956857896106,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__92","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__92.wmat","formatVersion":1,"sourceHash":15347192330358828181}, - {"assetId":14593766337526508589,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__93","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":12588103244075146374,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__93","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__93.wmat","formatVersion":1,"sourceHash":11879179696935072141}, - {"assetId":6530629928095013992,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__94","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__94.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":15112736952776741626,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__94","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__94.wmat","formatVersion":1,"sourceHash":7690582110283420096}, - {"assetId":18081150503426162446,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__95","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__95.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":1994779334733726980,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__95","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__95.wmat","formatVersion":1,"sourceHash":3660925423700951973}, - {"assetId":10480157717321104301,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__96","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__96.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":4087356689680254175,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__96","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__96.wmat","formatVersion":1,"sourceHash":4973025491254526861}, - {"assetId":3901345578842322117,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__97","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__97.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":2419053644206384395,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__97","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__97.wmat","formatVersion":1,"sourceHash":15030832607098163724}, - {"assetId":5743276401868917762,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__98","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":10793103097143920867,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__98","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__98.wmat","formatVersion":1,"sourceHash":11605956222055713613}, - {"assetId":17840664353554127551,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__99","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__99.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":6429394278380673583,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__99","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__99.wmat","formatVersion":1,"sourceHash":521088494192120068}, - {"assetId":3968184785818211594,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__100","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__100.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":15000008075878416251,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__100","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__100.wmat","formatVersion":1,"sourceHash":14785741697486435488}, - {"assetId":15229175004894892839,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__101","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__101.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":12140336635836207652,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__101","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__101.wmat","formatVersion":1,"sourceHash":3586948860202070916}, - {"assetId":16670649961235633850,"kind":"texture","relativePath":"Generated/MeshTextures/sponza_atrium_3__102","cookedPath":"Cooked/Generated/MeshTextures/sponza_atrium_3__102.wtex","formatVersion":1,"sourceHash":1136906439044749114}, - {"assetId":5457201860838636531,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__102","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__102.wmat","formatVersion":1,"sourceHash":6145656221699350966}, - {"assetId":1545219856950675401,"kind":"material","relativePath":"Generated/MeshMaterials/basicmesh__0","cookedPath":"Cooked/Generated/MeshMaterials/basicmesh__0.wmat","formatVersion":1,"sourceHash":17385510028530011617}, - {"assetId":15423180035666698351,"kind":"material","relativePath":"Generated/MeshMaterials/basicmesh__1","cookedPath":"Cooked/Generated/MeshMaterials/basicmesh__1.wmat","formatVersion":1,"sourceHash":5263286426569856486}, - {"assetId":2002519743011508280,"kind":"material","relativePath":"Generated/MeshMaterials/basicmesh__2","cookedPath":"Cooked/Generated/MeshMaterials/basicmesh__2.wmat","formatVersion":1,"sourceHash":5263286426569856486} + {"assetId":9297787003665117867,"kind":"material","relativePath":"Generated/MeshMaterials/sponza_atrium_3__83","cookedPath":"Cooked/Generated/MeshMaterials/sponza_atrium_3__83.wmat","formatVersion":1,"sourceHash":987993751301634973} ] } diff --git a/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat b/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat index 7a0a03a6..85e1cc22 100644 Binary files a/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat and b/Content/Cooked/Generated/MeshMaterials/sponza_atrium_3__86.wmat differ diff --git a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex index 46b064ee..ea084279 100644 Binary files a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex and b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__86.wtex differ diff --git a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex index f2edcc3c..e0708d00 100644 Binary files a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex and b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__93.wtex differ diff --git a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex index bd2e1d0c..3252ed36 100644 Binary files a/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex and b/Content/Cooked/Generated/MeshTextures/sponza_atrium_3__98.wtex differ diff --git a/Content/Cooked/sponza_atrium_3.wmesh b/Content/Cooked/sponza_atrium_3.wmesh index 37339303..ddb2b67f 100644 Binary files a/Content/Cooked/sponza_atrium_3.wmesh and b/Content/Cooked/sponza_atrium_3.wmesh differ diff --git a/Docs/DistributedWraithEngineDesign.md b/Docs/DistributedWraithEngineDesign.md index d52e9da1..5a45224b 100644 --- a/Docs/DistributedWraithEngineDesign.md +++ b/Docs/DistributedWraithEngineDesign.md @@ -790,14 +790,21 @@ This does not require that every future game uses pure video streaming only. Som ### 23.1 Product Outputs The build/deployment pipeline should produce: +- `Project Workspace` + - `project.wraith.json` + - project-local `Content/` + - project-local `Scripts/` + - generated script solution/project files + - per-project `Build/` and `Package/` + - `Packaged Desktop Runtime` - executable - - cooked assets + - per-project cooked assets - runtime config - `Hosted Session Runtime` - headless executable or container image - - cooked assets + - per-project cooked assets - session bootstrap config - trust profile @@ -813,6 +820,7 @@ Both outputs should share: Hosted runtimes will need launch descriptors such as: - project/build identifier +- project root or packaged content root - asset manifest location - trust profile - requested runtime mode @@ -855,8 +863,9 @@ Hosted runtimes will need launch descriptors such as: - engine-to-managed bridge contracts ### 24.6 Packaging and Deployment -- project manifest +- project manifest (`project.wraith.json`) - cooked asset manifest +- package manifest - session launch config - trust profile descriptor @@ -868,6 +877,7 @@ Suggested modules: - auth/session pages - project browser - editor workspace layout +- script editor - outliner - inspector - asset/content browser @@ -1062,8 +1072,8 @@ pipeline. Progress update: -- `SetMeshAssetCommand { ObjectId, AssetPath }` lets any `SceneMeshObject` reference any discovered `.glb`/`.gltf`/`.fbx`/`.obj` asset; the handler resolves `ContentDir / AssetPath`, calls `LoadBasicMeshAsset`, and updates the live `MeshInstance`; if the object was created at runtime via `CreateObject` (which does not pre-populate `MeshInstances`), the handler now creates the entry rather than silently dropping the command -- `SetMeshAsset` is fully serialized to `scene.json` via the `assetRelativePath` field on each mesh entry so assignments survive server restarts; the content browser double-click on a `.glb`/`.fbx`/`.obj` asset while a mesh object is selected sends the command end-to-end +- `SetMeshAssetCommand { ObjectId, AssetPath }` now lets both `SceneMeshObject` and `SceneActor` roots reference any discovered `.glb`/`.gltf`/`.fbx`/`.obj` asset; the handler resolves `ContentDir / AssetPath`, calls `LoadBasicMeshAsset`, and updates the live `MeshInstance`; if the object was created at runtime via `CreateObject` (which does not pre-populate `MeshInstances`), the handler now creates the entry rather than silently dropping the command +- `SetMeshAsset` is fully serialized to `scene.json` via the `assetRelativePath` field on each assigned object so assignments survive server restarts; the content browser double-click on a `.glb`/`.fbx`/`.obj` asset while a mesh or actor object is selected sends the command end-to-end - `SetLightPropertiesCommand { ObjectId, Color, Intensity, Direction }` drives the first visible `SceneLight` in the scene; direction is derived from the light object's world-space position each frame; the `CameraFrameUniform` UBO was extended with `lightDirectionAndIntensity` and `lightColorAndEnabled` fields consumed by `mesh.frag` - `mesh.frag` was rewritten with a Blinn-Phong specular model: half-vector `H = normalize(L + V)`, `shininess = mix(256, 2, roughness)`, specular color blended between dielectric F0 `vec3(0.04)` and base color via metallic factor; a separate metallic ambient floor (`0.35` at metallic=1/roughness=0) approximates environment reflections absent IBL so metallic surfaces are not black without a high-intensity light - `SetMaterialPropertiesCommand { ObjectId, BaseColorFactor, Metallic, Roughness }` updates both `ObjectDetailsById.Material` and the live `MeshInstance.Material`; values reach the shader as Vulkan push constants in `MeshGraphicsPushConstants`; both stage flags (`VERTEX_BIT | FRAGMENT_BIT`) are set so the layout is valid @@ -1237,8 +1247,9 @@ Likely targets based on current trajectory: - CI passes without modifications to test code ### Phase 11: Packaging and Hosted Runtime Maturation -- packaged desktop builds from cooked content (depends on finishing the remaining Phase 8 packaging/runtime cutover work) +- dedicated packaged desktop runtime entrypoint built from per-project outputs - hosted session deployment descriptors +- stricter packaged-content validation and startup UX - sample project proving shared runtime path via `CookedAssetSource` ## 29. Test Plan @@ -1271,6 +1282,7 @@ Likely targets based on current trajectory: ### 29.6 Packaging - one sample project runs as a packaged desktop build and as a hosted authoritative runtime from the same cooked project content +- cook/package flows surface actionable progress and error status in the editor shell ### 29.7 Regression Coverage - native application loop still supports desktop editor startup @@ -1321,9 +1333,14 @@ Progress update: - reparent is implemented: any object can be dragged onto any other in the outliner; transforms are stored in local space and world transforms are recomputed for the entire moved subtree - Collaboration v1 is complete: object locking prevents simultaneous gizmo conflicts, presence roster shows connected users, and the heartbeat/timeout loop handles hard tab closes - Phase 5 (Reflection and Asset Evolution) is complete: `AssetId` stable identity, `IAssetSource` / `LocalAssetSource` VFS, `ListAssets` / `GetSchema` / `SetProperty` / `SaveScene` commands, `SceneFile` JSON persistence, content browser wired to live asset catalogue, details panel schema-driven, toolbar Save button with success/failure animation -- Phase 7 (Asset Pipeline) is complete: `SetMeshAssetCommand` wires any discovered `.glb`/`.gltf`/`.fbx`/`.obj` to any `SceneMeshObject` with scene-file persistence; `SetLightPropertiesCommand` drives a Blinn-Phong directional light from `SceneLight` world position; `SetMaterialPropertiesCommand` exposes `BaseColorFactor`/`Metallic`/`Roughness` push constants end-to-end through the inspector; `SetMaterialTextureCommand` assigns PNG/JPG textures to mesh base-color slots with persistence, inspector display, and drag-drop from both the content browser and outliner; FBX/OBJ import is implemented via assimp with embedded and external texture handling; the content browser accepts OS file drag-drop and a file picker Import button that upload to `POST /assets/upload`; texture thumbnail previews are served by the remote viewport server; the content browser navigates folders non-recursively; 17 new tests cover all new commands, events, and the `CreateObject`→`SetMeshAsset` runtime-creation path -- Phase 8 (Binary Asset Formats) first slice is now implemented: `.wmesh`, `.wtex`, and `.wmat` cooked formats exist; `AssetCookManifest` and `CookedAssetSource` resolve cooked content by stable `AssetId`; startup, scene reload, and mesh/texture editing flows all prefer cooked payloads while preserving source fallback; scene persistence now round-trips cooked material state through `materialAssetPath` -- the next step is packaging/runtime cutover work: finish removing source-content assumptions from packaged runs and make importer-driven cooking the primary freshness path +- the project system is now implemented as the active editor/runtime foundation: projects live under a managed host projects root, each project has its own `project.wraith.json`, `Content/`, `Scripts/`, C# solution/project files, `Build/`, and `Package/` directories, and the server tracks an active project instead of assuming one repo-global content root +- project-scoped content routing is in place: scene load/save, asset upload/listing, script CRUD, cooking, and packaging resolve against the active project's `Content/`, while engine-owned shared assets remain available from the global read-only `Content/Engine/` namespace +- browser project UX is now live: the editor opens through a project browser, `File -> New Project` and `File -> Open Project` return through the managed project flow, and the current project is shown in the shell +- scripting authoring is now project-local: each project gets a generated `Scripts/` workspace plus `.sln`/`.csproj`, the browser has a script editor with file CRUD and syntax highlighting, scripts can be attached to actors, and inspector `Open Script` jumps into the editor +- Phase 7 (Asset Pipeline) is complete: `SetMeshAssetCommand` wires any discovered `.glb`/`.gltf`/`.fbx`/`.obj` to mesh objects and actor roots with scene-file persistence; `SetLightPropertiesCommand` drives a Blinn-Phong directional light from `SceneLight` world position; `SetMaterialPropertiesCommand` exposes `BaseColorFactor`/`Metallic`/`Roughness` push constants end-to-end through the inspector; `SetMaterialTextureCommand` assigns PNG/JPG textures to mesh base-color slots with persistence, inspector display, and drag-drop from both the content browser and outliner; FBX/OBJ import is implemented via assimp with embedded and external texture handling; the content browser accepts OS file drag-drop and a file picker Import button that upload to `POST /assets/upload`; texture thumbnail previews are served by the remote viewport server; the content browser navigates folders non-recursively; regression coverage now includes the `CreateObject`→`SetMeshAsset` runtime-creation path and actor mesh assignment +- Phase 8 (Binary Asset Formats) and the first packaging foundation are now implemented: `.wmesh`, `.wtex`, and `.wmat` cooked formats exist; `AssetCookManifest` and `CookedAssetSource` resolve cooked content by stable `AssetId`; startup, scene reload, and mesh/texture editing flows all prefer cooked payloads while preserving source fallback during editor use; scene persistence now round-trips cooked material state through `materialAssetPath`; projects now cook into per-project `Content/Cooked/` and stage packaged outputs under per-project `Package/` directories +- packaged runtime cutover is partially implemented: staged packages include cooked project content, scene state, the asset cook manifest, shared engine content, and a package manifest; packaged content roots are now treated as cooked-only at runtime rather than silently recooking or falling back to source files +- the next step is packaging/runtime hardening and editor polish: add a dedicated packaged app entrypoint, tighten packaged-scene/runtime validation, and continue improving build/package UX on top of the finished project system foundation That slice proves the core thesis: diff --git a/EditorFrontend/app/globals.css b/EditorFrontend/app/globals.css index dc2aea17..23cc7e3a 100644 --- a/EditorFrontend/app/globals.css +++ b/EditorFrontend/app/globals.css @@ -75,8 +75,8 @@ } @theme inline { - --font-sans: 'Geist', 'Geist Fallback'; - --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); diff --git a/EditorFrontend/app/layout.tsx b/EditorFrontend/app/layout.tsx index 6a97d483..80054a4f 100644 --- a/EditorFrontend/app/layout.tsx +++ b/EditorFrontend/app/layout.tsx @@ -1,11 +1,9 @@ import type { Metadata } from 'next' -import { Geist, Geist_Mono } from 'next/font/google' import { Analytics } from '@vercel/analytics/next' +import { GeistSans } from 'geist/font/sans' +import { GeistMono } from 'geist/font/mono' import './globals.css' -const _geist = Geist({ subsets: ["latin"] }); -const _geistMono = Geist_Mono({ subsets: ["latin"] }); - export const metadata: Metadata = { title: 'Wraith Engine', description: 'High performance streamed game engine', @@ -35,8 +33,8 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - + + {children} {process.env.NODE_ENV === 'production' && } diff --git a/EditorFrontend/components/engine/content-browser.tsx b/EditorFrontend/components/engine/content-browser.tsx index c5da57df..e1babdcd 100644 --- a/EditorFrontend/components/engine/content-browser.tsx +++ b/EditorFrontend/components/engine/content-browser.tsx @@ -183,19 +183,30 @@ export function ContentBrowser() { return getDirectoryContents(assets, currentPath) }, [assets, currentPath, searchQuery]) - const canAssignToSelection = - selectedObject?.kind === "mesh" && selectedObject.id !== undefined + const selectedObjectKind = selectedObject?.kind + const canAssignMeshToSelection = + (selectedObjectKind === "mesh" || selectedObjectKind === "actor") && + selectedObject?.id !== undefined + const canAssignTextureToSelection = + selectedObjectKind === "mesh" && selectedObject?.id !== undefined const assignAssetToSelection = useCallback( async (asset: SessionAssetDescriptor) => { - if (!canAssignToSelection || !selectedObject) return if (asset.kind === "mesh") { + if (!canAssignMeshToSelection || !selectedObject) return await setMeshAsset(selectedObject.id, asset.path) } else if (asset.kind === "texture") { + if (!canAssignTextureToSelection || !selectedObject) return await setMaterialTexture(selectedObject.id, asset.path) } }, - [canAssignToSelection, selectedObject, setMeshAsset, setMaterialTexture] + [ + canAssignMeshToSelection, + canAssignTextureToSelection, + selectedObject, + setMaterialTexture, + setMeshAsset, + ] ) const navigateTo = (path: string) => { @@ -452,9 +463,9 @@ export function ContentBrowser() { if (h) h(e.clientX, e.clientY, asset.kind, asset.path) }} title={ - canAssignToSelection && asset.kind === "mesh" - ? "Double-click to assign, or drag into the viewport to add" - : canAssignToSelection && asset.kind === "texture" + canAssignMeshToSelection && asset.kind === "mesh" + ? "Double-click to assign to the selected mesh or actor, or drag into the viewport to add" + : canAssignTextureToSelection && asset.kind === "texture" ? "Double-click or drag to assign texture to a mesh object" : asset.path } @@ -510,9 +521,9 @@ export function ContentBrowser() { if (h) h(e.clientX, e.clientY, asset.kind, asset.path) }} title={ - canAssignToSelection && asset.kind === "mesh" - ? "Double-click to assign, or drag into the viewport to add" - : canAssignToSelection && asset.kind === "texture" + canAssignMeshToSelection && asset.kind === "mesh" + ? "Double-click to assign to the selected mesh or actor, or drag into the viewport to add" + : canAssignTextureToSelection && asset.kind === "texture" ? "Double-click or drag to assign texture to a mesh object" : asset.path } diff --git a/EditorFrontend/components/engine/details.tsx b/EditorFrontend/components/engine/details.tsx index 3eb174db..4dadf3e7 100644 --- a/EditorFrontend/components/engine/details.tsx +++ b/EditorFrontend/components/engine/details.tsx @@ -1,13 +1,15 @@ "use client" import { Code, Lock, Settings, Unlock } from "lucide-react" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useRemoteViewport, type SessionObjectDetails, type SessionObjectSchema, type SessionObjectTransformUpdate, } from "./remote-viewport-context" +import { useProjectSession } from "./project-session-context" +import { useDock } from "./dock/dock-context" function fallbackUserLabel(userId: number) { return userId === 1 ? "Host" : `User ${userId - 1}` @@ -287,15 +289,60 @@ function ScriptSection({ isSaving: boolean setIsSaving: (value: boolean) => void }) { + const { serverOrigin, activeProject, requestOpenScript } = useProjectSession() + const { setActiveTab } = useDock() const { setProperty } = useRemoteViewport() const scriptProp = schema?.properties.find((p) => p.name === "scriptClass") const currentValue = scriptProp?.value ?? "" const [draftClass, setDraftClass] = useState(currentValue) + const [availableClasses, setAvailableClasses] = useState< + Array<{ className: string; path: string }> + >([]) + const [classesLoading, setClassesLoading] = useState(false) useEffect(() => { setDraftClass(scriptProp?.value ?? "") }, [objectId, scriptProp?.value]) + const loadScriptClasses = useCallback(async () => { + setClassesLoading(true) + try { + const response = await fetch(`${serverOrigin}/scripts/classes`, { + cache: "no-store", + }) + const text = await response.text() + const payload = text.length > 0 ? JSON.parse(text) : { classes: [] } + if (!response.ok) { + throw new Error(payload?.message ?? `${response.status} ${response.statusText}`) + } + setAvailableClasses( + Array.isArray(payload.classes) + ? payload.classes.filter( + (entry: unknown): entry is { className: string; path: string } => + typeof entry === "object" && + entry !== null && + typeof (entry as { className?: unknown }).className === "string" && + typeof (entry as { path?: unknown }).path === "string" + ) + : [] + ) + } catch { + setAvailableClasses([]) + } finally { + setClassesLoading(false) + } + }, [serverOrigin]) + + useEffect(() => { + void loadScriptClasses() + }, [loadScriptClasses, activeProject.slug]) + + const attachedScriptPath = useMemo( + () => + availableClasses.find((entry) => entry.className === currentValue)?.path ?? null, + [availableClasses, currentValue] + ) + async function applyScriptClass() { setIsSaving(true) try { @@ -315,6 +362,12 @@ function ScriptSection({ } } + function openAttachedScript() { + if (!attachedScriptPath) return + requestOpenScript(attachedScriptPath) + setActiveTab("tg-viewport", "script-editor") + } + return (
@@ -326,6 +379,7 @@ function ScriptSection({ Class
setDraftClass(event.target.value)} @@ -333,9 +387,31 @@ function ScriptSection({ type="text" value={draftClass} /> + + {availableClasses.map((entry) => ( + +

+ {classesLoading + ? "Loading project script classes..." + : availableClasses.length > 0 + ? `${availableClasses.length} project classes available` + : "No attachable project script classes found yet"} +

+ {attachedScriptPath ? ( + + ) : null} {currentValue && ( + item === "File" ? ( +
+ + {fileMenuOpen ? ( +
+ + +
+ ) : null} +
+ ) : item === "Build" ? ( +
+ + {buildMenuOpen ? ( +
+ + +
+ ) : null} +
+ ) : ( + + ) ))}
- Project: MyGame - +
+ + Project: {activeProject?.name ?? "None"} + +
) diff --git a/EditorFrontend/components/engine/project-browser.tsx b/EditorFrontend/components/engine/project-browser.tsx new file mode 100644 index 00000000..1a2334c4 --- /dev/null +++ b/EditorFrontend/components/engine/project-browser.tsx @@ -0,0 +1,259 @@ +"use client" + +import { FolderOpen, Loader2, Plus, RefreshCw, Sparkles } from "lucide-react" +import { useMemo, useState } from "react" + +export interface ProjectDescriptor { + projectId: string + name: string + slug: string + rootPath: string + contentDir: string + scriptsDir: string + scriptProjectPath: string + scriptSolutionPath: string + scriptAssemblyName: string + scriptRootNamespace: string + starterScriptPath: string + starterScriptClassName: string + starterScriptQualifiedClassName: string + cookedDir: string + cookManifestPath: string + buildDir: string + packageDir: string + packagedContentDir: string + packagedCookedDir: string + packagedSceneFilePath: string + packageManifestPath: string + engineContentDir: string + sceneFilePath: string +} + +interface ProjectBrowserProps { + projects: ProjectDescriptor[] + activeProject: ProjectDescriptor | null + loading: boolean + busy: boolean + error: string | null + serverOrigin: string + canClose: boolean + onClose: () => void + onRefresh: () => Promise + onOpenProject: (slug: string) => Promise + onCreateProject: (name: string) => Promise +} + +export function ProjectBrowser({ + projects, + activeProject, + loading, + busy, + error, + serverOrigin, + canClose, + onClose, + onRefresh, + onOpenProject, + onCreateProject, +}: ProjectBrowserProps) { + const [projectName, setProjectName] = useState("") + const sortedProjects = useMemo( + () => [...projects].sort((left, right) => left.name.localeCompare(right.name)), + [projects] + ) + + async function handleCreateProject() { + const trimmed = projectName.trim() + if (!trimmed) return + await onCreateProject(trimmed) + setProjectName("") + } + + return ( +
+
+
+
+
+
+ + Project Workspace +
+ {canClose ? ( + + ) : null} +
+ +

+ Choose the project that owns this editor session. +

+

+ New projects get their own content root and scene file. Shared engine assets stay + available from the global engine content directory. +

+ +
+
+
+

+ Create Project +

+

+ Start a clean workspace under the managed host projects root. +

+
+ +
+ +
+ setProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + void handleCreateProject() + } + }} + placeholder="Project name" + type="text" + value={projectName} + /> + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+
+ Server + {serverOrigin} +
+
+ Shared Engine + + {activeProject?.engineContentDir ?? "Content/Engine"} + +
+
+
+ +
+
+
+

+ Open Project +

+

+ Managed Projects +

+
+ +
+ +
+ {sortedProjects.length === 0 ? ( +
+ +

No projects yet

+

+ Create your first project to start a dedicated content workspace and editor + session. +

+
+ ) : null} + + {sortedProjects.map((project) => { + const isActive = activeProject?.slug === project.slug + return ( +
+
+
+
+

{project.name}

+

+ {project.slug} +

+
+ {isActive ? ( + + Active + + ) : null} +
+ +
+
+
+ Project Content +
+
{project.contentDir}
+
+
+
+ Shared Engine Content +
+
{project.engineContentDir}
+
+
+
+ +
+

{project.rootPath}

+ +
+
+ ) + })} +
+
+
+
+ ) +} diff --git a/EditorFrontend/components/engine/project-session-context.tsx b/EditorFrontend/components/engine/project-session-context.tsx new file mode 100644 index 00000000..b01b5001 --- /dev/null +++ b/EditorFrontend/components/engine/project-session-context.tsx @@ -0,0 +1,54 @@ +"use client" + +import { createContext, useCallback, useContext, useState, type ReactNode } from "react" +import type { ProjectDescriptor } from "./project-browser" + +interface ProjectSessionContextValue { + activeProject: ProjectDescriptor + serverOrigin: string + requestedScriptPath: string | null + requestOpenScript: (path: string) => void + clearRequestedScriptPath: () => void +} + +const ProjectSessionContext = createContext(null) + +export function ProjectSessionProvider({ + activeProject, + serverOrigin, + children, +}: { + activeProject: ProjectDescriptor + serverOrigin: string + children: ReactNode +}) { + const [requestedScriptPath, setRequestedScriptPath] = useState(null) + const requestOpenScript = useCallback((path: string) => { + setRequestedScriptPath(path) + }, []) + const clearRequestedScriptPath = useCallback(() => { + setRequestedScriptPath(null) + }, []) + + return ( + + {children} + + ) +} + +export function useProjectSession() { + const context = useContext(ProjectSessionContext) + if (!context) { + throw new Error("useProjectSession must be used within ProjectSessionProvider") + } + return context +} diff --git a/EditorFrontend/components/engine/script-editor.tsx b/EditorFrontend/components/engine/script-editor.tsx new file mode 100644 index 00000000..8c54bbf9 --- /dev/null +++ b/EditorFrontend/components/engine/script-editor.tsx @@ -0,0 +1,706 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react" +import { + FileCode2, + Folder, + ChevronDown, + ChevronRight, + FilePlus2, + Pencil, + RefreshCw, + Save, + Trash2, +} from "lucide-react" +import { useProjectSession } from "./project-session-context" +import { useRemoteViewport } from "./remote-viewport-context" + +interface ScriptListResponse { + type: "scripts_list" + files: string[] +} + +interface ScriptFileResponse { + type: "script_file" + path: string + content: string +} + +interface ScriptMutationResponse { + type: string + path: string +} + +interface ScriptTreeNode { + name: string + path: string + children: ScriptTreeNode[] + files: string[] +} + +function buildScriptTree(files: string[]): ScriptTreeNode { + const root: ScriptTreeNode = { name: "Scripts", path: "", children: [], files: [] } + const nodes = new Map([["", root]]) + + for (const file of files) { + const parts = file.split("/") + if (parts.length === 1) { + root.files.push(file) + continue + } + + for (let depth = 1; depth < parts.length; depth++) { + const dirPath = parts.slice(0, depth).join("/") + if (!nodes.has(dirPath)) { + const parentPath = parts.slice(0, depth - 1).join("/") + const node: ScriptTreeNode = { + name: parts[depth - 1], + path: dirPath, + children: [], + files: [], + } + nodes.set(dirPath, node) + nodes.get(parentPath)!.children.push(node) + } + } + + const dirPath = parts.slice(0, -1).join("/") + nodes.get(dirPath)!.files.push(file) + } + + const sortNode = (node: ScriptTreeNode) => { + node.children.sort((left, right) => left.name.localeCompare(right.name)) + node.files.sort((left, right) => left.localeCompare(right)) + node.children.forEach(sortNode) + } + sortNode(root) + + return root +} + +type TokenStyle = "plain" | "comment" | "string" | "keyword" | "engine" | "number" + +interface ScriptToken { + text: string + style: TokenStyle +} + +const KEYWORDS = new Set([ + "namespace", + "public", + "class", + "using", + "return", + "if", + "else", + "new", + "override", + "void", + "float", + "int", + "bool", + "string", + "private", + "protected", + "internal", + "static", + "async", + "await", + "var", +]) + +const ENGINE_SYMBOLS = new Set([ + "OnCreate", + "OnTick", + "OnDestroy", + "Script", + "GameObject", + "Transform", + "Vector3", +]) + +function isIdentifierStart(char: string) { + return /[A-Za-z_]/.test(char) +} + +function isIdentifierPart(char: string) { + return /[A-Za-z0-9_]/.test(char) +} + +function isNumberStart(source: string, index: number) { + const current = source[index] + const next = source[index + 1] ?? "" + if (!/\d/.test(current)) { + return false + } + if (current !== ".") { + return true + } + return /\d/.test(next) +} + +function tokenizeCSharp(source: string): ScriptToken[] { + const tokens: ScriptToken[] = [] + let index = 0 + + while (index < source.length) { + const current = source[index] + const next = source[index + 1] ?? "" + + if (current === "/" && next === "/") { + let end = index + 2 + while (end < source.length && source[end] !== "\n") { + end += 1 + } + tokens.push({ text: source.slice(index, end), style: "comment" }) + index = end + continue + } + + if (current === "/" && next === "*") { + let end = index + 2 + while (end < source.length - 1 && !(source[end] === "*" && source[end + 1] === "/")) { + end += 1 + } + end = Math.min(source.length, end + 2) + tokens.push({ text: source.slice(index, end), style: "comment" }) + index = end + continue + } + + if (current === "\"") { + let end = index + 1 + while (end < source.length) { + if (source[end] === "\\" && end + 1 < source.length) { + end += 2 + continue + } + if (source[end] === "\"") { + end += 1 + break + } + end += 1 + } + tokens.push({ text: source.slice(index, end), style: "string" }) + index = end + continue + } + + if (isIdentifierStart(current)) { + let end = index + 1 + while (end < source.length && isIdentifierPart(source[end])) { + end += 1 + } + const word = source.slice(index, end) + let style: TokenStyle = "plain" + if (KEYWORDS.has(word)) { + style = "keyword" + } else if (ENGINE_SYMBOLS.has(word)) { + style = "engine" + } + tokens.push({ text: word, style }) + index = end + continue + } + + if (isNumberStart(source, index)) { + let end = index + 1 + while (end < source.length && /[\d._fFmMdD]/.test(source[end])) { + end += 1 + } + tokens.push({ text: source.slice(index, end), style: "number" }) + index = end + continue + } + + tokens.push({ text: current, style: "plain" }) + index += 1 + } + + return tokens +} + +function tokenClassName(style: TokenStyle) { + switch (style) { + case "comment": + return "text-emerald-400/80" + case "string": + return "text-amber-300" + case "keyword": + return "text-sky-300" + case "engine": + return "text-fuchsia-300" + case "number": + return "text-cyan-300" + default: + return undefined + } +} + +function renderHighlightedCode(source: string): ReactNode[] { + return tokenizeCSharp(source).map((token, index) => { + const className = tokenClassName(token.style) + if (!className) { + return {token.text} + } + return ( + + {token.text} + + ) + }) +} + +export function ScriptEditor() { + const { + activeProject, + serverOrigin, + requestedScriptPath, + clearRequestedScriptPath, + } = useProjectSession() + const { reloadScripts, reloadStatus } = useRemoteViewport() + const [files, setFiles] = useState([]) + const [selectedPath, setSelectedPath] = useState(null) + const [loadedPath, setLoadedPath] = useState(null) + const [content, setContent] = useState("") + const [savedContent, setSavedContent] = useState("") + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [expandedPaths, setExpandedPaths] = useState>(new Set([""])) + const editorRef = useRef(null) + const highlightRef = useRef(null) + + const isDirty = content !== savedContent + const tree = useMemo(() => buildScriptTree(files), [files]) + + const fetchJson = useCallback( + async (path: string, init?: RequestInit) => { + const response = await fetch(`${serverOrigin}${path}`, { + cache: "no-store", + ...init, + }) + const text = await response.text() + const payload = text.length > 0 ? (JSON.parse(text) as T & { message?: string }) : null + if (!response.ok) { + throw new Error(payload?.message ?? `${response.status} ${response.statusText}`) + } + return payload as T + }, + [serverOrigin] + ) + + const refreshScripts = useCallback(async () => { + setLoading(true) + setError(null) + try { + const payload = await fetchJson("/scripts") + setFiles(payload.files) + if (payload.files.length === 0) { + setSelectedPath(null) + setLoadedPath(null) + setContent("") + setSavedContent("") + } else if (selectedPath === null || !payload.files.includes(selectedPath)) { + setSelectedPath((current) => + current && payload.files.includes(current) ? current : payload.files[0] + ) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, [fetchJson, selectedPath]) + + const loadFile = useCallback( + async (path: string) => { + setError(null) + try { + const payload = await fetchJson( + `/scripts/file?path=${encodeURIComponent(path)}` + ) + setSelectedPath(payload.path) + setLoadedPath(payload.path) + setContent(payload.content) + setSavedContent(payload.content) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, + [fetchJson] + ) + + useEffect(() => { + void refreshScripts() + }, [refreshScripts, activeProject.slug]) + + useEffect(() => { + if (!selectedPath) { + return + } + if (selectedPath === loadedPath) { + return + } + void loadFile(selectedPath) + }, [selectedPath, loadedPath, loadFile]) + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty) return + event.preventDefault() + event.returnValue = "" + } + window.addEventListener("beforeunload", handleBeforeUnload) + return () => window.removeEventListener("beforeunload", handleBeforeUnload) + }, [isDirty]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") { + return + } + event.preventDefault() + void handleSave() + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }) + + const ensureCanDiscard = useCallback(() => { + if (!isDirty) { + return true + } + return window.confirm("Discard unsaved script changes?") + }, [isDirty]) + + useEffect(() => { + if (!requestedScriptPath) { + return + } + if (requestedScriptPath === selectedPath) { + clearRequestedScriptPath() + return + } + if (!ensureCanDiscard()) { + clearRequestedScriptPath() + return + } + setSelectedPath(requestedScriptPath) + clearRequestedScriptPath() + }, [ + clearRequestedScriptPath, + ensureCanDiscard, + requestedScriptPath, + selectedPath, + ]) + + const chooseFile = useCallback( + (path: string) => { + if (path === selectedPath) return + if (!ensureCanDiscard()) return + setSelectedPath(path) + }, + [ensureCanDiscard, selectedPath] + ) + + async function handleSave() { + if (!selectedPath) return + setSaving(true) + setError(null) + try { + await fetchJson("/scripts/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedPath, content }), + }) + setSavedContent(content) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + } + + async function handleCreate() { + if (!ensureCanDiscard()) return + const suggestedBase = + activeProject.starterScriptClassName && files.length === 0 + ? activeProject.starterScriptClassName + : "NewScript" + const nextPath = window.prompt("New script path", `${suggestedBase}.cs`) + if (!nextPath) return + setError(null) + try { + const payload = await fetchJson("/scripts/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: nextPath }), + }) + setExpandedPaths((current) => { + const next = new Set(current) + const path = payload.path.split("/") + for (let index = 1; index < path.length; index++) { + next.add(path.slice(0, index).join("/")) + } + return next + }) + await refreshScripts() + await loadFile(payload.path) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + async function handleRename() { + if (!selectedPath) return + if (!ensureCanDiscard()) return + const nextPath = window.prompt("Rename script", selectedPath) + if (!nextPath || nextPath === selectedPath) return + setError(null) + try { + const payload = await fetchJson("/scripts/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedPath, newPath: nextPath }), + }) + await refreshScripts() + setSelectedPath(payload.path) + setLoadedPath(null) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + async function handleDelete() { + if (!selectedPath) return + if (!ensureCanDiscard()) return + if (!window.confirm(`Delete ${selectedPath}?`)) return + setError(null) + try { + await fetchJson("/scripts/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: selectedPath }), + }) + setSelectedPath(null) + setLoadedPath(null) + setContent("") + setSavedContent("") + await refreshScripts() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const renderNode = (node: ScriptTreeNode, depth = 0): React.ReactNode => { + const isRoot = node.path === "" + const isExpanded = expandedPaths.has(node.path) + + return ( +
+ {!isRoot ? ( + + ) : null} + + {(isRoot || isExpanded) ? ( +
+ {node.children.map((child) => renderNode(child, depth + (isRoot ? 0 : 1)))} + {node.files.map((file) => { + const fileName = file.split("/").at(-1) ?? file + const isSelected = selectedPath === file + return ( + + ) + })} +
+ ) : null} +
+ ) + } + + const highlightedContent = useMemo(() => renderHighlightedCode(content), [content]) + + return ( +
+ + +
+
+
+

+ {selectedPath ?? "Select a script file"} +

+

+ {activeProject.scriptRootNamespace} +

+
+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ {selectedPath ? ( + <> + +