diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c63e347 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' # Triggers on version tags like v0.2.0, v0.3.0, etc. + workflow_dispatch: # Allows manual trigger from GitHub UI + +jobs: + build-and-release: + runs-on: macos-14 # macOS 14 (Sonoma) with Apple Silicon support + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "5.10" + + - name: Extract version from tag + id: get_version + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + else + VERSION="0.2.0-dev" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Build app bundle + run: | + chmod +x ./create_app_bundle.sh + # Update version in script + sed -i '' "s/VERSION=\".*\"/VERSION=\"${{ steps.get_version.outputs.VERSION }}\"/" create_app_bundle.sh + ./create_app_bundle.sh + + - name: Create DMG + run: | + chmod +x ./create_dmg.sh + # Update version in script + sed -i '' "s/VERSION=\".*\"/VERSION=\"${{ steps.get_version.outputs.VERSION }}\"/" create_dmg.sh + ./create_dmg.sh + + - name: Create Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + UntoldEngineStudio-${{ steps.get_version.outputs.VERSION }}.dmg + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload DMG artifact (for manual builds) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: UntoldEngineStudio-${{ steps.get_version.outputs.VERSION }} + path: UntoldEngineStudio-${{ steps.get_version.outputs.VERSION }}.dmg diff --git a/.gitignore b/.gitignore index 92061f4..f62522d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ DerivedData/ *.exe *.out *.app +*.dmg *.ipa # --------------------------------------- diff --git a/Package.swift b/Package.swift index ed0655c..fada1bc 100644 --- a/Package.swift +++ b/Package.swift @@ -13,7 +13,7 @@ let package = Package( // Use a branch during active development: // .package(url: "https://github.com/untoldengine/UntoldEngine.git", branch: "develop"), // Or pin to a release: - .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.6.0"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.6.1"), ], targets: [ .executableTarget( diff --git a/README.md b/README.md index 2f1e17f..472285b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,52 @@ # Untold Editor -The **Untold Editor** is a companion tool for the [Untold Engine](https://github.com/untoldengine/UntoldEngine). -It provides a visual environment for managing assets, scenes, and entities in projects built with the engine. +The **Untold Editor** is the visual development environment for the [Untold Engine](https://github.com/untoldengine/UntoldEngine). +It provides a complete toolkit for building games with scripting support, asset management, scene editing, and more. -The editor is not required to use the engine, but it makes iteration faster by giving developers and designers a user-friendly interface. +## ๐ŸŽฎ For Game Developers + +**Want to make games? Download [Untold Engine Studio](https://github.com/untoldengine/UntoldEditor/releases)** โ€” a standalone app that includes everything you need: + +- โœ… Complete visual editor +- โœ… Scripting system for game logic +- โœ… All engine features built-in +- โœ… No setup required โ€” just download the DMG and start creating + +๐Ÿ‘‰ **[Download Untold Engine Studio](https://github.com/untoldengine/UntoldEditor/releases)** + +## ๐Ÿ› ๏ธ For Contributors + +This repository is for developers who want to **contribute to the editor itself**. If you're looking to improve the editor, fix bugs, or add features, you're in the right place! ![UntoldEditorScreenshot](images/editorscreenshot.png) --- +## ๐Ÿ“š Understanding the Ecosystem + +The Untold Engine project has three main components: + +### For Game Developers: +- **[Untold Engine Studio](https://github.com/untoldengine/UntoldEditor/releases)** (Download) + - Standalone app with everything included + - Scripting, visual editor, asset management + - No GitHub or build tools required + - **Start here if you want to make games** + +### For Engine/Editor Contributors: +- **[Untold Engine](https://github.com/untoldengine/UntoldEngine)** (Clone to contribute) + - Core engine repository + - Rendering, physics, ECS, animation systems + - Clone this if you want to contribute to the engine core + +- **[Untold Editor](https://github.com/untoldengine/UntoldEditor)** (This repo) + - Editor interface and tooling + - Clone this if you want to contribute to the editor + +### Additional Resources: +- **Examples / Starter Projects:** [UntoldEngineExamples](https://github.com/untoldengine/UntoldEngineExamples) + +--- + ## โœจ Features - **Scene Graph** โ€“ Organize and visualize entity hierarchies @@ -27,9 +66,11 @@ The editor is not required to use the engine, but it makes iteration faster by g --- -## ๐Ÿ“ฆ Getting the Editor +## ๐Ÿ“ฆ Development Setup + +**Note:** If you just want to make games, download [Untold Engine Studio](https://github.com/untoldengine/UntoldEditor/releases) instead. -Clone this repository: +For editor development, clone this repository: ```bash git clone https://github.com/untoldengine/UntoldEditor.git @@ -115,13 +156,6 @@ See **CONTRIBUTING.md** and **CODE_OF_CONDUCT.md** (coming soon). --- -## ๐Ÿ“š Related Repos - -- **Engine Core:** [Untold Engine](https://github.com/untoldengine/UntoldEngine) -- **Examples / Starter Projects:** [UntoldEngineExamples](https://github.com/untoldengine/UntoldEngineExamples) - ---- - ## ๐Ÿ“œ License Licensed under **GNU LGPL v3.0**. diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..8d17567 Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Sources/UntoldEditor/Build/BuildSettingsView.swift b/Sources/UntoldEditor/Build/BuildSettingsView.swift index a61d24c..475e642 100644 --- a/Sources/UntoldEditor/Build/BuildSettingsView.swift +++ b/Sources/UntoldEditor/Build/BuildSettingsView.swift @@ -25,6 +25,7 @@ struct BuildSettingsView: View { @State private var showBuildResult: Bool = false @State private var buildResultMessage: String = "" @State private var buildSucceeded: Bool = false + @State private var showBasePathAlert: Bool = false @Environment(\.dismiss) private var dismiss @@ -122,6 +123,7 @@ struct BuildSettingsView: View { HStack { Spacer() Button("Build") { + guard ensureAssetBasePath() else { return } startBuild() } .buttonStyle(.borderedProminent) @@ -146,6 +148,11 @@ struct BuildSettingsView: View { } message: { Text(buildResultMessage) } + .alert("Set Asset Folder First", isPresented: $showBasePathAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Please set the Asset Folder in the Asset Browser before building.") + } .onAppear { loadDefaultSettings() } @@ -260,6 +267,14 @@ struct BuildSettingsView: View { NSWorkspace.shared.open(xcodeProjectPath) } + + private func ensureAssetBasePath() -> Bool { + guard EditorAssetBasePath.shared.basePath != nil else { + showBasePathAlert = true + return false + } + return true + } } #Preview { diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 41d9011..5d08e9e 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -12,12 +12,12 @@ import UntoldEngine enum AssetCategory: String, CaseIterable { case models = "Models" - case materials = "Materials" - case hdr = "HDR" case animations = "Animations" - case gaussians = "Gaussians" - case scenes = "Scenes" case scripts = "Scripts" + case scenes = "Scenes" + case gaussians = "Gaussians" + case materials = "Materials" + case hdr = "HDR" var iconName: String { switch self { @@ -46,7 +46,18 @@ struct AssetBrowserView: View { @State private var selectedAssetName: String? @ObservedObject var editorBaseAssetPath = EditorAssetBasePath.shared @ObservedObject var selectionManager: SelectionManager + @ObservedObject var sceneGraphModel: SceneGraphModel @State private var folderPathStack: [URL] = [] + @State private var showSceneLoadConfirmation = false + @State private var pendingSceneToLoad: URL? + @State private var showDeleteConfirmation = false + @State private var pendingDeleteAsset: Asset? + @State private var showBasePathAlert = false + @State private var searchQuery: String = "" + @State private var statusMessage: String? + @State private var statusIsError = false + @State private var compactMode = false + @State private var targetEntityName: String = "None" var editor_addEntityWithAsset: () -> Void private var currentFolderPath: URL? { folderPathStack.last @@ -63,7 +74,7 @@ struct AssetBrowserView: View { Text("Assets") .font(.title3) .bold() - .foregroundColor(.primary) + .foregroundColor(.white) Button(action: importAsset) { HStack(spacing: 6) { @@ -73,12 +84,28 @@ struct AssetBrowserView: View { } .padding(.vertical, 6) .padding(.horizontal, 12) - .background(Color.gray) + .background(Color.editorAccent) + .foregroundColor(.black.opacity(0.9)) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: promptDeleteAsset) { + HStack(spacing: 6) { + Text("Delete") + Image(systemName: "trash") + .foregroundColor(.white) + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(selectedAsset == nil ? Color.gray.opacity(0.5) : Color.red) .foregroundColor(.white) .cornerRadius(8) .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) } .buttonStyle(PlainButtonStyle()) + .disabled(selectedAsset == nil) Spacer() @@ -92,7 +119,7 @@ struct AssetBrowserView: View { } .padding(.vertical, 6) .padding(.horizontal, 12) - .background(Color.gray) + .background(Color.editorSurface) .foregroundColor(.white) .cornerRadius(8) .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) @@ -101,7 +128,7 @@ struct AssetBrowserView: View { } .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorPanelBackground.opacity(0.9)) .cornerRadius(8) // MARK: - Path Indicator @@ -109,7 +136,7 @@ struct AssetBrowserView: View { if let resourceDir = editorBaseAssetPath.basePath { Text("Current Path: \(resourceDir.lastPathComponent)") .font(.caption) - .foregroundColor(.green) + .foregroundColor(Color.editorAccent) .padding(.horizontal, 10) .padding(.bottom, 5) } else { @@ -120,24 +147,58 @@ struct AssetBrowserView: View { .padding(.bottom, 5) } + HStack { + Text("Target Entity:") + .font(.caption) + .foregroundColor(.secondary) + Text(targetEntityName) + .font(.caption) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 10) + .padding(.bottom, 2) + + // MARK: - Search + + HStack { + Image(systemName: "magnifyingglass") + TextField("Filter assets", text: $searchQuery) + .textFieldStyle(RoundedBorderTextFieldStyle()) + Spacer() + Toggle("Compact", isOn: $compactMode) + .toggleStyle(.switch) + .labelsHidden() + .help("Toggle compact list spacing") + } + .padding(.horizontal, 10) + .padding(.bottom, 4) + // MARK: - Sidebar and Asset List Layout HStack(spacing: 8) { // MARK: - Sidebar ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: compactMode ? 4 : 8) { + Text("Categories") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.bottom, 2) + ForEach(AssetCategory.allCases, id: \.self) { category in HStack { Image(systemName: selectedCategory == category.rawValue ? "folder.fill" : "folder") - .foregroundColor(selectedCategory == category.rawValue ? .blue : .gray) + .foregroundColor(selectedCategory == category.rawValue ? Color.editorAccent : .gray) Text(category.rawValue) .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundColor(selectedCategory == category.rawValue ? .blue : .primary) + .foregroundColor(selectedCategory == category.rawValue ? .white : .primary) } - .padding(.vertical, 6) + .padding(.vertical, compactMode ? 4 : 6) .padding(.horizontal, 8) - .background(selectedCategory == category.rawValue ? Color.blue.opacity(0.1) : Color.clear) + .background(selectedCategory == category.rawValue ? Color.editorAccentSoft : Color.clear) .cornerRadius(6) .onTapGesture { // If reselecting the same category, force a reload @@ -155,14 +216,14 @@ struct AssetBrowserView: View { .padding(8) } - .frame(width: 120) + .frame(width: compactMode ? 110 : 140) .background(Color.secondary.opacity(0.05)) .cornerRadius(8) // MARK: - Asset List ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: compactMode ? 6 : 10) { if let selectedCategory { // Scripts: flat, no breadcrumbs or folders let isScripts = (selectedCategory == AssetCategory.scripts.rawValue) @@ -195,11 +256,11 @@ struct AssetBrowserView: View { folderContentsView(for: currentFolderPath, selectionManager: selectionManager) } else { if let categoryAssets = assets[selectedCategory] { - ForEach(categoryAssets) { asset in + ForEach(categoryAssets.filter { matchesSearch($0) }) { asset in // For Scripts, we never navigate into folders (we won't list folders anyway) assetRow(asset) .onTapGesture(count: 2) { - // Optional double-click behavior can go here. + handle_add_model_double_click(asset: asset) } .onTapGesture(count: 1) { if asset.isFolder { @@ -223,15 +284,22 @@ struct AssetBrowserView: View { .padding(.horizontal, 8) } .frame(maxHeight: 300) - .background(Color.secondary.opacity(0.05)) + .background(Color.editorSurface.opacity(0.7)) .cornerRadius(8) } .frame(maxHeight: 300) } .padding(10) } - .frame(maxHeight: 200) - .onAppear(perform: loadAssets) + .frame(maxHeight: .infinity) + .onAppear { + loadAssets() + updateTargetEntityName(for: selectionManager.selectedEntity) + } + // Keep the target label in sync with editor selection changes. + .onReceive(selectionManager.$selectedEntity) { entityId in + updateTargetEntityName(for: entityId) + } .onChange(of: editorBaseAssetPath.basePath) { loadAssets() } @@ -243,6 +311,52 @@ struct AssetBrowserView: View { .onReceive(NotificationCenter.default.publisher(for: .assetBrowserReload)) { _ in loadAssets() } + .alert("Load Scene?", isPresented: $showSceneLoadConfirmation) { + Button("Cancel", role: .cancel) { + pendingSceneToLoad = nil + } + Button("Load Scene", role: .destructive) { + if let sceneURL = pendingSceneToLoad { + loadScene(from: sceneURL) + } + pendingSceneToLoad = nil + } + } message: { + Text("Loading a new scene will replace the current scene. Any unsaved changes will be lost.") + } + .alert("Set Asset Folder First", isPresented: $showBasePathAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Please set the Asset Folder in the Asset Browser before importing assets.") + } + .alert("Delete Asset?", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) { + pendingDeleteAsset = nil + } + Button("Delete", role: .destructive) { + if let asset = pendingDeleteAsset { + deleteAsset(asset) + } + pendingDeleteAsset = nil + } + } message: { + if let asset = pendingDeleteAsset { + Text("This will remove \(asset.name) from disk under your Asset Folder.") + } + } + .overlay(alignment: .bottom) { + if let statusMessage { + Text(statusMessage) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(statusIsError ? Color.red.opacity(0.85) : Color.green.opacity(0.85)) + .cornerRadius(8) + .padding(.bottom, 8) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } } // MARK: - Select Resource Directory @@ -272,6 +386,11 @@ struct AssetBrowserView: View { } private func importAsset() { + guard editorBaseAssetPath.basePath != nil else { + showBasePathAlert = true + return + } + let openPanel = NSOpenPanel() openPanel.allowedContentTypes = [ UTType(filenameExtension: "usdz")!, @@ -367,12 +486,20 @@ struct AssetBrowserView: View { } loadAssets() + showStatus("Imported \(openPanel.urls.count) item(s)") } } + private func promptDeleteAsset() { + guard let asset = selectedAsset else { return } + pendingDeleteAsset = asset + showDeleteConfirmation = true + } + // MARK: - Load Assets private func loadAssets() { + guard editorBaseAssetPath.basePath != nil else { return } guard let basePath = assetBasePath else { return } var groupedAssets: [String: [Asset]] = [:] @@ -383,7 +510,7 @@ struct AssetBrowserView: View { if category == .scripts { // Flat list of .uscript files anywhere under Scripts - let uscriptURLs = findUScriptFilesRecursively(at: categoryPath) + let uscriptURLs = findFilesRecursively(at: categoryPath, withExtension: "uscript") for url in uscriptURLs { categoryAssets.append( Asset(name: url.lastPathComponent, @@ -398,6 +525,23 @@ struct AssetBrowserView: View { continue } + // Flat list for Models and Animations - show .usdz files directly + if category == .models || category == .animations { + let usdzURLs = findFilesRecursively(at: categoryPath, withExtension: "usdz") + for url in usdzURLs { + categoryAssets.append( + Asset(name: url.lastPathComponent, + category: category.rawValue, + path: url, + isFolder: false) + ) + } + // Sort for stable UI + categoryAssets.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + groupedAssets[category.rawValue] = categoryAssets + continue + } + // Non-Scripts categories: list immediate children, with folder navigation support if let contents = try? FileManager.default.contentsOfDirectory( at: categoryPath, @@ -446,8 +590,25 @@ struct AssetBrowserView: View { assets = groupedAssets } - // Recursively find all .uscript files under a root directory - private func findUScriptFilesRecursively(at root: URL) -> [URL] { + private func matchesSearch(_ asset: Asset) -> Bool { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.isEmpty == false else { return true } + return asset.name.localizedCaseInsensitiveContains(query) + } + + private func showStatus(_ message: String, isError: Bool = false) { + statusMessage = message + statusIsError = isError + + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if statusMessage == message { + statusMessage = nil + } + } + } + + // Recursively find all files with a specific extension under a root directory + private func findFilesRecursively(at root: URL, withExtension ext: String) -> [URL] { var results: [URL] = [] let fm = FileManager.default @@ -456,7 +617,7 @@ struct AssetBrowserView: View { } for case let url as URL in enumerator { - if url.pathExtension.lowercased() == "uscript" { + if url.pathExtension.lowercased() == ext.lowercased() { results.append(url) } } @@ -502,10 +663,10 @@ struct AssetBrowserView: View { } VStack(alignment: .leading, spacing: 8) { - ForEach(items) { asset in + ForEach(items.filter { matchesSearch($0) }) { asset in assetRow(asset) .onTapGesture(count: 2) { - // Intentionally left blank for now + handle_add_model_double_click(asset: asset) } .onTapGesture(count: 1) { if asset.isFolder { @@ -531,5 +692,230 @@ struct AssetBrowserView: View { private func selectAsset(_ asset: Asset) { selectedAsset = asset selectedAssetName = asset.name + updateTargetEntityName(for: selectionManager.selectedEntity) + } + + // MARK: - Delete Asset + + private func deleteAsset(_ asset: Asset) { + guard let basePath = assetBasePath else { + showBasePathAlert = true + return + } + + // Ensure the asset lives under the base path before deleting + guard asset.path.resolvingSymlinksInPath().path.hasPrefix(basePath.resolvingSymlinksInPath().path) else { + print("โš ๏ธ Refusing to delete asset outside base path: \(asset.path.path)") + showStatus("Cannot delete outside Asset Folder", isError: true) + return + } + + do { + try FileManager.default.removeItem(at: asset.path) + print("โœ… Deleted asset: \(asset.name)") + if selectedAsset?.id == asset.id { + selectedAsset = nil + selectedAssetName = nil + } + loadAssets() + showStatus("Deleted \(asset.name)") + } catch { + print("โŒ Failed to delete asset \(asset.name): \(error)") + showStatus("Failed to delete \(asset.name)", isError: true) + } + } + + // MARK: - Add Model with Double Click + + private func handle_add_model_double_click(asset: Asset) { + // Don't handle folders + guard !asset.isFolder else { return } + + let filename = asset.path.deletingPathExtension().lastPathComponent + let withExtension = asset.path.pathExtension + + // Handle model files (usdz) + if asset.category == AssetCategory.models.rawValue, + withExtension.lowercased() == "usdz" + { + // Create entity + let entityId = createEntity() + + // Use a generated name to avoid duplicate names when importing repeatedly + let uniqueName = generateEntityName() + setEntityName(entityId: entityId, name: uniqueName) + + // Add mesh to entity + setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension) + + // Refresh the scene hierarchy to show the new entity + sceneGraphModel.refreshHierarchy() + + // Select the newly created entity in the editor + selectionManager.selectedEntity = entityId + + showStatus("Added model \(uniqueName)") + } + // Handle Gaussian files (ply) + else if asset.category == AssetCategory.gaussians.rawValue, + withExtension.lowercased() == "ply" + { + // Create entity + let entityId = createEntity() + + // Use a generated name to avoid duplicate names when importing repeatedly + let uniqueName = generateEntityName() + setEntityName(entityId: entityId, name: uniqueName) + + // Add Gaussian component to entity + setEntityGaussian(entityId: entityId, filename: filename, withExtension: withExtension) + + // Refresh the scene hierarchy to show the new entity + sceneGraphModel.refreshHierarchy() + + // Select the newly created entity in the editor + selectionManager.selectedEntity = entityId + + showStatus("Added Gaussian \(uniqueName)") + } + // Handle Animation files (usdz in Animations category) + else if asset.category == AssetCategory.animations.rawValue, + withExtension.lowercased() == "usdz" + { + // Animations require a selected entity to work with + guard let entityId = selectionManager.selectedEntity, + entityId != .invalid + else { + print("โš ๏ธ Please select an entity first to add animation") + showStatus("Select an entity before adding animation", isError: true) + return + } + + // Add AnimationComponent if not already present + if !hasComponent(entityId: entityId, componentType: AnimationComponent.self) { + registerComponent(entityId: entityId, componentType: AnimationComponent.self) + } + + // Add the animation to the entity + setEntityAnimations(entityId: entityId, filename: filename, withExtension: withExtension, name: filename) + + // Store the animation file URL in the component + if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { + animationComponent.animationsFilenames.append(asset.path) + } + + // Refresh view + selectionManager.objectWillChange.send() + showStatus("Animation linked to \(targetEntityName)", isError: false) + } + // Handle Script files (uscript) + else if asset.category == AssetCategory.scripts.rawValue, + withExtension.lowercased() == "uscript" + { + // Scripts require a selected entity to work with + guard let entityId = selectionManager.selectedEntity, + entityId != .invalid + else { + print("โš ๏ธ Please select an entity first to add script") + showStatus("Select an entity before adding script", isError: true) + return + } + + // Get or create ScriptComponent + let scriptComponent: ScriptComponent + if let existing = scene.get(component: ScriptComponent.self, for: entityId) { + scriptComponent = existing + } else { + guard let newComp = scene.assign(to: entityId, component: ScriptComponent.self) else { + print("โŒ Failed to create ScriptComponent") + return + } + scriptComponent = newComp + } + + // Load and append the script + do { + let jsonData = try Data(contentsOf: asset.path) + let decoder = JSONDecoder() + let loadedScript = try decoder.decode(USCScript.self, from: jsonData) + + // Append script and its path + scriptComponent.scripts.append(loadedScript) + if scriptComponent.scriptFilePaths == nil { + scriptComponent.scriptFilePaths = [] + } + scriptComponent.scriptFilePaths?.append(asset.path.path) + + print("โœ… Script added: \(loadedScript.name)") + + // Refresh view + selectionManager.objectWillChange.send() + showStatus("Script linked to \(targetEntityName)", isError: false) + } catch { + print("โŒ Failed to load script: \(error.localizedDescription)") + } + } + // Handle Scene files (json) + else if asset.category == AssetCategory.scenes.rawValue, + withExtension.lowercased() == "json" + { + // Show confirmation dialog before loading scene + pendingSceneToLoad = asset.path + showSceneLoadConfirmation = true + editorController?.currentSceneURL = asset.path + } + // Handle HDR files (hdr) + else if asset.category == AssetCategory.hdr.rawValue, + withExtension.lowercased() == "hdr" + { + // Load HDR as environment IBL + let filename = asset.path.lastPathComponent + let directoryURL = asset.path.deletingLastPathComponent() + generateHDR(filename, from: directoryURL) + + print("โœ… HDR environment loaded: \(filename)") + showStatus("HDR loaded: \(filename)") + } + } + + private func updateTargetEntityName(for entityId: EntityID?) { + guard let entityId, entityId != .invalid else { + targetEntityName = "None" + return + } + let name = getEntityName(entityId: entityId) + targetEntityName = name.isEmpty ? "Entity \(entityId)" : name + } + + // MARK: - Load Scene Helper + + private func loadScene(from url: URL) { + guard let sceneData = loadGameScene(from: url) else { + print("โŒ Failed to load scene from \(url.lastPathComponent)") + return + } + + // Clear current scene + destroyAllEntities() + removeGizmo() + EditorComponentsState.shared.clear() + + // Load new scene + deserializeScene(sceneData: sceneData) + + // Reset editor state + selectionManager.selectedEntity = nil + activeEntity = .invalid + gizmoActive = false + + // Refresh UI + selectionManager.objectWillChange.send() + sceneGraphModel.refreshHierarchy() + + // Reset camera + CameraSystem.shared.activeCamera = findSceneCamera() + + editorController?.currentSceneURL = url + print("โœ… Scene loaded: \(url.lastPathComponent)") } } diff --git a/Sources/UntoldEditor/Editor/AssetWindowDelegate.swift b/Sources/UntoldEditor/Editor/AssetWindowDelegate.swift new file mode 100644 index 0000000..ff81547 --- /dev/null +++ b/Sources/UntoldEditor/Editor/AssetWindowDelegate.swift @@ -0,0 +1,13 @@ +import AppKit + +final class AssetWindowDelegate: NSObject, NSWindowDelegate { + private let onClose: () -> Void + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + } + + func windowWillClose(_: Notification) { + onClose() + } +} diff --git a/Sources/UntoldEditor/Editor/EditorController.swift b/Sources/UntoldEditor/Editor/EditorController.swift index 51ad421..f3f3c2b 100644 --- a/Sources/UntoldEditor/Editor/EditorController.swift +++ b/Sources/UntoldEditor/Editor/EditorController.swift @@ -65,6 +65,7 @@ class EditorController: SelectionDelegate, ObservableObject { var isEnabled: Bool = false @Published var activeMode: TransformManipulationMode = .none @Published var activeAxis: TransformAxis = .none + @Published var currentSceneURL: URL? init(selectionManager: SelectionManager) { self.selectionManager = selectionManager @@ -157,6 +158,49 @@ func saveScene(sceneData: SceneData) { } } +/// Save directly to a URL (used by the inline save flow). +func saveSceneDirect(sceneData: SceneData, to url: URL) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(sceneData) + try jsonData.write(to: url) + Logger.log(message: "Scene saved to \(url.path)") + + guard let basePath = assetBasePath else { + Logger.log(message: "Warning: assetBasePath is not set; cannot copy scene into Scenes folder.") + NotificationCenter.default.post(name: .assetBrowserReload, object: nil) + return + } + + let fm = FileManager.default + let scenesRoot = basePath.appendingPathComponent("Scenes", isDirectory: true) + try? fm.createDirectory(at: scenesRoot, withIntermediateDirectories: true) + + let resolvedSaveURL = url.resolvingSymlinksInPath() + let resolvedScenesRoot = scenesRoot.resolvingSymlinksInPath() + let saveParent = resolvedSaveURL.deletingLastPathComponent() + + let isAlreadyInScenes = (saveParent == resolvedScenesRoot) + let destURL = resolvedScenesRoot.appendingPathComponent(resolvedSaveURL.lastPathComponent) + + if isAlreadyInScenes { + Logger.log(message: "Scene already in Scenes folder: \(destURL.path)") + } else { + if fm.fileExists(atPath: destURL.path) { + try fm.removeItem(at: destURL) + } + try fm.copyItem(at: resolvedSaveURL, to: destURL) + Logger.log(message: "Scene copied into Scenes folder: \(destURL.path)") + } + + NotificationCenter.default.post(name: .assetBrowserReload, object: nil) + } catch { + Logger.log(message: "Failed to save scene: \(error)") + } +} + func loadGameScene() -> SceneData? { let openPanel = NSOpenPanel() openPanel.title = "Open Scene" diff --git a/Sources/UntoldEditor/Editor/EditorScheme.swift b/Sources/UntoldEditor/Editor/EditorScheme.swift index 4f40205..54b9794 100644 --- a/Sources/UntoldEditor/Editor/EditorScheme.swift +++ b/Sources/UntoldEditor/Editor/EditorScheme.swift @@ -9,5 +9,11 @@ import SwiftUI extension Color { - static let editorBackground = Color(red: 52 / 255, green: 64 / 255, blue: 68 / 255) + static let editorBackground = Color(red: 0.11, green: 0.12, blue: 0.14) // deep charcoal + static let editorPanelBackground = Color(red: 0.16, green: 0.18, blue: 0.21) // muted slate + static let editorSurface = Color(red: 0.20, green: 0.22, blue: 0.25) + static let editorAccent = Color(red: 0.17, green: 0.59, blue: 0.78) // teal/blue accent + static let editorAccentSoft = Color(red: 0.17, green: 0.59, blue: 0.78, opacity: 0.14) + static let editorSecondaryAccent = Color(red: 0.90, green: 0.64, blue: 0.24) // warm amber secondary + static let editorDivider = Color.white.opacity(0.06) } diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index 1619df5..15992b3 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -11,12 +11,28 @@ public struct Asset: Identifiable { } public struct EditorView: View { + enum InspectorTab: String, CaseIterable, Hashable { + case inspector = "Inspector" + case environment = "Environment" + case effects = "Effects" + } + @State private var editor_entities: [EntityID] = getAllGameEntities() @StateObject private var selectionManager = SelectionManager() @StateObject private var sceneGraphModel = SceneGraphModel() @State private var assets: [String: [Asset]] = [:] @State private var selectedAsset: Asset? = nil @State private var isPlaying = false + @State private var inspectorTab: InspectorTab = .inspector + @State private var showSaveNamePrompt = false + @State private var pendingSceneName: String = "untitled" + @State private var showOverwriteAlert = false + @State private var pendingTargetURL: URL? + @State private var isSaveAs = false + @State private var showSaveBasePathAlert = false + @State private var showAssetLibrary = false + @State private var assetWindow: NSWindow? + @State private var assetWindowDelegate: AssetWindowDelegate? var renderer: UntoldRenderer? @@ -40,18 +56,68 @@ public struct EditorView: View { public var body: some View { VStack { ToolbarView( - selectionManager: selectionManager, onSave: editor_handleSave, - onLoad: editor_handleLoad, onClear: editor_clearScene, - onPlayToggled: { isPlaying in editor_handlePlayToggle(isPlaying) }, + selectionManager: selectionManager, + onSave: editor_handleSave, + onSaveAs: editor_handleSaveAs, + onClear: editor_clearScene, + onPlayToggled: { isPlaying in + editor_handlePlayToggle(isPlaying) + }, + onShowAssets: { + openAssetWindow() + }, dirLightCreate: editor_createDirLight, pointLightCreate: editor_createPointLight, spotLightCreate: editor_createSpotLight, - areaLightCreate: editor_createAreaLight + areaLightCreate: editor_createAreaLight, + onCreateCube: editor_createCube, + onCreateSphere: editor_createSphere, + onCreatePlane: editor_createPlane, + onCreateCylinder: editor_createCylinder, + onCreateCone: editor_createCone ) + .popover(isPresented: $showAssetLibrary, arrowEdge: .bottom) { + VStack(spacing: 0) { + HStack { + Text("Assets Library") + .font(.headline) + Spacer() + Button("Close") { showAssetLibrary = false } + .keyboardShortcut(.cancelAction) + } + .padding() + .background(Color.editorPanelBackground.opacity(0.9)) + + Divider() + + AssetBrowserView( + assets: $assets, + selectedAsset: $selectedAsset, + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + editor_addEntityWithAsset: editor_addEntityWithAsset + ) + .frame(minWidth: 700, minHeight: 500) + } + .frame(minWidth: 720, minHeight: 540) + } Divider() HStack { VStack { - SceneHierarchyView(selectionManager: selectionManager, sceneGraphModel: sceneGraphModel, entityList: editor_entities, onAddEntity_Editor: editor_addNewEntity, onRemoveEntity_Editor: editor_removeEntity) + SceneHierarchyView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + entityList: editor_entities, + onAddEntity_Editor: editor_addNewEntity, + onRemoveEntity_Editor: editor_removeEntity, + onAddCube: editor_createCube, + onAddSphere: editor_createSphere, + onAddPlane: editor_createPlane, + onAddDirLight: editor_createDirLight, + onAddPointLight: editor_createPointLight, + onAddSpotLight: editor_createSpotLight, + onAddAreaLight: editor_createAreaLight + ) } VStack(spacing: 0) { @@ -59,52 +125,215 @@ public struct EditorView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) TransformManipulationToolbar(controller: editorController!) .frame(height: 40) - HStack(spacing: 0) { - AssetBrowserView( - assets: $assets, - selectedAsset: $selectedAsset, - selectionManager: selectionManager, - editor_addEntityWithAsset: editor_addEntityWithAsset - ) - .frame(width: 400) - // .tabItem { Label("Assets", systemImage: "shippingbox") } - Divider() - LogConsoleView() - .tabItem { Label("Console", systemImage: "terminal") } - } - .frame(height: 200) - // .clipped() + LogConsoleView() + .frame(height: 260) } - TabView { - EnvironmentView(selectedAsset: $selectedAsset) - .tabItem { - Label("Environment", systemImage: "sun.max") - } - - PostProcessingEditorView() - .tabItem { - Label("Effects", systemImage: "cube") + VStack(spacing: 8) { + Picker("", selection: $inspectorTab) { + ForEach(InspectorTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) } - - InspectorView(selectionManager: selectionManager, sceneGraphModel: sceneGraphModel, onAddName_Editor: editor_addName, selectedAsset: $selectedAsset) - .tabItem { - Label("Inspector", systemImage: "cube") + } + .pickerStyle(.segmented) + .padding(.horizontal, 4) + + Divider() + + Group { + switch inspectorTab { + case .inspector: + InspectorView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + onAddName_Editor: editor_addName, + selectedAsset: $selectedAsset + ) + case .environment: + ScrollView { EnvironmentView(selectedAsset: $selectedAsset) } + case .effects: + ScrollView { PostProcessingEditorView() } } + } } - .frame(minWidth: 200, maxWidth: 250) + .onChange(of: selectionManager.selectedEntity) { _, newValue in + if let entity = newValue, entity != .invalid { + inspectorTab = .inspector + } + } + .frame(minWidth: 216, maxWidth: 288) } } .background( - Color.editorBackground.ignoresSafeArea()) + LinearGradient( + colors: [Color.editorBackground, Color.editorPanelBackground.opacity(0.95)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea()) .onAppear { sceneGraphModel.refreshHierarchy() } + .sheet(isPresented: $showSaveNamePrompt) { + VStack(spacing: 12) { + Text("Save Scene") + .font(.headline) + Text("Scenes are saved to the Scenes folder in your Asset Folder.") + .font(.caption) + .foregroundColor(.secondary) + + TextField("Scene name", text: $pendingSceneName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onSubmit { confirmSaveSceneName() } + + HStack { + Button("Cancel") { showSaveNamePrompt = false } + Spacer() + Button("Save") { confirmSaveSceneName() } + .keyboardShortcut(.defaultAction) + .disabled(pendingSceneName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding() + .frame(width: 320) + } + .alert("Overwrite Scene?", isPresented: $showOverwriteAlert) { + Button("Cancel", role: .cancel) { + showSaveNamePrompt = false + } + Button("Overwrite", role: .destructive) { + finalizeSceneSave(targetURL: pendingTargetURL, overwrite: true) + } + } message: { + Text("A scene with that name already exists. Overwrite it?") + } + .alert("Set Asset Folder First", isPresented: $showSaveBasePathAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Please set the Asset Folder in the Asset Browser before saving scenes.") + } } private func editor_handleSave() { + guard assetBasePath != nil else { + showSaveBasePathAlert = true + return + } + // If we have a current scene path, save immediately + if let sceneURL = editorController?.currentSceneURL { + let sceneData: SceneData = serializeScene() + saveSceneDirect(sceneData: sceneData, to: sceneURL) + return + } + + // Otherwise prompt for a name + isSaveAs = false + pendingSceneName = "untitled" + showSaveNamePrompt = true + } + + private func editor_handleSaveAs() { + guard assetBasePath != nil else { + showSaveBasePathAlert = true + return + } + isSaveAs = true + if let current = editorController?.currentSceneURL { + pendingSceneName = current.deletingPathExtension().lastPathComponent + } else { + pendingSceneName = "untitled" + } + showSaveNamePrompt = true + } + + private func confirmSaveSceneName() { + let name = pendingSceneName.trimmingCharacters(in: .whitespacesAndNewlines) + guard name.isEmpty == false else { return } + + guard let basePath = assetBasePath else { + showSaveNamePrompt = false + print("โŒ Cannot save scene: Asset Folder not set.") + return + } + + let scenesFolder = basePath.appendingPathComponent("Scenes", isDirectory: true) + try? FileManager.default.createDirectory(at: scenesFolder, withIntermediateDirectories: true) + + let targetURL = scenesFolder.appendingPathComponent(name).appendingPathExtension("json") + pendingTargetURL = targetURL + + if FileManager.default.fileExists(atPath: targetURL.path) { + showOverwriteAlert = true + return + } + + finalizeSceneSave(targetURL: targetURL, overwrite: false) + } + + private func finalizeSceneSave(targetURL: URL? = nil, overwrite: Bool = false) { let sceneData: SceneData = serializeScene() - saveScene(sceneData: sceneData) + + let destinationURL: URL + if let targetURL { + destinationURL = targetURL + } else if let existing = editorController?.currentSceneURL { + destinationURL = existing + } else { + showSaveNamePrompt = true + return + } + + if FileManager.default.fileExists(atPath: destinationURL.path), !overwrite { + showOverwriteAlert = true + return + } + + saveSceneDirect(sceneData: sceneData, to: destinationURL) + editorController?.currentSceneURL = destinationURL + showSaveNamePrompt = false + showOverwriteAlert = false + isSaveAs = false + } + + // MARK: - Asset Library Window + + private func openAssetWindow() { + if let existing = assetWindow { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let content = AssetBrowserView( + assets: $assets, + selectedAsset: $selectedAsset, + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + editor_addEntityWithAsset: editor_addEntityWithAsset + ) + + let hosting = NSHostingController(rootView: content) + let window = NSWindow(contentViewController: hosting) + window.title = "Assets Library" + window.styleMask = [.titled, .closable, .resizable, .miniaturizable] + window.setContentSize(NSSize(width: 760, height: 520)) + window.minSize = NSSize(width: 620, height: 420) + window.level = .floating // keep above editor while arranging assets + window.isReleasedWhenClosed = false + window.alphaValue = 1.0 + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + let delegate = AssetWindowDelegate { + // Window is closing โ€” clear our references + assetWindow = nil + assetWindowDelegate = nil + } + window.delegate = delegate + assetWindowDelegate = delegate + + assetWindow = window } private func editor_handleLoad() { @@ -127,6 +356,7 @@ public struct EditorView: View { removeGizmo() EditorComponentsState.shared.clear() deserializeScene(sceneData: sceneData) + editorController?.currentSceneURL = nil editor_entities = getAllGameEntities() selectionManager.selectedEntity = nil activeEntity = .invalid @@ -243,7 +473,7 @@ public struct EditorView: View { self.isPlaying = isPlaying gameMode = !gameMode // For now, during "play" mode, the camera will keep being the scene camera - // CameraSystem.shared.activeCamera = gameMode ? findGameCamera() : findSceneCamera() + CameraSystem.shared.activeCamera = gameMode ? findGameCamera() : findSceneCamera() AnimationSystem.shared.isEnabled = isPlaying // Start/stop USC System @@ -328,4 +558,59 @@ public struct EditorView: View { translateTo(entityId: selectionManager.selectedEntity!, position: spawnPosition) } + + // MARK: - Primitive Creation Functions + + private func editor_createPrimitive(name: String, meshes: [Mesh]) { + removeGizmo() + + let entityId = createEntity() + // Append entity ID to make the name unique + let uniqueName = "\(name)-\(entityId)" + setEntityName(entityId: entityId, name: uniqueName) + + // Use setEntityMeshDirect which follows the same pattern as setEntityMesh + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: name) + + // Spawn in front of camera + guard let camera = CameraSystem.shared.activeCamera, let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + handleError(.noActiveCamera) + return + } + + var forward = forwardDirectionVector(from: cameraComponent.rotation) + forward *= -1.0 + let camPosition = cameraComponent.localPosition + let spawnPosition = camPosition + forward * spawnDistance + translateTo(entityId: entityId, position: simd_float3(0.0, 0.0, 0.0)) + + selectionManager.selectedEntity = entityId + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + } + + private func editor_createCube() { + let meshes = BasicPrimitives.createCube() + editor_createPrimitive(name: "Cube", meshes: meshes) + } + + private func editor_createSphere() { + let meshes = BasicPrimitives.createSphere() + editor_createPrimitive(name: "Sphere", meshes: meshes) + } + + private func editor_createPlane() { + let meshes = BasicPrimitives.createPlane() + editor_createPrimitive(name: "Plane", meshes: meshes) + } + + private func editor_createCylinder() { + let meshes = BasicPrimitives.createCylinder() + editor_createPrimitive(name: "Cylinder", meshes: meshes) + } + + private func editor_createCone() { + let meshes = BasicPrimitives.createCone() + editor_createPrimitive(name: "Cone", meshes: meshes) + } } diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 39310a3..9712076 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -463,9 +463,8 @@ struct RenderingEditorView: View { HStack(alignment: .top, spacing: 24) { ForEach(TextureType.allCases) { type in let image: NSImage? = { - if let url = getMaterialTextureURL(entityId: entityId, type: type), - let img = NSImage(contentsOf: url) - { + // Use the helper function that handles both regular and embedded textures + if let img = getMaterialTextureImage(entityId: entityId, type: type) { return img } else { return NSImage(named: "Default Texture") @@ -494,15 +493,31 @@ struct RenderingEditorView: View { } .buttonStyle(PlainButtonStyle()) - // Remove texture - Button(action: { - removeMaterialTexture(entityId: entityId, textureType: type) - refreshView() - }) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) + // Remove or Restore texture buttons + HStack(spacing: 8) { + // Remove texture button + Button(action: { + removeMaterialTexture(entityId: entityId, textureType: type) + refreshView() + }) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + .buttonStyle(BorderlessButtonStyle()) + + // Restore button (only show if there's an embedded texture to restore) + if canRestoreEmbeddedTexture(entityId: entityId, type: type) { + Button(action: { + restoreEmbeddedTexture(entityId: entityId, textureType: type) + refreshView() + }) { + Image(systemName: "arrow.counterclockwise.circle.fill") + .foregroundColor(.blue) + } + .buttonStyle(BorderlessButtonStyle()) + .help("Restore original embedded texture") + } } - .buttonStyle(BorderlessButtonStyle()) // Texture name Text(type.displayName) diff --git a/Sources/UntoldEditor/Editor/LogConsoleView.swift b/Sources/UntoldEditor/Editor/LogConsoleView.swift index 8bf6510..f9c49dd 100644 --- a/Sources/UntoldEditor/Editor/LogConsoleView.swift +++ b/Sources/UntoldEditor/Editor/LogConsoleView.swift @@ -16,6 +16,7 @@ struct LogConsoleView: View { @State private var selectedLevel: LogLevel? = nil @State private var search = "" @State private var autoScroll = true + @State private var clearLog = false private func passes(_ e: LogEvent) -> Bool { (selectedLevel == nil || e.level == selectedLevel!) && @@ -31,6 +32,18 @@ struct LogConsoleView: View { .font(.title3) .bold() .foregroundColor(.primary) +// Spacer().frame(width: 16) +// Picker("Level", selection: $selectedLevel) { +// Text("All").tag(LogLevel?.none) +// Text("Error").tag(LogLevel?.some(.error)) +// Text("Warning").tag(LogLevel?.some(.warning)) +// Text("Info").tag(LogLevel?.some(.info)) +// Text("Debug").tag(LogLevel?.some(.debug)) +// Text("Test").tag(LogLevel?.some(.test)) +// } +// .pickerStyle(.segmented) +// .frame(width: 360) +// .accentColor(.gray) Spacer() TextField("Searchโ€ฆ", text: $search) .textFieldStyle(.roundedBorder) @@ -38,10 +51,18 @@ struct LogConsoleView: View { Toggle("Autoโ€‘scroll", isOn: $autoScroll) .toggleStyle(.checkbox) + + Button(action: { + LogStore.shared.clear() + }) { + Image(systemName: "trash") + } + .buttonStyle(BorderlessButtonStyle()) + .help("Clear console") } .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color.secondary.opacity(0.1)) + .background(Color.editorPanelBackground.opacity(0.8)) .cornerRadius(8) ScrollViewReader { proxy in @@ -51,65 +72,31 @@ struct LogConsoleView: View { .font(.caption).foregroundColor(.secondary) .frame(width: 84, alignment: .leading) -// Text("[\(e.category)]") -// .font(.caption).foregroundColor(.secondary) -// .frame(width: 120, alignment: .leading) -// -// Text(tag(for: e.level)) -// .font(.caption2) -// .padding(.horizontal, 6).padding(.vertical, 2) -// .background(badgeColor(for: e.level).opacity(0.15)) -// .clipShape(RoundedRectangle(cornerRadius: 6)) - Text(e.message) .font(.system(.body, design: .monospaced)) + .foregroundColor(colorForLevel(e.level)) .textSelection(.enabled) .lineLimit(4) } .id(e.id) } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .listRowBackground(Color.clear) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.editorSurface.opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.editorDivider, lineWidth: 1) + ) .onChange(of: store.entries.last?.id) { _, last in if autoScroll, let last { withAnimation { proxy.scrollTo(last, anchor: .bottom) } } } } - - /* - HStack { - Picker("Level", selection: $selectedLevel) { - Text("All").tag(LogLevel?.none) - Text("Error").tag(LogLevel?.some(.error)) - Text("Warning").tag(LogLevel?.some(.warning)) - Text("Info").tag(LogLevel?.some(.info)) - Text("Debug").tag(LogLevel?.some(.debug)) - Text("Test").tag(LogLevel?.some(.test)) - } - .pickerStyle(.segmented) - - //Disabling Buttons for now - Spacer() - - Button("Copy") { - let text = store.entries.filter(passes).map { - "[\($0.level)] \($0.message)" - }.joined(separator: "\n") - #if os(macOS) - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - #endif - } - - Button("Export") { - //exportLog(store.entries.filter(passes)) - } - - Button("Clear") { - // optional: expose a clear API on Logger/LogStore if you want - } - - } - .padding(.horizontal, 8) - */ } + .frame(minHeight: 140) .padding(10) } @@ -119,6 +106,17 @@ struct LogConsoleView: View { return f.string(from: d) } + private func colorForLevel(_ level: LogLevel) -> Color { + switch level { + case .error: return .red + case .warning: return .yellow + case .info: return .primary + case .debug: return .gray + case .test: return Color.editorAccent + case .none: return .primary + } + } + private func tag(for level: LogLevel) -> String { switch level { case .error: return "ERROR" diff --git a/Sources/UntoldEditor/Editor/NumericInputView.swift b/Sources/UntoldEditor/Editor/NumericInputView.swift index e3f1959..a4c22fd 100644 --- a/Sources/UntoldEditor/Editor/NumericInputView.swift +++ b/Sources/UntoldEditor/Editor/NumericInputView.swift @@ -15,6 +15,7 @@ @Binding var value: SIMD3 @State private var tempValues: [String] = ["0", "0", "0"] @FocusState private var focusedField: Int? + @State private var lastFocusedField: Int? public init(label: String, value: Binding>) { self.label = label @@ -44,6 +45,15 @@ } focusedField = nil } + .onChange(of: focusedField) { oldValue, newValue in + // Commit when this field loses focus (e.g., via Tab) + if oldValue == index, newValue != index { + if let newValue = Float(tempValues[index]) { + value[index] = newValue + } + } + lastFocusedField = newValue + } } } } @@ -59,6 +69,7 @@ @Binding var value: Float @State private var tempValues: String = "0" @FocusState private var focusedField: Int? + @State private var wasFocused: Bool = false public init(label: String, value: Binding) { self.label = label @@ -87,6 +98,15 @@ } focusedField = nil } + .onChange(of: focusedField) { _, newValue in + // Commit when focus leaves (e.g., Tab) + if wasFocused, newValue != 1 { + if let newValue = Float(tempValues) { + value = newValue + } + } + wasFocused = (newValue == 1) + } } } .padding(.vertical, 4) diff --git a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift index b00dab4..4120b22 100644 --- a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift +++ b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift @@ -15,28 +15,50 @@ struct SceneHierarchyView: View { var entityList: [EntityID] var onAddEntity_Editor: () -> Void var onRemoveEntity_Editor: () -> Void + var onAddCube: () -> Void + var onAddSphere: () -> Void + var onAddPlane: () -> Void + var onAddDirLight: () -> Void + var onAddPointLight: () -> Void + var onAddSpotLight: () -> Void + var onAddAreaLight: () -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { // MARK: - Header with Add/Remove Buttons HStack { - Image(systemName: "diagram.tree") + Image(systemName: "list.bullet.indent") .foregroundColor(.accentColor) Text("Scene Graph") - .font(.title2) + .font(.title3) .fontWeight(.bold) .foregroundColor(.primary) Spacer() - // Add Entity Button - Button(action: onAddEntity_Editor) { - Image(systemName: "plus.circle.fill") - .foregroundColor(.blue) - .font(.system(size: 18)) + // Add Entity Menu + Menu { + Button("Empty Entity", systemImage: "plus") { onAddEntity_Editor() } + Divider() + Button("Cube", systemImage: "cube.fill") { onAddCube() } + Button("Sphere", systemImage: "circle.fill") { onAddSphere() } + Button("Plane", systemImage: "rectangle.fill") { onAddPlane() } + Divider() + Button("Directional Light", systemImage: "sun.max") { onAddDirLight() } + Button("Point Light", systemImage: "lightbulb") { onAddPointLight() } + Button("Spot Light", systemImage: "flashlight.on.fill") { onAddSpotLight() } + Button("Area Light", systemImage: "square") { onAddAreaLight() } + } label: { + Image(systemName: "plus") + .foregroundColor(.white) + .font(.system(size: 14, weight: .bold)) + .padding(8) + .background(Color.blue) + .clipShape(Circle()) } - .buttonStyle(PlainButtonStyle()) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) .help("Add Entity") // Remove Entity Button diff --git a/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift b/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift index 1b14dd6..fb4cb32 100644 --- a/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift +++ b/Sources/UntoldEditor/Editor/ScriptComponentInspector.swift @@ -21,6 +21,7 @@ struct ScriptComponentInspector: View { // We keep minimal UI state; details are read from the component each render. @State private var showError: Bool = false @State private var errorMessage: String = "" + @State private var showBasePathAlert: Bool = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -88,6 +89,11 @@ struct ScriptComponentInspector: View { .onAppear { // Nothing to pre-load; we render directly from the component. } + .alert("Set Asset Folder First", isPresented: $showBasePathAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Please set the Asset Folder in the Asset Browser before loading or creating scripts.") + } } // MARK: - Script Row @@ -177,6 +183,11 @@ struct ScriptComponentInspector: View { // MARK: - Load Script File (Append) private func loadScriptFileAndAppend() { + guard EditorAssetBasePath.shared.basePath != nil else { + showBasePathAlert = true + return + } + // Prefer selected asset from Asset Browser if let selectedScript = asset, selectedScript.category == "Scripts", diff --git a/Sources/UntoldEditor/Editor/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index 4ee069c..bad14d6 100644 --- a/Sources/UntoldEditor/Editor/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/SelectionManager.swift @@ -7,6 +7,7 @@ // See the LICENSE file or for details. // import Foundation +import simd import UntoldEngine protocol SelectionDelegate: AnyObject { @@ -42,15 +43,60 @@ class SelectionManager: ObservableObject { func selectEntity(entityId: EntityID) { selectedEntity = entityId - if hasComponent(entityId: entityId, componentType: RenderComponent.self), hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) { + // Check if entity or any of its children have a render component + let hasRenderCapability = entityOrChildrenHaveRenderComponent(entityId: entityId) + + if hasRenderCapability, hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) { activeEntity = entityId - guard let localTransform = scene.get(component: LocalTransformComponent.self, for: activeEntity) else { return } - updateBoundingBoxBuffer(min: localTransform.boundingBox.min, max: localTransform.boundingBox.max) + // Get bounding box from hierarchy (entity or children) + let hierarchyBoundingBox = getHierarchyBoundingBox(entityId: entityId) + updateBoundingBoxBuffer(min: hierarchyBoundingBox.min, max: hierarchyBoundingBox.max) createGizmo(name: "translateGizmo") } else { activeEntity = .invalid } } + + // Helper: Check if entity or any children have RenderComponent + private func entityOrChildrenHaveRenderComponent(entityId: EntityID) -> Bool { + // Check entity itself + if hasComponent(entityId: entityId, componentType: RenderComponent.self) { + return true + } + + // Check children + let children = getEntityChildren(parentId: entityId) + for childId in children { + if hasComponent(entityId: childId, componentType: RenderComponent.self) { + return true + } + } + + return false + } + + // Helper: Get combined bounding box for entity hierarchy + private func getHierarchyBoundingBox(entityId: EntityID) -> (min: simd_float3, max: simd_float3) { + var minBounds = simd_float3(Float.greatestFiniteMagnitude, Float.greatestFiniteMagnitude, Float.greatestFiniteMagnitude) + var maxBounds = simd_float3(-Float.greatestFiniteMagnitude, -Float.greatestFiniteMagnitude, -Float.greatestFiniteMagnitude) + + // Check entity itself + if let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) { + minBounds = simd_min(minBounds, localTransform.boundingBox.min) + maxBounds = simd_max(maxBounds, localTransform.boundingBox.max) + } + + // Check children + let children = getEntityChildren(parentId: entityId) + for childId in children { + if let childTransform = scene.get(component: LocalTransformComponent.self, for: childId) { + minBounds = simd_min(minBounds, childTransform.boundingBox.min) + maxBounds = simd_max(maxBounds, childTransform.boundingBox.max) + } + } + + return (min: minBounds, max: maxBounds) + } } diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 7e7729a..1f42bf8 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -7,24 +7,32 @@ // See the LICENSE file or for details. // #if canImport(AppKit) + import AppKit import SwiftUI struct ToolbarView: View { @ObservedObject var selectionManager: SelectionManager var onSave: () -> Void - var onLoad: () -> Void + var onSaveAs: () -> Void var onClear: () -> Void var onPlayToggled: (Bool) -> Void + var onShowAssets: () -> Void var dirLightCreate: () -> Void var pointLightCreate: () -> Void var spotLightCreate: () -> Void var areaLightCreate: () -> Void + var onCreateCube: () -> Void + var onCreateSphere: () -> Void + var onCreatePlane: () -> Void + var onCreateCylinder: () -> Void + var onCreateCone: () -> Void @State private var isPlaying = false @State private var showBuildSettings = false @State private var showingNewScriptDialog = false @State private var newScriptName = "" + @State private var showBasePathAlert = false var body: some View { HStack { @@ -40,11 +48,15 @@ .padding(.horizontal, 20) .padding(.vertical, 6) .background( - Color.secondary.opacity(0.1) - .ignoresSafeArea() + LinearGradient( + colors: [Color.editorPanelBackground.opacity(0.95), Color.editorPanelBackground.opacity(0.85)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() ) .cornerRadius(8) - .shadow(color: Color.black.opacity(0.05), radius: 3, x: 0, y: 1) + .shadow(color: Color.black.opacity(0.08), radius: 4, x: 0, y: 2) .sheet(isPresented: $showBuildSettings) { BuildSettingsView() } @@ -60,10 +72,28 @@ } ) } + .alert("Set Asset Folder First", isPresented: $showBasePathAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Please set the Asset Folder in the Asset Browser before creating or editing scripts.") + } } var rightSection: some View { HStack(spacing: 12) { + Button(action: onShowAssets) { + HStack(spacing: 6) { + Image(systemName: "shippingbox.fill") + Text("Assets Library") + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.editorSurface) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + Button(action: { showBuildSettings = true }) { HStack(spacing: 6) { Image(systemName: "hammer.fill") @@ -71,8 +101,8 @@ } .padding(.vertical, 6) .padding(.horizontal, 12) - .background(Color.green) - .foregroundColor(.white) + .background(Color.editorAccent) + .foregroundColor(.black.opacity(0.9)) .cornerRadius(6) } .buttonStyle(.plain) @@ -83,9 +113,7 @@ var centeredButtons: some View { HStack(spacing: 12) { - ToolbarButton(iconName: "clear.fill", action: onClear, tooltip: "Clear Scene") - - ToolbarButton(iconName: "square.and.arrow.down", action: onLoad, tooltip: "Import JSON Scene") + ToolbarButton(iconName: "gobackward", action: onClear, tooltip: "Clear Scene") Button(action: { isPlaying.toggle() @@ -103,51 +131,89 @@ } .buttonStyle(.plain) - ToolbarButton(iconName: "square.and.arrow.up", action: onSave, tooltip: "Export JSON Scene") + Menu { + Button("Save", systemImage: "square.and.arrow.down.on.square", action: onSave) + Button("Save Asโ€ฆ", systemImage: "square.and.arrow.down", action: onSaveAs) + } label: { + HStack(spacing: 6) { + Image(systemName: "square.and.arrow.down.on.square") + Text("Save") + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.gray.opacity(0.8)) + .foregroundColor(.white) + .cornerRadius(8) + } + .menuStyle(.borderlessButton) } } var leftSection: some View { HStack(spacing: 8) { - Text("Scripts:") - .font(.system(size: 11)) - .foregroundColor(.secondary) +// Button(action: onCreateCylinder) { +// Image(systemName: "cylinder.fill") +// .font(.system(size: 14)) +// .foregroundColor(.white) +// } +// .padding(6) +// .background(Color.blue.opacity(0.8)) +// .cornerRadius(6) +// .buttonStyle(.plain) +// .help("Add Cylinder") +// +// Button(action: onCreateCone) { +// Image(systemName: "cone.fill") +// .font(.system(size: 14)) +// .foregroundColor(.white) +// } +// .padding(6) +// .background(Color.blue.opacity(0.8)) +// .cornerRadius(6) +// .buttonStyle(.plain) +// .help("Add Cone") + + Divider().frame(height: 24) - // New Script - Button(action: { showingNewScriptDialog = true }) { - HStack(spacing: 4) { + Button(action: { + guard ensureAssetBasePath() else { return } + showingNewScriptDialog = true + }) { + HStack(spacing: 6) { Image(systemName: "plus.circle.fill") - Text("New") + Text("New Script") } - .font(.system(size: 12)) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(.white) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(Color.green) - .cornerRadius(5) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.green.opacity(0.85)) + .cornerRadius(8) } .buttonStyle(.plain) + .help("Create a new script in the Scripts project") - // Open in Xcode - Button(action: { openInXcode() }) { - HStack(spacing: 4) { - Image(systemName: "hammer.circle.fill") + Button(action: openInXcode) { + HStack(spacing: 6) { + Image(systemName: "hammer.fill") Text("Open in Xcode") } - .font(.system(size: 12)) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(.white) - .padding(.vertical, 4) - .padding(.horizontal, 8) - .background(Color.blue) - .cornerRadius(5) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.blue.opacity(0.85)) + .cornerRadius(8) } .buttonStyle(.plain) + .help("Open Scripts project in Xcode") } } // MARK: - Script Management Functions private func createNewScript() { + guard ensureAssetBasePath() else { return } let manager = ScriptProjectManager.shared // Initialize project if needed @@ -177,6 +243,7 @@ } private func openInXcode() { + guard ensureAssetBasePath() else { return } let manager = ScriptProjectManager.shared // Initialize project if needed @@ -202,6 +269,14 @@ NSWorkspace.shared.open(packageSwiftPath) print("โœ… Opening Scripts project in Xcode") } + + private func ensureAssetBasePath() -> Bool { + guard EditorAssetBasePath.shared.basePath != nil else { + showBasePathAlert = true + return false + } + return true + } } // MARK: - Toolbar Button Component @@ -263,4 +338,5 @@ .frame(width: 400) } } + #endif diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index d2e224e..0769e70 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -572,10 +572,7 @@ extension RenderPasses { return } - guard let renderComponent = scene.get(component: RenderComponent.self, for: activeEntity) else { - handleError(.noRenderComponent) - return - } + let renderComponent = scene.get(component: RenderComponent.self, for: activeEntity) renderEncoder.setVertexBytes( &cameraComponent.viewSpace, length: MemoryLayout.stride, index: 1 diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index 7e45d17..4c9a3b9 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -175,7 +175,20 @@ activeHitGizmoEntity = .invalid if hit { - activeEntity = entityId + // If hit a child mesh, select the parent instead (except for gizmos) + var hitEntityId = entityId + if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { + // Gizmo hit - select the gizmo directly + hitEntityId = entityId + } else if let parentId = getEntityParent(entityId: entityId) { + // Non-gizmo child hit - select parent + hitEntityId = parentId + } else { + // Entity with no parent - select it directly + hitEntityId = entityId + } + + activeEntity = hitEntityId selectionDelegate?.didSelectEntity(activeEntity) selectionDelegate?.resetActiveAxis() diff --git a/Sources/UntoldEditor/Systems/RayModelIntersecions.swift b/Sources/UntoldEditor/Systems/RayModelIntersecions.swift index 0205b3a..181d90a 100644 --- a/Sources/UntoldEditor/Systems/RayModelIntersecions.swift +++ b/Sources/UntoldEditor/Systems/RayModelIntersecions.swift @@ -91,6 +91,9 @@ func createAccelerationStructures(_: Bool) { // Iterate over the entities found by the component query for (i, entityId) in entities.enumerated() { + // Skip entities pending destroy + if scene.mask(for: entityId) == nil { continue } + guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { handleError(.noRenderComponent, entityId) continue diff --git a/Sources/UntoldEditor/Systems/ScriptProjectManager.swift b/Sources/UntoldEditor/Systems/ScriptProjectManager.swift index c4c284e..23026ec 100644 --- a/Sources/UntoldEditor/Systems/ScriptProjectManager.swift +++ b/Sources/UntoldEditor/Systems/ScriptProjectManager.swift @@ -126,6 +126,9 @@ class ScriptProjectManager { let scriptContent = generateScriptTemplate(name: name) try scriptContent.write(to: scriptPath, atomically: true, encoding: .utf8) + // Ensure GenerateScripts main triggers this generator + try addScriptInvocationToMain(name: name) + print("โœ… Created script: \(scriptPath.path)") } @@ -200,6 +203,35 @@ class ScriptProjectManager { // MARK: - Template Generators + /// Adds a generate(to:) invocation into GenerateScripts.swift if not present. + private func addScriptInvocationToMain(name: String) throws { + guard let sourcesDir = sourcesDirectory() else { return } + let generateScriptsPath = sourcesDir.appendingPathComponent("GenerateScripts.swift") + + var contents = try String(contentsOf: generateScriptsPath, encoding: .utf8) + + let callLine = "generate\(name)(to: outputDir)" + if contents.contains(callLine) { + return + } + + let anchor = "print(\"โœ… All scripts generated in Generated/\")" + if let range = contents.range(of: anchor) { + // Preserve existing indentation from the anchor line + let lineStart = contents[.. String { """ // swift-tools-version: 5.10 @@ -254,6 +286,7 @@ class ScriptProjectManager { // TODO: Call your script generation functions here // Example: generatePlayerController(to: outputDir) + // New scripts created in the editor are auto-added below. print("โœ… All scripts generated in Generated/") } @@ -273,6 +306,9 @@ class ScriptProjectManager { import Foundation import UntoldEngine + import simd + + // To view the Untold Engine API click here: https://untoldengine.github.io/UntoldEngine/docs/Scripting/usc-scripting-api extension GenerateScripts { static func generate\(name)(to dir: URL) { @@ -296,4 +332,22 @@ class ScriptProjectManager { .DS_Store """ } + + /// Removes a generate(to:) invocation from GenerateScripts.swift if present. + func removeScriptInvocationFromMain(name: String) { + guard let sourcesDir = sourcesDirectory() else { return } + let generateScriptsPath = sourcesDir.appendingPathComponent("GenerateScripts.swift") + + guard FileManager.default.fileExists(atPath: generateScriptsPath.path), + var contents = try? String(contentsOf: generateScriptsPath, encoding: .utf8) + else { return } + + let callLine = "generate\(name)(to: outputDir)" + + if contents.contains(callLine) { + contents = contents.replacingOccurrences(of: callLine + "\n", with: "") + contents = contents.replacingOccurrences(of: callLine, with: "") + try? contents.write(to: generateScriptsPath, atomically: true, encoding: .utf8) + } + } } diff --git a/Sources/UntoldEditor/Utils/EditorFuncUtils.swift b/Sources/UntoldEditor/Utils/EditorFuncUtils.swift index 358b3b6..a42cc3a 100644 --- a/Sources/UntoldEditor/Utils/EditorFuncUtils.swift +++ b/Sources/UntoldEditor/Utils/EditorFuncUtils.swift @@ -8,6 +8,7 @@ // import Foundation +import ModelIO import SwiftUI import UntoldEngine @@ -66,3 +67,82 @@ func bindingForMaterialRoughness(entityId: EntityID, onChange: @escaping () -> V } ) } + +// Helper function to get NSImage from material texture, handling both file-based and embedded USDZ textures +func getMaterialTextureImage(entityId: EntityID, type: TextureType) -> NSImage? { + // First, check if we have a URL + guard let url = getMaterialTextureURL(entityId: entityId, type: type) else { + return nil + } + + // Check if this is an embedded USDZ texture + if isEmbeddedUSDZTexture(url) { + // For embedded textures, we need to extract the image from MDLTexture + guard let mdlTexture = getMaterialMDLTexture(entityId: entityId, type: type) else { + print("Warning: Could not get MDLTexture for embedded texture: \(url)") + return nil + } + + // Convert MDLTexture to NSImage via CGImage + if let image = nsImageFromMDLTexture(mdlTexture) { + return image + } else { + print("Warning: Failed to convert MDLTexture to NSImage for: \(url)") + return nil + } + } else { + // For file-based textures, load directly from URL + return NSImage(contentsOf: url) + } +} + +// Convert MDLTexture to NSImage +func nsImageFromMDLTexture(_ mdlTexture: MDLTexture) -> NSImage? { + // Get the texture data from MDLTexture + guard let texelData = mdlTexture.texelDataWithTopLeftOrigin() else { + print("Error: texelDataWithTopLeftOrigin() returned nil") + return nil + } + + let width = Int(mdlTexture.dimensions.x) + let height = Int(mdlTexture.dimensions.y) + let channelCount = Int(mdlTexture.channelCount) + + // Validate dimensions + guard width > 0, height > 0, channelCount > 0 else { + print("Error: Invalid texture dimensions - width: \(width), height: \(height), channels: \(channelCount)") + return nil + } + + let bytesPerRow = width * channelCount + + // Create a CGImage from the texture data + guard let dataProvider = CGDataProvider(data: texelData as CFData) else { + return nil + } + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo: CGBitmapInfo = mdlTexture.channelCount == 4 + ? CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + : CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let cgImage = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: channelCount * 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo, + provider: dataProvider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { + print("Error: Failed to create CGImage with dimensions \(width)x\(height), channels: \(channelCount)") + return nil + } + + let size = NSSize(width: CGFloat(width), height: CGFloat(height)) + return NSImage(cgImage: cgImage, size: size) +} diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 929e7f5..99989c4 100644 --- a/Sources/UntoldEditor/main.swift +++ b/Sources/UntoldEditor/main.swift @@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { defer: false ) - window.title = "Untold Engine Editor v0.2" + window.title = "Untold Engine Editor v0.1.0" window.center() let hostingView = NSHostingView(rootView: EditorView()) diff --git a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift index 2781a37..bd0705a 100644 --- a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift +++ b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift @@ -29,12 +29,14 @@ final class AssetBrowserViewTests: XCTestCase { private func makeView(assets: Binding<[String: [Asset]]>, selectedAsset: Binding, selectionManager: SelectionManager = SelectionManager(), + sceneGraphModel: SceneGraphModel = SceneGraphModel(), editor_addEntityWithAsset: @escaping () -> Void = {}) -> AssetBrowserView { AssetBrowserView( assets: assets, selectedAsset: selectedAsset, selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, editor_addEntityWithAsset: editor_addEntityWithAsset ) } diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index afacaba..5df6387 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -19,68 +19,104 @@ import XCTest private func makeSUT( selectionManager: SelectionManager = SelectionManager(), onSaveCalled: UnsafeMutablePointer, - onLoadCalled: UnsafeMutablePointer, + onSaveAsCalled: UnsafeMutablePointer, onClearCalled: UnsafeMutablePointer, + onShowAssetsCalled: UnsafeMutablePointer, onCameraSaveCalled _: UnsafeMutablePointer, onPlayToggledValues: UnsafeMutablePointer<[Bool]>, onDirLightCalled: UnsafeMutablePointer, onPointLightCalled: UnsafeMutablePointer, onSpotLightCalled: UnsafeMutablePointer, - onAreaLightCalled: UnsafeMutablePointer + onAreaLightCalled: UnsafeMutablePointer, + onCreateCubeCalled: UnsafeMutablePointer, + onCreateSphereCalled: UnsafeMutablePointer, + onCreatePlaneCalled: UnsafeMutablePointer, + onCreateCylinderCalled: UnsafeMutablePointer, + onCreateConeCalled: UnsafeMutablePointer ) -> ToolbarView { ToolbarView( selectionManager: selectionManager, onSave: { onSaveCalled.pointee = true }, - onLoad: { onLoadCalled.pointee = true }, + onSaveAs: { onSaveAsCalled.pointee = true }, onClear: { onClearCalled.pointee = true }, onPlayToggled: { value in onPlayToggledValues.pointee.append(value) }, + onShowAssets: { onShowAssetsCalled.pointee = true }, dirLightCreate: { onDirLightCalled.pointee = true }, pointLightCreate: { onPointLightCalled.pointee = true }, spotLightCreate: { onSpotLightCalled.pointee = true }, - areaLightCreate: { onAreaLightCalled.pointee = true } + areaLightCreate: { onAreaLightCalled.pointee = true }, + onCreateCube: { onCreateCubeCalled.pointee = true }, + onCreateSphere: { onCreateSphereCalled.pointee = true }, + onCreatePlane: { onCreatePlaneCalled.pointee = true }, + onCreateCylinder: { onCreateCylinderCalled.pointee = true }, + onCreateCone: { onCreateConeCalled.pointee = true } ) } func test_actionsAreWired_up() { var onSave = false - var onLoad = false + var onSaveAs = false var onClear = false + var onShowAssets = false var onCameraSave = false var playValues: [Bool] = [] var onDir = false var onPoint = false var onSpot = false var onArea = false + var onCube = false + var onSphere = false + var onPlane = false + var onCylinder = false + var onCone = false let sut = makeSUT( onSaveCalled: &onSave, - onLoadCalled: &onLoad, + onSaveAsCalled: &onSaveAs, onClearCalled: &onClear, + onShowAssetsCalled: &onShowAssets, onCameraSaveCalled: &onCameraSave, onPlayToggledValues: &playValues, onDirLightCalled: &onDir, onPointLightCalled: &onPoint, onSpotLightCalled: &onSpot, - onAreaLightCalled: &onArea + onAreaLightCalled: &onArea, + onCreateCubeCalled: &onCube, + onCreateSphereCalled: &onSphere, + onCreatePlaneCalled: &onPlane, + onCreateCylinderCalled: &onCylinder, + onCreateConeCalled: &onCone ) // We cannot programmatically tap SwiftUI Buttons without a host and introspection. // Instead, assert that injected closures can be called and flip their flags. sut.onSave() - sut.onLoad() + sut.onSaveAs() sut.onClear() + sut.onShowAssets() sut.dirLightCreate() sut.pointLightCreate() sut.spotLightCreate() sut.areaLightCreate() + sut.onCreateCube() + sut.onCreateSphere() + sut.onCreatePlane() + sut.onCreateCylinder() + sut.onCreateCone() XCTAssertTrue(onSave, "onSave should be wired.") - XCTAssertTrue(onLoad, "onLoad should be wired.") + XCTAssertTrue(onSaveAs, "onSaveAs should be wired.") XCTAssertTrue(onClear, "onClear should be wired.") + XCTAssertTrue(onShowAssets, "onShowAssets should be wired.") XCTAssertTrue(onDir, "dirLightCreate should be wired.") XCTAssertTrue(onPoint, "pointLightCreate should be wired.") XCTAssertTrue(onSpot, "spotLightCreate should be wired.") XCTAssertTrue(onArea, "areaLightCreate should be wired.") + XCTAssertTrue(onCube, "onCreateCube should be wired.") + XCTAssertTrue(onSphere, "onCreateSphere should be wired.") + XCTAssertTrue(onPlane, "onCreatePlane should be wired.") + XCTAssertTrue(onCylinder, "onCreateCylinder should be wired.") + XCTAssertTrue(onCone, "onCreateCone should be wired.") // For play toggle, verify the closure records values we pass. // Since @State is internal, we mimic the button behavior by calling the closure directly. @@ -97,13 +133,19 @@ import XCTest let sut = ToolbarView( selectionManager: selection, onSave: {}, - onLoad: {}, + onSaveAs: {}, onClear: {}, onPlayToggled: { playValues.append($0) }, + onShowAssets: {}, dirLightCreate: {}, pointLightCreate: {}, spotLightCreate: {}, - areaLightCreate: {} + areaLightCreate: {}, + onCreateCube: {}, + onCreateSphere: {}, + onCreatePlane: {}, + onCreateCylinder: {}, + onCreateCone: {} ) // Wrap in a hosting controller to ensure SwiftUI can build the body. @@ -112,5 +154,113 @@ import XCTest _ = host // keep alive _ = playValues // keep referenced to avoid 'never read' warning } + + // MARK: - Primitive Creation Tests + + func test_primitiveButtons_callCorrectClosures() { + // Given: A ToolbarView with tracked primitive creation callbacks + var cubeCreated = false + var sphereCreated = false + var planeCreated = false + var cylinderCreated = false + var coneCreated = false + + let sut = ToolbarView( + selectionManager: SelectionManager(), + onSave: {}, + onSaveAs: {}, + onClear: {}, + onPlayToggled: { _ in }, + onShowAssets: {}, + dirLightCreate: {}, + pointLightCreate: {}, + spotLightCreate: {}, + areaLightCreate: {}, + onCreateCube: { cubeCreated = true }, + onCreateSphere: { sphereCreated = true }, + onCreatePlane: { planeCreated = true }, + onCreateCylinder: { cylinderCreated = true }, + onCreateCone: { coneCreated = true } + ) + + // When: Invoking the primitive creation closures + sut.onCreateCube() + sut.onCreateSphere() + sut.onCreatePlane() + sut.onCreateCylinder() + sut.onCreateCone() + + // Then: Each closure should be called + XCTAssertTrue(cubeCreated, "Cube creation callback should be invoked") + XCTAssertTrue(sphereCreated, "Sphere creation callback should be invoked") + XCTAssertTrue(planeCreated, "Plane creation callback should be invoked") + XCTAssertTrue(cylinderCreated, "Cylinder creation callback should be invoked") + XCTAssertTrue(coneCreated, "Cone creation callback should be invoked") + } + + func test_primitivesSection_existsInView() { + // Given: A ToolbarView instance + var callCount = 0 + let sut = ToolbarView( + selectionManager: SelectionManager(), + onSave: {}, + onSaveAs: {}, + onClear: {}, + onPlayToggled: { _ in }, + onShowAssets: {}, + dirLightCreate: {}, + pointLightCreate: {}, + spotLightCreate: {}, + areaLightCreate: {}, + onCreateCube: { callCount += 1 }, + onCreateSphere: { callCount += 1 }, + onCreatePlane: { callCount += 1 }, + onCreateCylinder: {}, + onCreateCone: {} + ) + + // When: Calling the primitive closures + sut.onCreateCube() + sut.onCreateSphere() + sut.onCreatePlane() + + // Then: All three active primitives should have been called + XCTAssertEqual(callCount, 3, "All three active primitive buttons (cube, sphere, plane) should be functional") + } + + func test_primitiveCallbacks_areIndependent() { + // Given: Separate tracking for each primitive + var cubeCount = 0 + var sphereCount = 0 + var planeCount = 0 + + let sut = ToolbarView( + selectionManager: SelectionManager(), + onSave: {}, + onSaveAs: {}, + onClear: {}, + onPlayToggled: { _ in }, + onShowAssets: {}, + dirLightCreate: {}, + pointLightCreate: {}, + spotLightCreate: {}, + areaLightCreate: {}, + onCreateCube: { cubeCount += 1 }, + onCreateSphere: { sphereCount += 1 }, + onCreatePlane: { planeCount += 1 }, + onCreateCylinder: {}, + onCreateCone: {} + ) + + // When: Calling specific primitive closures multiple times + sut.onCreateCube() + sut.onCreateCube() + sut.onCreateSphere() + + // Then: Each closure should track independently + XCTAssertEqual(cubeCount, 2, "Cube should be created twice") + XCTAssertEqual(sphereCount, 1, "Sphere should be created once") + XCTAssertEqual(planeCount, 0, "Plane should not be created") + } } #endif diff --git a/create_app_bundle.sh b/create_app_bundle.sh new file mode 100755 index 0000000..593383a --- /dev/null +++ b/create_app_bundle.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +set -e # Exit on error + +echo "๐Ÿ”จ Building UntoldEditor app bundle..." + +# Configuration +APP_NAME="Untold Engine Studio" +EXECUTABLE_NAME="UntoldEditor" +BUNDLE_ID="com.untoldengine.studio" + +# Determine version (env -> release/* branch -> latest tag vX.Y.Z -> fallback) +detect_version() { + if [ -n "${VERSION:-}" ]; then + echo "$VERSION"; return + fi + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [[ "$branch" == release/* ]]; then + echo "${branch#release/}"; return + fi + tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [[ "$tag" == v* ]]; then + echo "${tag#v}"; return + fi + echo "0.0.0-dev" +} +VERSION="$(detect_version)" +BUILD_DIR=".build/arm64-apple-macosx/release" +APP_BUNDLE="$APP_NAME.app" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +UNTOLD_ENGINE_ROOT="$SCRIPT_DIR/../UntoldEngine" +METALLIB_PATH="$UNTOLD_ENGINE_ROOT/Sources/UntoldEngine/UntoldEngineKernels/UntoldEngineKernels.metallib" + +# Clean previous bundle +echo "๐Ÿงน Cleaning previous bundle..." +rm -rf "$APP_BUNDLE" + +# Build the executable with Swift Package Manager +echo "๐Ÿ”ง Building executable..." +swift build --configuration release + +# Create app bundle structure +echo "๐Ÿ“ฆ Creating app bundle structure..." +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" + +# Copy the executable +echo "๐Ÿ“‹ Copying executable..." +cp "$BUILD_DIR/$EXECUTABLE_NAME" "$APP_BUNDLE/Contents/MacOS/$EXECUTABLE_NAME" + +# Copy Metal shaders +if [ -f "$METALLIB_PATH" ]; then + echo "๐ŸŽจ Copying Metal shaders..." + cp "$METALLIB_PATH" "$APP_BUNDLE/Contents/Resources/" +else + echo "โš ๏ธ Warning: Metal shader library not found at $METALLIB_PATH" +fi + +# Copy app icon if it exists +if [ -f "Resources/AppIcon.icns" ]; then + echo "๐ŸŽจ Copying app icon..." + cp "Resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/" +fi + +# Create Info.plist +echo "๐Ÿ“ Creating Info.plist..." + +# Check if icon exists to conditionally add it +ICON_ENTRY="" +if [ -f "Resources/AppIcon.icns" ]; then + ICON_ENTRY=" CFBundleIconFile\n AppIcon" +fi + +cat > "$APP_BUNDLE/Contents/Info.plist" << EOF + + + + + CFBundleExecutable + $EXECUTABLE_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + $APP_NAME + CFBundlePackageType + APPL + CFBundleShortVersionString + $VERSION + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + +$(echo -e "$ICON_ENTRY") + + +EOF + +# Make executable +chmod +x "$APP_BUNDLE/Contents/MacOS/$EXECUTABLE_NAME" + +echo "โœ… App bundle created successfully at: $APP_BUNDLE" +echo "" +echo "To test the app, run:" +echo " open $APP_BUNDLE" +echo "" +echo "Or double-click the app in Finder" diff --git a/create_dmg.sh b/create_dmg.sh new file mode 100755 index 0000000..6026349 --- /dev/null +++ b/create_dmg.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +set -e # Exit on error + +echo "๐Ÿ“€ Creating DMG installer for Untold Engine Studio..." + +# Configuration +APP_NAME="Untold Engine Studio" +DMG_NAME="UntoldEngineStudio" +APP_BUNDLE="$APP_NAME.app" +DMG_TEMP_DIR="dmg_temp" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Determine version (env -> release/* branch -> latest tag vX.Y.Z -> fallback) +detect_version() { + if [ -n "${VERSION:-}" ]; then + echo "$VERSION"; return + fi + branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") + if [[ "$branch" == release/* ]]; then + echo "${branch#release/}"; return + fi + tag=$(git -C "$SCRIPT_DIR" describe --tags --abbrev=0 2>/dev/null || echo "") + if [[ "$tag" == v* ]]; then + echo "${tag#v}"; return + fi + echo "0.0.0-dev" +} +VERSION="$(detect_version)" + +DMG_FILE="$DMG_NAME-$VERSION.dmg" + +# Check if app bundle exists +if [ ! -d "$APP_BUNDLE" ]; then + echo "โŒ Error: $APP_BUNDLE not found. Run ./create_app_bundle.sh first." + exit 1 +fi + +# Clean previous DMG files +echo "๐Ÿงน Cleaning previous DMG files..." +rm -f "$DMG_FILE" +rm -rf "$DMG_TEMP_DIR" + +# Create temporary directory for DMG contents +echo "๐Ÿ“ฆ Creating DMG staging directory..." +mkdir -p "$DMG_TEMP_DIR" + +# Copy app bundle to staging +echo "๐Ÿ“‹ Copying app bundle..." +cp -R "$APP_BUNDLE" "$DMG_TEMP_DIR/" + +# Create symbolic link to Applications folder +echo "๐Ÿ”— Creating Applications folder link..." +ln -s /Applications "$DMG_TEMP_DIR/Applications" + +# Create DMG +echo "๐Ÿ’ฟ Creating DMG image..." +hdiutil create -volname "$APP_NAME" \ + -srcfolder "$DMG_TEMP_DIR" \ + -ov -format UDZO \ + "$DMG_FILE" + +# Clean up temp directory +echo "๐Ÿงน Cleaning up..." +rm -rf "$DMG_TEMP_DIR" + +echo "โœ… DMG created successfully: $DMG_FILE" +echo "" +echo "To test the DMG:" +echo " open $DMG_FILE" +echo "" +echo "Users can drag '$APP_NAME.app' to the Applications folder to install." diff --git a/scripts/next-version.sh b/scripts/next-version.sh index 70b1a34..3c94301 100755 --- a/scripts/next-version.sh +++ b/scripts/next-version.sh @@ -5,7 +5,7 @@ # ------------------------------------------------------------- # Description: # Determines the next semantic version of the Untold Engine -# by comparing the current release branch (e.g. release/0.3.0) +# by comparing the current base ref (release/x.y.z branch or vX.Y.Z tag) # against the latest commits in develop. # # The script scans commit messages between the release branch @@ -25,14 +25,14 @@ # snapshot documentation for the new release # # Usage Examples: -# ./scripts/next-version.sh +# ./scripts/next-version.sh # auto-picks latest vX.Y.Z tag from develop # ./scripts/next-version.sh --with-v # ./scripts/next-version.sh release/0.3.0 --cliff -# ./scripts/next-version.sh --cliff --docs +# ./scripts/next-version.sh v0.3.0 --cliff --docs # # Notes: # - Must be executed from the repository root. -# - Assumes release branches follow the pattern release/x.y.z. +# - Base ref can be a release/x.y.z branch or vX.Y.Z tag. # - Designed to simplify the Untold Engine release flow by # automating version calculation, changelog generation, and # docs versioning in a single step. @@ -44,38 +44,44 @@ set -euo pipefail WITH_V="false" DO_CLIFF="false" DO_DOCS="false" -BASE_BRANCH="" +BASE_REF="" for arg in "$@"; do case "$arg" in --with-v) WITH_V="true" ;; --cliff) DO_CLIFF="true" ;; --docs) DO_DOCS="true" ;; - release/*) BASE_BRANCH="$arg" ;; + release/*) BASE_REF="$arg" ;; + v[0-9]*|[0-9]*.[0-9]*.[0-9]*) BASE_REF="$arg" ;; # allow tags like v0.3.0 or 0.3.0 *) echo "Unknown argument: $arg" >&2; exit 2 ;; esac done -# Auto-pick latest local release/* branch if none provided -if [[ -z "${BASE_BRANCH}" ]]; then - BASE_BRANCH="$(git for-each-ref --sort=-committerdate --format='%(refname:short)' refs/heads/release/ | head -n1 || true)" +# Auto-pick latest reachable tag (vX.Y.Z) from develop if none provided +if [[ -z "${BASE_REF}" ]]; then + BASE_REF="$(git describe --tags --match 'v[0-9]*' --abbrev=0 develop 2>/dev/null || true)" fi -[[ -n "${BASE_BRANCH}" ]] || { echo "No local release/* branch found; provide one (e.g., release/0.3.0)." >&2; exit 1; } +[[ -n "${BASE_REF}" ]] || { echo "No base ref found. Pass a release branch (release/x.y.z) or tag (vX.Y.Z)." >&2; exit 1; } -# Parse base version from branch name release/x.y.z -if [[ ! "${BASE_BRANCH}" =~ ^release/([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "Base branch must be named like release/x.y.z (got: ${BASE_BRANCH})" >&2 +# Parse base version from branch or tag name +if [[ "${BASE_REF}" =~ ^release/([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + BASE_MAJOR="${BASH_REMATCH[1]}" + BASE_MINOR="${BASH_REMATCH[2]}" + BASE_PATCH="${BASH_REMATCH[3]}" +elif [[ "${BASE_REF}" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + BASE_MAJOR="${BASH_REMATCH[1]}" + BASE_MINOR="${BASH_REMATCH[2]}" + BASE_PATCH="${BASH_REMATCH[3]}" +else + echo "Base ref must look like release/x.y.z or vX.Y.Z (got: ${BASE_REF})" >&2 exit 1 fi -BASE_MAJOR="${BASH_REMATCH[1]}" -BASE_MINOR="${BASH_REMATCH[2]}" -BASE_PATCH="${BASH_REMATCH[3]}" # Ensure develop exists locally git rev-parse --verify develop >/dev/null 2>&1 || { echo "Local branch 'develop' not found." >&2; exit 1; } # Collect commit messages BASE..develop (local) -LOG="$(git log --pretty=%B "${BASE_BRANCH}..develop" || true)" +LOG="$(git log --pretty=%B "${BASE_REF}..develop" || true)" # Highest bump wins if echo "$LOG" | grep -q "\[API Change\]"; then @@ -109,7 +115,7 @@ fi if [[ "${DO_CLIFF}" == "true" ]]; then command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found. Install it first." >&2; exit 1; } TAG="v${NEXT}" - RANGE="${BASE_BRANCH}..HEAD" + RANGE="${BASE_REF}..HEAD" git cliff "${RANGE}" --tag "${TAG}" --prepend CHANGELOG.md fi @@ -117,9 +123,17 @@ fi if [[ "${DO_DOCS}" == "true" ]]; then command -v npm >/dev/null 2>&1 || { echo "npm not found. Please install Node.js." >&2; exit 1; } - echo "๐Ÿงญ Running Docusaurus versioning for ${NEXT}..." - npm run docusaurus docs:version "${NEXT}" + DOCS_DIR="website" + if [[ -d "${DOCS_DIR}" && -f "${DOCS_DIR}/package.json" ]]; then + echo "๐Ÿงญ Running Docusaurus versioning for ${NEXT} (in ${DOCS_DIR})..." + ( + cd "${DOCS_DIR}" + npm run docusaurus docs:version "${NEXT}" + ) + else + echo "๐Ÿงญ Running Docusaurus versioning for ${NEXT} (current directory)..." + npm run docusaurus docs:version "${NEXT}" + fi echo "โœ… Docusaurus version ${NEXT} created." fi -