diff --git a/CHANGELOG.md b/CHANGELOG.md index 73adb47..250463b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # Changelog +## v0.14.0 - 2026-07-09 +### 🐞 Fixes +- [Patch] added option to load authored scene data (4558dec…) +- [Patch] Fixed area light direction (e05a8d4…) +- [Patch] Added an Explore Window (86e53f7…) +- [Patch] Added Try your scene steps (03ee7d5…) +- [Patch] updated editor render graph (f47e3b7…) +- [Patch] Migrate editor rendering to the engine's RenderExtension system (31ee159…) ## v0.13.0 - 2026-06-03 ### 🐞 Fixes - [Patch] Made user experience improvements (adf6d2b…) diff --git a/Package.swift b/Package.swift index b2de770..ccf625d 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.13.0"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.14.0"), ], targets: [ .executableTarget( @@ -23,7 +23,7 @@ let package = Package( ], path: "Sources/UntoldEditor", resources: [ - // .process("Resources") + .process("Resources"), ], swiftSettings: [ .swiftLanguageMode(.v5), diff --git a/Sources/UntoldEditor/Config/EditorFeatureFlags.swift b/Sources/UntoldEditor/Config/EditorFeatureFlags.swift index 73189e9..fb795b1 100644 --- a/Sources/UntoldEditor/Config/EditorFeatureFlags.swift +++ b/Sources/UntoldEditor/Config/EditorFeatureFlags.swift @@ -14,7 +14,7 @@ enum EditorFeatureFlags { // MARK: - Build System Features /// Enable the Build button in the toolbar - /// When disabled, users should use the `untoldengine-create` CLI tool instead + /// When disabled, users should use `untoldegie create` instead static let enableBuildButton: Bool = true // MARK: - Script Management Features diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 178f30b..0638412 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -15,6 +15,14 @@ private let runtimeTextureFolderNames = ["Textures", "textures"] private let sourceAssetExtensions: Set = ["usd", "usda", "usdc", "usdz"] private let streamModelResourceFolderNames = ["tile_exports", "tile_export", "Textures", "textures"] +struct RuntimeExportRequest: Identifiable, Equatable { + let id = UUID() + let sourceURL: URL + let category: AssetCategory + let destinationFolder: URL + let outputURL: URL +} + struct TilesExportRequest: Identifiable, Equatable { let id = UUID() let sourceURL: URL @@ -214,6 +222,10 @@ func findUntoldEngineScript(named name: String, fileManager fm: FileManager = .d .first { fm.isExecutableFile(atPath: $0.path) || fm.fileExists(atPath: $0.path) } } +func findExportUntoldScript(fileManager fm: FileManager = .default) -> URL? { + findUntoldEngineScript(named: "export-untold", fileManager: fm) +} + func findExportUntoldTilesScript(fileManager fm: FileManager = .default) -> URL? { findUntoldEngineScript(named: "export-untold-tiles", fileManager: fm) } @@ -363,6 +375,11 @@ struct AssetBrowserView: View { @State private var showImportMenu = false @State private var showRemoteStreamSheet = false @State private var remoteStreamURLString = "" + @State private var pendingRuntimeExport: RuntimeExportRequest? + @State private var runtimeExportQueue: [RuntimeExportRequest] = [] + @State private var isExportingRuntimeAsset = false + @State private var exportConvertOrientation = true + @State private var exportSourceOrientation = "blender-native" @State private var pendingTilesExport: TilesExportRequest? @State private var tilesExportQueue: [TilesExportRequest] = [] @State private var isExportingTilesAsset = false @@ -378,6 +395,7 @@ struct AssetBrowserView: View { @State private var exportGenerateLOD = false @State private var exportDryRun = false var editor_addEntityWithAsset: () -> Void + var editor_loadSceneAuthoredFromAsset: (Asset) -> Void = { _ in } private var currentFolderPath: URL? { folderPathStack.last } @@ -426,6 +444,22 @@ struct AssetBrowserView: View { } .buttonStyle(PlainButtonStyle()) + Button(action: loadSelectedSceneAuthoredPayload) { + HStack(spacing: 6) { + Text("Load Authored") + Image(systemName: "camera.badge.ellipsis") + .foregroundColor(.white) + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(selectedSceneAuthoredAsset() == nil ? Color.gray.opacity(0.5) : Color.editorSecondaryAccent) + .foregroundColor(.white) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedSceneAuthoredAsset() == nil) + .help("Load scene-authored cameras and lights from the selected .untold asset or tiled scene manifest") Button(action: promptDeleteAsset) { HStack(spacing: 6) { Text("Delete") @@ -640,6 +674,9 @@ struct AssetBrowserView: View { Text("This will remove \(asset.name) from disk under your Asset Folder.") } } + .sheet(item: $pendingRuntimeExport) { request in + runtimeExportSheet(for: request) + } .sheet(item: $pendingTilesExport) { request in tilesExportSheet(for: request) } @@ -678,7 +715,9 @@ struct AssetBrowserView: View { // Set allowed file types based on category switch category { case .models, .animations: - openPanel.allowedContentTypes = [UTType(filenameExtension: runtimeAssetExtension)!] + openPanel.allowedContentTypes = ([runtimeAssetExtension] + sourceAssetExtensions.sorted()).compactMap { + UTType(filenameExtension: $0) + } case .streamModels: openPanel.allowedContentTypes = ([UTType(filenameExtension: "json")!] + sourceAssetExtensions.sorted().compactMap { UTType(filenameExtension: $0) }) case .scripts: @@ -745,6 +784,12 @@ struct AssetBrowserView: View { if sourceExtension == runtimeAssetExtension { try importRuntimeAsset(sourceURL: sourceURL, destinationFolder: destFolder, fileManager: fm) + } else if sourceAssetExtensions.contains(sourceExtension) { + queueRuntimeExport( + sourceURL: sourceURL, + category: category, + destinationFolder: destFolder + ) } case "StreamModels": @@ -792,7 +837,9 @@ struct AssetBrowserView: View { } loadAssets() - if tilesExportQueue.isEmpty, pendingTilesExport == nil { + if runtimeExportQueue.isEmpty, pendingRuntimeExport == nil, + tilesExportQueue.isEmpty, pendingTilesExport == nil + { showStatus("Queued import of \(openPanel.urls.count) item(s) (see Console)") } } @@ -809,6 +856,262 @@ struct AssetBrowserView: View { try copyRuntimeAssetSidecars(for: sourceURL, to: destinationFolder, fileManager: fm) } + private func queueRuntimeExport(sourceURL: URL, category: AssetCategory, destinationFolder: URL) { + let outputURL = destinationFolder + .appendingPathComponent(sourceURL.deletingPathExtension().lastPathComponent) + .appendingPathExtension(runtimeAssetExtension) + let request = RuntimeExportRequest( + sourceURL: sourceURL, + category: category, + destinationFolder: destinationFolder, + outputURL: outputURL + ) + + runtimeExportQueue.append(request) + presentNextRuntimeExportIfNeeded() + } + + private func presentNextRuntimeExportIfNeeded() { + guard pendingRuntimeExport == nil, !runtimeExportQueue.isEmpty else { + return + } + pendingRuntimeExport = runtimeExportQueue.removeFirst() + } + + private func runtimeExportSheet(for request: RuntimeExportRequest) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("Convert to Untold Asset") + .font(.title2) + .bold() + + Text("This USD file needs to be converted to Untold Engine's .untold runtime format before it can be added to your project.") + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 6) { + Text("Source") + .font(.caption) + .foregroundColor(.secondary) + Text(request.sourceURL.path) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + + Text("Output") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 6) + Text(request.outputURL.path) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Convert orientation", isOn: $exportConvertOrientation) + + Picker("Source orientation", selection: $exportSourceOrientation) { + Text("Blender native").tag("blender-native") + Text("Engine oriented").tag("engine-oriented") + } + .disabled(!exportConvertOrientation) + + Toggle("Compress geometry (LZ4)", isOn: $exportCompressGeometry) + .help("Compresses vertex and index data with LZ4. Requires the Python lz4 package.") + if exportCompressGeometry { + Text("Requires: pip install lz4") + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 20) + } + + Toggle("Compress textures (ASTC)", isOn: $exportCompressTextures) + .help("Converts textures to GPU-native ASTC format. Requires astcenc and the Python Pillow package.") + if exportCompressTextures { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Link("Install astcenc →", destination: URL(string: "https://github.com/ARM-software/astc-encoder/releases")!) + .font(.caption) + Text("·") + .font(.caption) + .foregroundColor(.secondary) + Text("Also requires: pip install Pillow") + .font(.caption) + .foregroundColor(.secondary) + } + VStack(alignment: .leading, spacing: 4) { + Text("astcenc path (optional)") + .font(.caption) + .foregroundColor(.secondary) + HStack { + TextField("/opt/homebrew/bin/astcenc", text: $astcencBinPath) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + Button("Browse…") { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.title = "Select astcenc binary" + if panel.runModal() == .OK, let url = panel.url { + astcencBinPath = url.path + } + } + } + } + } + .padding(.leading, 20) + } + } + + if isExportingRuntimeAsset { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Exporting...") + .foregroundColor(.secondary) + } + } + + HStack { + Spacer() + Button("Cancel") { + pendingRuntimeExport = nil + presentNextRuntimeExportIfNeeded() + } + .disabled(isExportingRuntimeAsset) + + Button("Export") { + exportRuntimeAsset(request) + } + .keyboardShortcut(.defaultAction) + .disabled(isExportingRuntimeAsset) + } + } + .padding(20) + .frame(width: 560) + } + + private func exportRuntimeAsset(_ request: RuntimeExportRequest) { + guard !isExportingRuntimeAsset else { return } + guard let exporterScript = findExportUntoldScript() else { + showStatus("export-untold script not found", isError: true) + Logger.log(message: "❌ export-untold script not found. Expected at .build/checkouts/UntoldEngine/scripts/export-untold") + pendingRuntimeExport = nil + presentNextRuntimeExportIfNeeded() + return + } + + isExportingRuntimeAsset = true + showStatus("Exporting \(request.sourceURL.lastPathComponent)...") + let convertOrientation = exportConvertOrientation + let sourceOrientation = exportSourceOrientation + let compressGeometry = exportCompressGeometry + let compressTextures = exportCompressTextures + let astcencBin = astcencBinPath.trimmingCharacters(in: .whitespacesAndNewlines) + + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let tempDirectory = FileManager.default.temporaryDirectory + let outputLogURL = tempDirectory.appendingPathComponent("untold-export-\(UUID().uuidString).out") + let errorLogURL = tempDirectory.appendingPathComponent("untold-export-\(UUID().uuidString).err") + + do { + try FileManager.default.createDirectory(at: request.destinationFolder, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: request.outputURL.path) { + try FileManager.default.removeItem(at: request.outputURL) + } + FileManager.default.createFile(atPath: outputLogURL.path, contents: nil) + FileManager.default.createFile(atPath: errorLogURL.path, contents: nil) + let outputHandle = try FileHandle(forWritingTo: outputLogURL) + let errorHandle = try FileHandle(forWritingTo: errorLogURL) + defer { + try? outputHandle.close() + try? errorHandle.close() + try? FileManager.default.removeItem(at: outputLogURL) + try? FileManager.default.removeItem(at: errorLogURL) + } + + process.executableURL = exporterScript + var arguments = [ + "--input", request.sourceURL.path, + "--output", request.outputURL.path, + ] + if request.category == .animations { + arguments.append("--animation") + } + if convertOrientation { + arguments.append("--ConvertOrientation") + arguments.append(contentsOf: ["--source-orientation", sourceOrientation]) + } + if compressGeometry { + arguments.append("--compress-geometry") + } + process.arguments = arguments + process.standardOutput = outputHandle + process.standardError = errorHandle + + try process.run() + process.waitUntilExit() + + let stdout = (try? String(contentsOf: outputLogURL, encoding: .utf8)) ?? "" + let stderr = (try? String(contentsOf: errorLogURL, encoding: .utf8)) ?? "" + + DispatchQueue.main.async { + if !stdout.isEmpty { Logger.log(message: stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !stderr.isEmpty { Logger.log(message: stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + } + + let exportSucceeded = process.terminationStatus == 0 + + if exportSucceeded, compressTextures { + let texturesDir = request.destinationFolder.appendingPathComponent("Textures") + if FileManager.default.fileExists(atPath: texturesDir.path), + let texbakeScript = findTexbakeScript() + { + DispatchQueue.main.async { showStatus("Baking textures (ASTC)...") } + let bakeResult = runTexbakeStep(script: texbakeScript, arguments: ["--dir", texturesDir.path], astcencBin: astcencBin) + DispatchQueue.main.async { + if !bakeResult.stdout.isEmpty { Logger.log(message: bakeResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !bakeResult.stderr.isEmpty { Logger.log(message: bakeResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + } + + DispatchQueue.main.async { showStatus("Patching texture references...") } + let patchResult = runTexbakeStep(script: texbakeScript, arguments: ["--patch-refs", request.outputURL.path], astcencBin: astcencBin) + DispatchQueue.main.async { + if !patchResult.stdout.isEmpty { Logger.log(message: patchResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !patchResult.stderr.isEmpty { Logger.log(message: patchResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + if bakeResult.status != 0 || patchResult.status != 0 { + Logger.log(message: "⚠️ ASTC compression had errors — asset imported without compressed textures") + } + } + } else { + DispatchQueue.main.async { + Logger.log(message: "⚠️ ASTC skipped — texbake.py not found or no Textures folder present") + } + } + } + + DispatchQueue.main.async { + isExportingRuntimeAsset = false + pendingRuntimeExport = nil + if exportSucceeded { + loadAssets() + showStatus("Exported \(request.outputURL.lastPathComponent)") + } else { + showStatus("Export failed for \(request.sourceURL.lastPathComponent)", isError: true) + } + presentNextRuntimeExportIfNeeded() + } + } catch { + DispatchQueue.main.async { + isExportingRuntimeAsset = false + pendingRuntimeExport = nil + Logger.log(message: "❌ Export failed: \(error)") + showStatus("Export failed for \(request.sourceURL.lastPathComponent)", isError: true) + presentNextRuntimeExportIfNeeded() + } + } + } + } + private func queueTilesExport(sourceURL: URL, destinationFolder: URL) { let outputDirURL = destinationFolder.appendingPathComponent("tile_exports", isDirectory: true) let request = TilesExportRequest( @@ -1304,6 +1607,41 @@ struct AssetBrowserView: View { updateTargetEntityName(for: selectionManager.selectedEntity) } + private func selectedSceneAuthoredAsset() -> Asset? { + guard let selectedAsset else { + return nil + } + + if let runtimeAsset = resolvedRuntimeAsset(for: selectedAsset), + runtimeAsset.category == AssetCategory.models.rawValue, + runtimeAsset.path.pathExtension.lowercased() == runtimeAssetExtension + { + return runtimeAsset + } + + if let manifestAsset = resolvedTiledSceneManifest(for: selectedAsset) { + return manifestAsset + } + + if selectedAsset.category == AssetCategory.streamModels.rawValue, + selectedAsset.path.pathExtension.lowercased() == "remotestream" + { + return selectedAsset + } + + return nil + } + + private func loadSelectedSceneAuthoredPayload() { + guard let asset = selectedSceneAuthoredAsset() else { + showStatus("Select a .untold model or tiled scene manifest first", isError: true) + return + } + + editor_loadSceneAuthoredFromAsset(asset) + showStatus("Loading authored cameras/lights: \(asset.name)...") + } + // MARK: - Delete Asset private func deleteAsset(_ asset: Asset) { diff --git a/Sources/UntoldEditor/Editor/DemoGalleryView.swift b/Sources/UntoldEditor/Editor/DemoGalleryView.swift new file mode 100644 index 0000000..b036e92 --- /dev/null +++ b/Sources/UntoldEditor/Editor/DemoGalleryView.swift @@ -0,0 +1,484 @@ +// +// DemoGalleryView.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import SwiftUI + +struct DemoGalleryView: View { + let demos: [DemoSceneCatalogItem] + var onDemoSelected: (DemoSceneCatalogItem) -> Void + var onTryOwnScene: () -> Void + var onCreateProject: () -> Void + var onOpenProject: () -> Void + var onOpenFullEditor: () -> Void + + private let columns = [ + GridItem(.adaptive(minimum: 190), spacing: 14, alignment: .top), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + header + + ScrollView { + LazyVGrid(columns: columns, alignment: .leading, spacing: 14) { + ForEach(demos) { demo in + DemoSceneCard(demo: demo) { + onDemoSelected(demo) + } + } + } + .padding(.vertical, 2) + } + .frame(maxHeight: 420) + + footer + } + .padding(20) + .frame(maxWidth: 760) + .background( + LinearGradient( + colors: [ + Color.editorPanelBackground.opacity(0.98), + Color.editorBackground.opacity(0.98), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .cornerRadius(14) + .shadow(color: .black.opacity(0.34), radius: 24, x: 0, y: 14) + } + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Explore Untold Engine") + .font(.largeTitle.bold()) + .foregroundColor(.white) + + Text("Open a ready-to-navigate scene. No project setup, asset import, or scene graph knowledge required.") + .font(.body) + .foregroundColor(.white.opacity(0.78)) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button(action: onOpenFullEditor) { + Label("Full Editor", systemImage: "slider.horizontal.3") + } + .buttonStyle(.bordered) + .tint(Color.editorSecondaryAccent) + .focusable(false) + } + } + + private var footer: some View { + HStack(spacing: 10) { + Button(action: onTryOwnScene) { + Label("Try Your Own Scene", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + .tint(Color.editorAccent) + .focusable(false) + Button(action: onCreateProject) { + Label("Create Project", systemImage: "hammer.fill") + } + .focusable(false) + + Button(action: onOpenProject) { + Label("Open Project", systemImage: "folder.fill") + } + .focusable(false) + + Spacer() + + Text("You can switch to the full editor after loading a scene.") + .font(.caption) + .foregroundColor(.white.opacity(0.58)) + } + } +} + +struct PreviewImportGalleryView: View { + var onModeSelected: (QuickPreviewImportMode) -> Void + var onBackToDemos: () -> Void + var onOpenFullEditor: () -> Void + + private let columns = [ + GridItem(.adaptive(minimum: 180), spacing: 14, alignment: .top), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + header + + LazyVGrid(columns: columns, alignment: .leading, spacing: 14) { + ForEach(QuickPreviewImportMode.allCases, id: \.self) { mode in + PreviewImportCard(mode: mode) { + onModeSelected(mode) + } + } + } + + preparationGuide + } + .padding(20) + .frame(maxWidth: 760) + .background( + LinearGradient( + colors: [ + Color.editorPanelBackground.opacity(0.98), + Color.editorBackground.opacity(0.98), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .cornerRadius(14) + .shadow(color: .black.opacity(0.34), radius: 24, x: 0, y: 14) + } + + private var header: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("Try Your Own Scene") + .font(.largeTitle.bold()) + .foregroundColor(.white) + + Text("Load an exported Untold scene file without creating a project. You can navigate immediately, then open the full editor when you are ready.") + .font(.body) + .foregroundColor(.white.opacity(0.78)) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Button("Back to Demos", action: onBackToDemos) + .buttonStyle(.bordered) + .tint(Color.editorSecondaryAccent) + .focusable(false) + + Button(action: onOpenFullEditor) { + Label("Full Editor", systemImage: "slider.horizontal.3") + } + .buttonStyle(.bordered) + .tint(Color.editorSecondaryAccent) + .focusable(false) + } + } + + private var preparationGuide: some View { + VStack(alignment: .leading, spacing: 10) { + Label("Need to create one of these files?", systemImage: "wand.and.stars") + .font(.headline) + .foregroundColor(.white) + + Text("Export from Blender with the Untold exporter add-on, or use the CLI exporter to produce .untold runtime assets and tiled .json scene manifests. Gaussian splats can be loaded directly from .ply files.") + .font(.caption) + .foregroundColor(.white.opacity(0.68)) + .fixedSize(horizontal: false, vertical: true) + + Link( + "Get the Blender add-on and installation steps", + destination: URL(string: "https://untoldengine.github.io/UntoldEngine/API/UsingBlenderAddon/")! + ) + .font(.caption.weight(.semibold)) + .foregroundColor(Color.editorAccent) + .focusable(false) + + HStack(spacing: 8) { + Text("1. Install Blender") + Text("2. Install Untold exporter") + Text("3. Export") + Text("4. Load here") + } + .font(.caption2.weight(.semibold)) + .foregroundColor(.white.opacity(0.72)) + } + .padding(12) + .background(Color.editorSurface.opacity(0.56)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .cornerRadius(10) + } +} + +private struct PreviewImportCard: View { + let mode: QuickPreviewImportMode + var onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.editorAccentSoft) + + Image(systemName: mode.systemImageName) + .font(.system(size: 38, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + } + .frame(height: 96) + + Text(mode.exploreTitle) + .font(.headline) + .foregroundColor(.white) + + Text(mode.exploreSubtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.68)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + + Text(mode.exploreFileTypes) + .font(.caption2.weight(.semibold)) + .foregroundColor(.white.opacity(0.74)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.18)) + .cornerRadius(7) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.editorSurface.opacity(0.72)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .cornerRadius(12) + } + .buttonStyle(.plain) + .focusable(false) + } +} + +struct QuickPreviewSceneOverlayView: View { + let title: String + let mode: QuickPreviewImportMode? + var onLoadAnother: () -> Void + var onChooseDemo: () -> Void + var onOpenFullEditor: () -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundColor(.white) + Text(mode?.exploreLoadedSubtitle ?? "Your Scene") + .font(.caption) + .foregroundColor(.white.opacity(0.66)) + } + + Spacer() + + Button("Load Another", action: onLoadAnother) + .focusable(false) + Button("Demo Gallery", action: onChooseDemo) + .focusable(false) + Button(action: onOpenFullEditor) { + Label("Full Editor", systemImage: "slider.horizontal.3") + } + .focusable(false) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.editorPanelBackground.opacity(0.92)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .cornerRadius(10) + .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8) + } +} + +private extension QuickPreviewImportMode { + var exploreTitle: String { + switch self { + case .untoldAsset: + return "Untold Asset" + case .tiledScene: + return "Tiled Scene" + case .gaussian: + return "Gaussian Splat" + } + } + + var exploreSubtitle: String { + switch self { + case .untoldAsset: + return "Open a runtime asset exported from Blender or converted from USD." + case .tiledScene: + return "Open a tiled stream manifest for larger scenes." + case .gaussian: + return "Open a Gaussian splat point-cloud scene." + } + } + + var exploreFileTypes: String { + switch self { + case .untoldAsset: + return ".untold, USD" + case .tiledScene: + return ".json" + case .gaussian: + return ".ply" + } + } + + var exploreLoadedSubtitle: String { + switch self { + case .untoldAsset: + return "Untold Asset Preview" + case .tiledScene: + return "Tiled Scene Preview" + case .gaussian: + return "Gaussian Splat Preview" + } + } +} + +private struct DemoSceneCard: View { + let demo: DemoSceneCatalogItem + var onSelect: () -> Void + + private var thumbnailImage: NSImage? { + for ext in ["png", "jpg", "jpeg"] { + if let url = Bundle.module.url(forResource: demo.thumbnailName, withExtension: ext), + let img = NSImage(contentsOf: url) + { + return img + } + } + return nil + } + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill( + LinearGradient( + colors: [ + Color.editorAccentSoft, + Color.editorSecondaryAccent.opacity(0.18), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + if let img = thumbnailImage { + Image(nsImage: img) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, maxHeight: 112) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + Image(systemName: demo.systemImageName) + .font(.system(size: 42, weight: .semibold)) + .foregroundColor(.white.opacity(0.88)) + } + } + .frame(height: 112) + + VStack(alignment: .leading, spacing: 6) { + Text(demo.title) + .font(.headline) + .foregroundColor(.white) + + Text(demo.subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.68)) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + + tagRow + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.editorSurface.opacity(0.72)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .cornerRadius(12) + } + .buttonStyle(.plain) + .focusable(false) + } + + private var tagRow: some View { + HStack(spacing: 6) { + ForEach(demo.tags.prefix(3), id: \.self) { tag in + Text(tag) + .font(.caption2.weight(.semibold)) + .foregroundColor(.white.opacity(0.74)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.18)) + .cornerRadius(7) + } + } + } +} + +struct ExploreSceneOverlayView: View { + let demo: DemoSceneCatalogItem + var onChooseAnotherDemo: () -> Void + var onResetCamera: () -> Void + var onOpenFullEditor: () -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(demo.title) + .font(.headline) + .foregroundColor(.white) + Text("Explore Mode") + .font(.caption) + .foregroundColor(.white.opacity(0.66)) + } + + Spacer() + + Button("Choose Another Demo", action: onChooseAnotherDemo) + .focusable(false) + Button("Reset View", action: onResetCamera) + .focusable(false) + Button(action: onOpenFullEditor) { + Label("Full Editor", systemImage: "slider.horizontal.3") + } + .focusable(false) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.editorPanelBackground.opacity(0.92)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.editorDivider, lineWidth: 1) + ) + .cornerRadius(10) + .shadow(color: .black.opacity(0.25), radius: 14, x: 0, y: 8) + } +} diff --git a/Sources/UntoldEditor/Editor/DemoSceneCatalog.swift b/Sources/UntoldEditor/Editor/DemoSceneCatalog.swift new file mode 100644 index 0000000..6bdab82 --- /dev/null +++ b/Sources/UntoldEditor/Editor/DemoSceneCatalog.swift @@ -0,0 +1,101 @@ +// +// DemoSceneCatalog.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import Foundation + +enum EditorExperienceMode { + case explore + case edit +} + +enum DemoSceneSource { + case remoteManifest(URL) + case bundledManifest(resourceName: String, fileExtension: String) + case bundledAsset(resourceName: String, fileExtension: String) + + var resolvedURL: URL? { + switch self { + case let .remoteManifest(url): + return url + case let .bundledManifest(resourceName, fileExtension), + let .bundledAsset(resourceName, fileExtension): + return Bundle.main.url(forResource: resourceName, withExtension: fileExtension) + } + } + + var fileExtension: String { + switch self { + case let .remoteManifest(url): + return url.pathExtension.isEmpty ? "json" : url.pathExtension.lowercased() + case let .bundledManifest(_, fileExtension), + let .bundledAsset(_, fileExtension): + return fileExtension.lowercased() + } + } +} + +struct DemoSceneCatalogItem: Identifiable { + let id: String + let title: String + let subtitle: String + let thumbnailName: String + let systemImageName: String + let tags: [String] + let source: DemoSceneSource + let cameraFrame: StreamModelCameraFrame? + let loadsSceneAuthoredPayload: Bool +} + +private let starterDemoMetadata: [String: (subtitle: String, systemImageName: String, tags: [String])] = [ + "dungeon": ( + subtitle: "A compact realtime dungeon scene for quick camera navigation.", + systemImageName: "cube.transparent", + tags: ["Streamed", "Realtime"] + ), + "city": ( + subtitle: "A stylized city stream that demonstrates larger scene navigation.", + systemImageName: "building.2", + tags: ["Streamed", "City"] + ), + "f1car": ( + subtitle: "A vehicle showcase with a simple orbit-friendly camera frame.", + systemImageName: "car.side", + tags: ["Showcase", "Orbit"] + ), + "airplane": ( + subtitle: "A small aircraft scene for inspecting model detail.", + systemImageName: "airplane", + tags: ["Showcase", "Orbit"] + ), + "porsche964": ( + subtitle: "A car presentation scene with a guided starter view.", + systemImageName: "car", + tags: ["Showcase", "Vehicle"] + ), +] + +let demoSceneCatalog: [DemoSceneCatalogItem] = starterStreamModels.map { item in + let metadata = starterDemoMetadata[item.id] ?? ( + subtitle: "A ready-to-explore Untold Engine demo scene.", + systemImageName: "sparkles.rectangle.stack", + tags: ["Demo"] + ) + + return DemoSceneCatalogItem( + id: item.id, + title: item.title, + subtitle: metadata.subtitle, + thumbnailName: item.id, + systemImageName: metadata.systemImageName, + tags: metadata.tags, + source: .remoteManifest(item.manifestURL), + cameraFrame: item.cameraFrame, + loadsSceneAuthoredPayload: false + ) +} diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index a24f829..eb10169 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -11,89 +11,6 @@ public struct Asset: Identifiable { var isFolder: Bool = false } -private struct WelcomeStartView: View { - var onStarterStreamSelected: (StreamModelCatalogItem) -> Void - var onQuickPreview: (QuickPreviewImportMode) -> Void - var onNewProject: () -> Void - var onOpenProject: () -> Void - var onDismiss: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("Start Here") - .font(.headline) - .foregroundColor(.white) - Spacer() - Button(action: onDismiss) { - Image(systemName: "xmark") - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.white.opacity(0.8)) - .frame(width: 24, height: 24) - } - .buttonStyle(.plain) - .focusable(false) - .help("Dismiss") - } - - Menu { - ForEach(starterStreamModels) { item in - Button { - onStarterStreamSelected(item) - } label: { - Label(item.title, systemImage: "square.stack.3d.up.fill") - } - } - } label: { - Label("Starter Streams", systemImage: "square.stack.3d.up.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(Color.editorSecondaryAccent) - .menuStyle(.button) - .focusable(false) - - HStack(spacing: 8) { - Menu { - ForEach(QuickPreviewImportMode.allCases, id: \.self) { mode in - Button { - onQuickPreview(mode) - } label: { - Label(mode.menuTitle, systemImage: mode.systemImageName) - } - } - } label: { - Label("Load Preview", systemImage: "eye.fill") - .frame(maxWidth: .infinity) - } - .menuStyle(.button) - .focusable(false) - - Button(action: onNewProject) { - Label("New", systemImage: "hammer.fill") - .frame(maxWidth: .infinity) - } - .focusable(false) - - Button(action: onOpenProject) { - Label("Open", systemImage: "folder.fill") - .frame(maxWidth: .infinity) - } - .focusable(false) - } - } - .padding(16) - .frame(width: 360) - .background(Color.editorPanelBackground.opacity(0.96)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.editorDivider, lineWidth: 1) - ) - .cornerRadius(8) - .shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 8) - } -} - private struct CameraControlHintsView: View { var onDismiss: () -> Void @@ -159,6 +76,22 @@ public struct EditorView: View { @State private var cameraControlHintsDismissed = false @State private var showQuickPreviewWarning = false @State private var quickPreviewEntities: [(EntityID, String)] = [] + @State private var sceneAuthoredGameCamera: EntityID? + @State private var pendingQuickPreviewExport: QuickPreviewRuntimeExportRequest? + @State private var isExportingQuickPreviewAsset = false + @State private var quickPreviewConvertOrientation = false + @State private var quickPreviewSourceOrientation = "blender-native" + @State private var quickPreviewCompressGeometry = false + @State private var quickPreviewCompressTextures = false + @State private var quickPreviewAstcencBinPath = "" + @State private var experienceMode: EditorExperienceMode = .explore + @State private var showDemoGallery = true + @State private var showPreviewImportGallery = false + @State private var activeDemoScene: DemoSceneCatalogItem? + @State private var activeDemoCameraFrame: StreamModelCameraFrame? + @State private var activePreviewSceneTitle: String? + @State private var activePreviewImportMode: QuickPreviewImportMode? + @State private var pendingQuickPreviewLoadsInExplore = false var renderer: UntoldRenderer? @@ -167,6 +100,9 @@ public struct EditorView: View { _selectionManager = StateObject(wrappedValue: sharedSelectionManager) editorController = EditorController(selectionManager: sharedSelectionManager) renderer = UntoldRenderer.create(configuration: .editor) + // Extensions that create pipelines must be registered after the renderer + // has initialized Metal and loaded the engine shader library. + registerEditorRenderExtension() if let r = renderer, let v = renderer?.metalView { r.setupCallbacks(gameUpdate: { _ in }, handleInput: r.handleSceneInput) @@ -182,129 +118,80 @@ public struct EditorView: View { public var body: some View { ZStack { VStack { - ToolbarView( - selectionManager: selectionManager, - onSave: editor_handleSave, - onSaveAs: editor_handleSaveAs, - onClear: editor_clearScene, - onPlayToggled: { isPlaying in - editor_handlePlayToggle(isPlaying) - }, - useSceneCameraDuringPlay: $useSceneCameraDuringPlay, - dirLightCreate: editor_createDirLight, - pointLightCreate: editor_createPointLight, - spotLightCreate: editor_createSpotLight, - areaLightCreate: editor_createAreaLight, - onCreateCube: editor_createCube, - onCreateSphere: editor_createSphere, - onCreatePlane: editor_createPlane, - onCreateCylinder: editor_createCylinder, - onCreateCone: editor_createCone, - onStarterStreamSelected: editor_loadStarterStream, - onQuickPreview: editor_handleQuickPreview - ) - Divider() + if experienceMode == .edit { + editorToolbar + Divider() + } HStack { - VStack { - 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, - onParentEntity: editor_parentEntity, - onUnparentEntity: editor_unparentEntity - ) + if experienceMode == .edit { + VStack { + 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, + onParentEntity: editor_parentEntity, + onUnparentEntity: editor_unparentEntity + ) + } } VStack(spacing: 0) { - EditorSceneView(renderer: renderer!) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay(alignment: .topLeading) { - EngineStatsOverlayView() + editorSceneViewport + if experienceMode == .edit { + TransformManipulationToolbar(controller: editorController!) + .frame(height: 40) + TabView { + AssetBrowserView( + assets: $assets, + selectedAsset: $selectedAsset, + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + editor_addEntityWithAsset: editor_addEntityWithAsset, + editor_loadSceneAuthoredFromAsset: editor_loadSceneAuthoredFromAsset + ) + .tabItem { Label("Assets", systemImage: "shippingbox") } + + LogConsoleView() + .tabItem { Label("Console", systemImage: "terminal") } } - .overlay { - if shouldShowWelcomeStart { - WelcomeStartView( - onStarterStreamSelected: { item in - showWelcomeStart = false - editor_loadStarterStream(item) - }, - onQuickPreview: { mode in - showWelcomeStart = false - editor_handleQuickPreview(mode: mode) - }, - onNewProject: { - showWelcomeStart = false - showCreateProject = true - }, - onOpenProject: { - showWelcomeStart = false - openExistingProjectFromWelcome() - }, - onDismiss: { - showWelcomeStart = false - } - ) - .padding() + .frame(height: 200) + .clipped() + } + } + + if experienceMode == .edit { + TabView { + EnvironmentView(selectedAsset: $selectedAsset) + .tabItem { + Label("Environment", systemImage: "sun.max") } - } - .overlay(alignment: .bottom) { - if shouldShowCameraControlHints { - CameraControlHintsView { - dismissCameraControlHints() - } - .padding(.bottom, 14) + + PostProcessingEditorView() + .tabItem { + Label("Effects", systemImage: "cube") } - } - TransformManipulationToolbar(controller: editorController!) - .frame(height: 40) - TabView { - AssetBrowserView( - assets: $assets, - selectedAsset: $selectedAsset, + + InspectorView( selectionManager: selectionManager, sceneGraphModel: sceneGraphModel, - editor_addEntityWithAsset: editor_addEntityWithAsset + onAddName_Editor: editor_addName, + selectedAsset: $selectedAsset ) - .tabItem { Label("Assets", systemImage: "shippingbox") } - - LogConsoleView() - .tabItem { Label("Console", systemImage: "terminal") } - } - .frame(height: 200) - .clipped() - } - - TabView { - EnvironmentView(selectedAsset: $selectedAsset) - .tabItem { - Label("Environment", systemImage: "sun.max") - } - - PostProcessingEditorView() .tabItem { - Label("Effects", systemImage: "cube") + Label("Inspector", systemImage: "cube") } - - InspectorView( - selectionManager: selectionManager, - sceneGraphModel: sceneGraphModel, - onAddName_Editor: editor_addName, - selectedAsset: $selectedAsset - ) - .tabItem { - Label("Inspector", systemImage: "cube") } + .frame(minWidth: 200, maxWidth: 250) } - .frame(minWidth: 200, maxWidth: 250) } } .background( @@ -328,6 +215,7 @@ public struct EditorView: View { } sceneGraphModel.refreshHierarchy() + syncEditorAvailabilityForExperienceMode() // Listen for asset instance loading completion NotificationCenter.default.addObserver( @@ -351,28 +239,11 @@ public struct EditorView: View { .onChange(of: useSceneCameraDuringPlay) { _, _ in updateActiveCameraForPlayMode() } + .onChange(of: experienceMode) { _, _ in + syncEditorAvailabilityForExperienceMode() + } .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) + saveScenePrompt } .alert("Overwrite Scene?", isPresented: $showOverwriteAlert) { Button("Cancel", role: .cancel) { @@ -410,18 +281,173 @@ public struct EditorView: View { let entityWord = count == 1 ? "entity" : "entities" return Text("Your scene contains \(count) Quick Preview \(entityWord):\n\n\(entityNames)\n\nQuick Preview entities use absolute file paths and cannot be saved to scenes. To include these assets permanently, use the Import button in the Asset Browser to copy them into your project first.\n\nYou can delete the Quick Preview entities and save the rest of your scene, or cancel to keep working.") } + .sheet(item: $pendingQuickPreviewExport) { request in + quickPreviewRuntimeExportSheet(for: request) + } + } + + private var editorSceneViewport: some View { + EditorSceneView(renderer: renderer!) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topLeading) { + EngineStatsOverlayView() + } + .overlay { + if shouldShowDemoGallery { + DemoGalleryView( + demos: demoSceneCatalog, + onDemoSelected: editor_loadDemoScene, + onTryOwnScene: showPreviewImportChooser, + onCreateProject: createProjectFromExplore, + onOpenProject: openProjectFromExplore, + onOpenFullEditor: switchToEditMode + ) + .padding() + } + } + .overlay { + if shouldShowPreviewImportGallery { + PreviewImportGalleryView( + onModeSelected: loadCustomPreviewScene, + onBackToDemos: showDemoChooser, + onOpenFullEditor: switchToEditMode + ) + .padding() + } + } + .overlay(alignment: .top) { + if shouldShowExploreSceneOverlay, let activeDemoScene { + ExploreSceneOverlayView( + demo: activeDemoScene, + onChooseAnotherDemo: showDemoChooser, + onResetCamera: resetActiveDemoCamera, + onOpenFullEditor: switchToEditMode + ) + .padding(.top, 12) + .padding(.horizontal, 16) + } + } + .overlay(alignment: .top) { + if shouldShowQuickPreviewSceneOverlay, let activePreviewSceneTitle { + QuickPreviewSceneOverlayView( + title: activePreviewSceneTitle, + mode: activePreviewImportMode, + onLoadAnother: showPreviewImportChooser, + onChooseDemo: showDemoChooser, + onOpenFullEditor: switchToEditMode + ) + .padding(.top, 12) + .padding(.horizontal, 16) + } + } + .overlay(alignment: .bottom) { + if shouldShowCameraControlHints { + CameraControlHintsView { + dismissCameraControlHints() + } + .padding(.bottom, 14) + } + } } - private var shouldShowWelcomeStart: Bool { + private var editorToolbar: some View { + ToolbarView( + selectionManager: selectionManager, + onSave: editor_handleSave, + onSaveAs: editor_handleSaveAs, + onClear: editor_clearScene, + onPlayToggled: { isPlaying in + editor_handlePlayToggle(isPlaying) + }, + useSceneCameraDuringPlay: $useSceneCameraDuringPlay, + dirLightCreate: editor_createDirLight, + pointLightCreate: editor_createPointLight, + spotLightCreate: editor_createSpotLight, + areaLightCreate: editor_createAreaLight, + onCreateCube: editor_createCube, + onCreateSphere: editor_createSphere, + onCreatePlane: editor_createPlane, + onCreateCylinder: editor_createCylinder, + onCreateCone: editor_createCone + ) + } + + private var saveScenePrompt: some View { + 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) + } + + private func completeDemoSceneAuthoredLoad( + _ success: Bool, + existingEntityIds: Set, + completion: (Bool) -> Void + ) { + guard success else { + completion(false) + return + } + + removeDefaultSceneAuthoredEntities(existingEntityIds: existingEntityIds) + let importedCamera = findImportedGameCamera(existingEntityIds: existingEntityIds) + sceneAuthoredGameCamera = importedCamera + if let importedCamera { + applyGameCameraFrameToSceneCamera(importedCamera) + } + completion(importedCamera != nil) + } + + private var shouldShowDemoGallery: Bool { + showWelcomeStart + && showDemoGallery + && showPreviewImportGallery == false + && experienceMode == .explore + && editorBasePath.basePath == nil + } + + private var shouldShowPreviewImportGallery: Bool { showWelcomeStart + && showPreviewImportGallery + && experienceMode == .explore && editorBasePath.basePath == nil - && hasQuickPreviewContent() == false + } + + private var shouldShowExploreSceneOverlay: Bool { + experienceMode == .explore + && showDemoGallery == false + && showPreviewImportGallery == false + && activeDemoScene != nil + } + + private var shouldShowQuickPreviewSceneOverlay: Bool { + experienceMode == .explore + && showDemoGallery == false + && showPreviewImportGallery == false + && activePreviewSceneTitle != nil } private var shouldShowCameraControlHints: Bool { showCameraControlHints - && shouldShowWelcomeStart == false - && hasQuickPreviewContent() + && shouldShowDemoGallery == false + && (hasQuickPreviewContent() || activeDemoScene != nil) } private func hasQuickPreviewContent() -> Bool { @@ -443,6 +469,76 @@ public struct EditorView: View { showCameraControlHints = false } + private func syncEditorAvailabilityForExperienceMode() { + editorController?.isEnabled = experienceMode == .edit + + if experienceMode == .explore { + enableExploreNavigationMode() + clearExploreSelection() + } else { + disableExploreNavigationMode() + } + } + + private func switchToEditMode() { + experienceMode = .edit + showDemoGallery = false + showPreviewImportGallery = false + disableExploreNavigationMode() + } + + private func createProjectFromExplore() { + switchToEditMode() + showWelcomeStart = false + showCreateProject = true + } + + private func openProjectFromExplore() { + switchToEditMode() + showWelcomeStart = false + openExistingProjectFromWelcome() + } + + private func showDemoChooser() { + experienceMode = .explore + showDemoGallery = true + showPreviewImportGallery = false + activePreviewSceneTitle = nil + activePreviewImportMode = nil + showCameraControlHints = false + enableExploreNavigationMode() + } + + private func showPreviewImportChooser() { + experienceMode = .explore + showWelcomeStart = true + showDemoGallery = false + showPreviewImportGallery = true + activeDemoScene = nil + activeDemoCameraFrame = nil + activePreviewSceneTitle = nil + activePreviewImportMode = nil + showCameraControlHints = false + enableExploreNavigationMode() + } + + private func loadCustomPreviewScene(mode: QuickPreviewImportMode) { + showDemoGallery = false + showPreviewImportGallery = false + activeDemoScene = nil + activeDemoCameraFrame = nil + enableExploreNavigationMode() + editor_handleQuickPreview(mode: mode, fromExploreMode: true) + } + + private func resetActiveDemoCamera() { + guard let activeDemoCameraFrame else { + return + } + + applyCameraFrame(activeDemoCameraFrame) + } + private func openExistingProjectFromWelcome() { let panel = NSOpenPanel() panel.canChooseFiles = false @@ -613,6 +709,7 @@ public struct EditorView: View { removeGizmo() EditorComponentsState.shared.clear() EditorUndoManager.shared.clear() + sceneAuthoredGameCamera = nil deserializeScene(sceneData: sceneData) editorController?.currentSceneURL = nil editor_entities = getAllGameEntities() @@ -631,6 +728,7 @@ public struct EditorView: View { removeGizmo() EditorComponentsState.shared.clear() EditorUndoManager.shared.clear() + sceneAuthoredGameCamera = nil let light = createEntity() setEntityName(entityId: light, name: "Directional Light") @@ -661,7 +759,7 @@ public struct EditorView: View { return } - let gameCameraEntityID = findGameCamera() + let gameCameraEntityID = findEditorGameCamera() if gameCameraEntityID == .invalid { return @@ -734,22 +832,42 @@ public struct EditorView: View { } private func editor_handlePlayToggle(_ isPlaying: Bool) { - self.isPlaying = isPlaying - gameMode = !gameMode + setEditorPlayMode(isPlaying) + } + + private func setEditorPlayMode(_ shouldPlay: Bool) { + let didChangePlayState = isPlaying != shouldPlay || gameMode != shouldPlay + isPlaying = shouldPlay + gameMode = shouldPlay updateActiveCameraForPlayMode() - AnimationSystem.shared.isEnabled = isPlaying + AnimationSystem.shared.isEnabled = shouldPlay + guard didChangePlayState else { + return + } // Start/stop USC System - if gameMode { + if shouldPlay { USCSystem.shared.startPlayMode() } else { USCSystem.shared.stopPlayMode() } } + private func enableExploreNavigationMode() { + useSceneCameraDuringPlay = true + setEditorPlayMode(true) + CameraSystem.shared.activeCamera = findSceneCamera() + } + + private func disableExploreNavigationMode() { + if isPlaying { + setEditorPlayMode(false) + } + } + private func updateActiveCameraForPlayMode() { if gameMode { - CameraSystem.shared.activeCamera = useSceneCameraDuringPlay ? findSceneCamera() : findGameCamera() + CameraSystem.shared.activeCamera = useSceneCameraDuringPlay ? findSceneCamera() : findEditorGameCamera() } else { CameraSystem.shared.activeCamera = findSceneCamera() } @@ -976,47 +1094,162 @@ public struct EditorView: View { // MARK: - Quick Preview - private func editor_loadStarterStream(_ item: StreamModelCatalogItem) { - deleteExistingQuickPreviewEntities() + private func editor_loadDemoScene(_ demo: DemoSceneCatalogItem) { + guard let sourceURL = demo.source.resolvedURL else { + Logger.log(message: "⚠️ Demo source not found: \(demo.title)") + showDemoGallery = true + return + } + + experienceMode = .explore + showWelcomeStart = true + showDemoGallery = false + showPreviewImportGallery = false + activeDemoScene = demo + activeDemoCameraFrame = demo.cameraFrame + activePreviewSceneTitle = nil + activePreviewImportMode = nil + enableExploreNavigationMode() + deleteExistingQuickPreviewEntities() + clearSceneBatches() removeGizmo() - let entityId = createEntity() - let uniqueName = "QuickPreview-\(item.title)-\(entityId)" - setEntityName(entityId: entityId, name: uniqueName) + clearExploreSelection() - if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { - quickPreviewComp.absoluteFilePath = item.manifestURL.absoluteString - quickPreviewComp.fileExtension = "json" - quickPreviewComp.originalFileName = item.title + switch demo.source { + case .remoteManifest, .bundledManifest: + loadDemoStreamScene(demo, manifestURL: sourceURL) + case .bundledAsset: + loadDemoRuntimeAsset(demo, assetURL: sourceURL) } + } + + private func loadDemoStreamScene(_ demo: DemoSceneCatalogItem, manifestURL: URL) { + let existingEntityIds = Set(getAllGameEntities()) + let entityId = createDemoPreviewEntity( + title: demo.title, + sourceURL: manifestURL, + fileExtension: "json" + ) - clearSceneBatches() GeometryStreamingSystem.shared.enabled = true - setEntityStreamScene(entityId: entityId, url: item.manifestURL) { success in + setEntityStreamScene(entityId: entityId, url: manifestURL) { success in DispatchQueue.main.async { - if success { - applyStarterStreamCameraFrame(item) - revealCameraControlHintsIfNeeded() - print("✅ Starter stream loaded: \(item.title)") - } else { - print("⚠️ Failed to load starter stream: \(item.title)") + guard success else { + Logger.log(message: "⚠️ Failed to load demo scene: \(demo.title)") + showDemoGallery = true + return + } + + loadDemoSceneAuthoredIfNeeded( + demo, + url: manifestURL, + isRuntimeAsset: false, + existingEntityIds: existingEntityIds + ) { didApplyAuthoredCamera in + completeDemoSceneLoad(demo, didApplyAuthoredCamera: didApplyAuthoredCamera) } - sceneGraphModel.refreshHierarchy() } } + } - selectionManager.selectedEntity = entityId + private func loadDemoRuntimeAsset(_ demo: DemoSceneCatalogItem, assetURL: URL) { + let existingEntityIds = Set(getAllGameEntities()) + let fileExtension = demo.source.fileExtension + let entityId = createDemoPreviewEntity( + title: demo.title, + sourceURL: assetURL, + fileExtension: fileExtension + ) + + GeometryStreamingSystem.shared.enabled = false + + setEntityMeshAsync(entityId: entityId, filename: assetURL.path, withExtension: fileExtension) { success in + DispatchQueue.main.async { + guard success else { + Logger.log(message: "⚠️ Failed to load demo asset: \(demo.title)") + showDemoGallery = true + return + } + + loadDemoSceneAuthoredIfNeeded( + demo, + url: assetURL, + isRuntimeAsset: true, + existingEntityIds: existingEntityIds + ) { didApplyAuthoredCamera in + completeDemoSceneLoad(demo, didApplyAuthoredCamera: didApplyAuthoredCamera) + } + } + } + } + + private func createDemoPreviewEntity(title: String, sourceURL: URL, fileExtension: String) -> EntityID { + let entityId = createEntity() + setEntityName(entityId: entityId, name: "Demo-\(title)-\(entityId)") + + if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + quickPreviewComp.absoluteFilePath = sourceURL.isFileURL ? sourceURL.path : sourceURL.absoluteString + quickPreviewComp.fileExtension = fileExtension + quickPreviewComp.originalFileName = title + } + + return entityId + } + + private func loadDemoSceneAuthoredIfNeeded( + _ demo: DemoSceneCatalogItem, + url: URL, + isRuntimeAsset: Bool, + existingEntityIds: Set, + completion: @escaping (Bool) -> Void + ) { + guard demo.loadsSceneAuthoredPayload else { + completion(false) + return + } + + if isRuntimeAsset { + loadSceneAuthored(filename: url.path, withExtension: url.pathExtension.lowercased()) { success in + DispatchQueue.main.async { + completeDemoSceneAuthoredLoad(success, existingEntityIds: existingEntityIds, completion: completion) + } + } + } else { + loadSceneAuthored(url: url) { success in + DispatchQueue.main.async { + completeDemoSceneAuthoredLoad(success, existingEntityIds: existingEntityIds, completion: completion) + } + } + } + } + + private func completeDemoSceneLoad(_ demo: DemoSceneCatalogItem, didApplyAuthoredCamera: Bool) { + if didApplyAuthoredCamera == false, let frame = demo.cameraFrame { + applyCameraFrame(frame) + } + + clearExploreSelection() editor_entities = getAllGameEntities() sceneGraphModel.refreshHierarchy() + enableExploreNavigationMode() + revealCameraControlHintsIfNeeded() - print("ℹ️ Quick Preview mode: Starter stream loaded from \(item.manifestURL.absoluteString)") - print("⚠️ Note: Quick Preview entities cannot be saved to scenes") + Logger.log(message: "✅ Demo loaded: \(demo.title)") } - private func applyStarterStreamCameraFrame(_ item: StreamModelCatalogItem) { + private func clearExploreSelection() { + removeGizmo() + activeEntity = .invalid + gizmoActive = false + selectionManager.selectedEntity = nil + selectionManager.inspectedMesh = nil + selectionManager.objectWillChange.send() + } + + private func applyCameraFrame(_ frame: StreamModelCameraFrame) { let camera = findSceneCamera() - let frame = item.cameraFrame cameraLookAt(entityId: camera, eye: frame.eye, target: frame.target, up: cameraUpDefault) CameraSystem.shared.activeCamera = camera @@ -1031,7 +1264,178 @@ public struct EditorView: View { } } - private func editor_handleQuickPreview(mode: QuickPreviewImportMode) { + private func applyGameCameraFrameToSceneCamera(_ gameCamera: EntityID) { + let sceneCamera = findSceneCamera() + let eye = getCameraEye(entityId: gameCamera) + let up = getCameraUp(entityId: gameCamera) + let target = getCameraTarget(entityId: gameCamera) + + cameraLookAt(entityId: sceneCamera, eye: eye, target: target, up: up) + CameraSystem.shared.activeCamera = sceneCamera + } + + private func findEditorGameCamera() -> EntityID { + let entities = getAllGameEntities() + + if let sceneAuthoredGameCamera, + entities.contains(sceneAuthoredGameCamera), + isGameCamera(sceneAuthoredGameCamera) + { + return sceneAuthoredGameCamera + } + + if let activeCamera = CameraSystem.shared.activeCamera, + entities.contains(activeCamera), + isGameCamera(activeCamera) + { + return activeCamera + } + + if let existingGameCamera = entities.first(where: isGameCamera) { + return existingGameCamera + } + + return findGameCamera() + } + + private func isGameCamera(_ entityId: EntityID) -> Bool { + hasComponent(entityId: entityId, componentType: CameraComponent.self) + && hasComponent(entityId: entityId, componentType: SceneCameraComponent.self) == false + } + + private func refreshEditorAfterSceneAuthoredLoad( + selecting entityId: EntityID?, + gameCamera: EntityID? + ) { + removeGizmo() + activeEntity = .invalid + gizmoActive = false + sceneAuthoredGameCamera = gameCamera + if let entityId { + selectionManager.selectedEntity = entityId + } + selectionManager.inspectedMesh = nil + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + updateActiveCameraForPlayMode() + selectionManager.objectWillChange.send() + } + + private func removeDefaultSceneAuthoredEntities(existingEntityIds: Set) { + let entities = Set(getAllGameEntities()) + let previousSceneAuthoredGameCamera = sceneAuthoredGameCamera + sceneAuthoredGameCamera = nil + + let gameCamerasToRemove = existingEntityIds.filter { + entities.contains($0) + && isGameCamera($0) + && (getEntityName(entityId: $0) == "Game Camera" || $0 == previousSceneAuthoredGameCamera) + } + + for gameCameraId in gameCamerasToRemove { + destroyEntity(entityId: gameCameraId) + setCamera(.active(.invalid)) + } + + if let directionalLightId = existingEntityIds.first(where: { + entities.contains($0) + && getEntityName(entityId: $0) == "Directional Light" + && hasComponent(entityId: $0, componentType: DirectionalLightComponent.self) + }) { + destroyEntity(entityId: directionalLightId) + } + + sceneGraphModel.refreshHierarchy() + } + + private func findImportedGameCamera(existingEntityIds: Set) -> EntityID? { + getAllGameEntities().first { + existingEntityIds.contains($0) == false + && isGameCamera($0) + } + } + + private func loadSceneAuthoredPayload( + filename: String, + withExtension fileExtension: String, + selecting entityId: EntityID?, + sourceName: String + ) { + let existingEntityIds = Set(getAllGameEntities()) + + loadSceneAuthored(filename: filename, withExtension: fileExtension) { success in + DispatchQueue.main.async { + if success { + removeDefaultSceneAuthoredEntities(existingEntityIds: existingEntityIds) + let importedCamera = findImportedGameCamera(existingEntityIds: existingEntityIds) + refreshEditorAfterSceneAuthoredLoad(selecting: entityId, gameCamera: importedCamera) + print("✅ Scene-authored cameras/lights loaded: \(sourceName)") + } else { + print("⚠️ Failed to load scene-authored cameras/lights: \(sourceName)") + } + } + } + } + + private func loadSceneAuthoredPayload( + url manifestURL: URL, + selecting entityId: EntityID?, + sourceName: String + ) { + let existingEntityIds = Set(getAllGameEntities()) + + loadSceneAuthored(url: manifestURL) { success in + DispatchQueue.main.async { + if success { + removeDefaultSceneAuthoredEntities(existingEntityIds: existingEntityIds) + let importedCamera = findImportedGameCamera(existingEntityIds: existingEntityIds) + refreshEditorAfterSceneAuthoredLoad(selecting: entityId, gameCamera: importedCamera) + print("✅ Scene-authored cameras/lights loaded: \(sourceName)") + } else { + print("⚠️ Failed to load scene-authored cameras/lights: \(sourceName)") + } + } + } + } + + private func editor_loadSceneAuthoredFromAsset(_ asset: Asset) { + let fileExtension = asset.path.pathExtension.lowercased() + + if fileExtension == "untold" { + loadSceneAuthoredPayload( + filename: asset.path.path, + withExtension: fileExtension, + selecting: selectionManager.selectedEntity, + sourceName: asset.name + ) + return + } + + if fileExtension == "json", isTiledSceneManifest(asset.path) { + loadSceneAuthoredPayload( + url: asset.path, + selecting: selectionManager.selectedEntity, + sourceName: asset.name + ) + return + } + + if fileExtension == "remotestream", + let urlString = try? String(contentsOf: asset.path, encoding: .utf8), + let manifestURL = URL(string: urlString.trimmingCharacters(in: .whitespacesAndNewlines)) + { + loadSceneAuthoredPayload( + url: manifestURL, + selecting: selectionManager.selectedEntity, + sourceName: asset.name + ) + return + } + + print("⚠️ Scene-authored loading is only supported for .untold assets and tiled scene manifests") + } + + private func editor_handleQuickPreview(mode: QuickPreviewImportMode, fromExploreMode: Bool = false) { let openPanel = NSOpenPanel() openPanel.title = mode.filePickerTitle openPanel.allowedContentTypes = mode.allowedContentTypes @@ -1040,24 +1444,38 @@ public struct EditorView: View { openPanel.message = mode.filePickerMessage guard openPanel.runModal() == .OK, let fileURL = openPanel.url else { + if fromExploreMode { + showPreviewImportGallery = true + } return } let fileExtension = fileURL.pathExtension.lowercased() let absolutePath = fileURL.path + let fileName = fileURL.deletingPathExtension().lastPathComponent + pendingQuickPreviewLoadsInExplore = fromExploreMode + + if isUSDSourceAsset(fileURL) { + queueQuickPreviewRuntimeExport(sourceURL: fileURL) + return + } if fileExtension == "json", !isTiledSceneManifest(fileURL) { Logger.log(message: "⚠️ Quick Preview JSON is not a tiled scene manifest: \(fileURL.lastPathComponent)") + if fromExploreMode { + showPreviewImportGallery = true + pendingQuickPreviewLoadsInExplore = false + } return } deleteExistingQuickPreviewEntities() + let existingEntityIds = Set(getAllGameEntities()) // Create a new entity for the preview removeGizmo() let entityId = createEntity() - let fileName = fileURL.deletingPathExtension().lastPathComponent let uniqueName = "QuickPreview-\(fileName)-\(entityId)" setEntityName(entityId: entityId, name: uniqueName) @@ -1074,13 +1492,28 @@ public struct EditorView: View { // Load Untold runtime asset using absolute path setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in - if success { - DispatchQueue.main.async { - revealCameraControlHintsIfNeeded() + DispatchQueue.main.async { + if success { + loadQuickPreviewSceneAuthored( + url: fileURL, + fileExtension: fileExtension, + isRuntimeAsset: true, + existingEntityIds: existingEntityIds + ) { _ in + if fromExploreMode { + completeExploreQuickPreviewLoad(fileName: fileName, mode: mode) + } else { + sceneGraphModel.refreshHierarchy() + } + } + print("✅ Quick Preview loaded: \(fileName).\(fileExtension)") + } else { + print("⚠️ Failed to load Quick Preview, using fallback: \(fileName).\(fileExtension)") + if fromExploreMode { + showPreviewImportGallery = true + pendingQuickPreviewLoadsInExplore = false + } } - print("✅ Quick Preview loaded: \(fileName).\(fileExtension)") - } else { - print("⚠️ Failed to load Quick Preview, using fallback: \(fileName).\(fileExtension)") } } } else if fileExtension == "ply" { @@ -1089,7 +1522,9 @@ public struct EditorView: View { // Load Gaussian PLY using absolute path setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) - revealCameraControlHintsIfNeeded() + if fromExploreMode == false { + revealCameraControlHintsIfNeeded() + } print("✅ Quick Preview Gaussian loaded: \(fileName).\(fileExtension)") } else if fileExtension == "json" { clearSceneBatches() @@ -1098,10 +1533,26 @@ public struct EditorView: View { setEntityStreamScene(entityId: entityId, url: fileURL) { success in DispatchQueue.main.async { if success { - revealCameraControlHintsIfNeeded() + loadQuickPreviewSceneAuthored( + url: fileURL, + fileExtension: fileExtension, + isRuntimeAsset: false, + existingEntityIds: existingEntityIds + ) { _ in + if fromExploreMode { + completeExploreQuickPreviewLoad(fileName: fileName, mode: mode) + } else { + revealCameraControlHintsIfNeeded() + sceneGraphModel.refreshHierarchy() + } + } print("✅ Quick Preview stream model loaded: \(fileName).\(fileExtension)") } else { print("⚠️ Failed to load Quick Preview stream model: \(fileName).\(fileExtension)") + if fromExploreMode { + showPreviewImportGallery = true + pendingQuickPreviewLoadsInExplore = false + } } sceneGraphModel.refreshHierarchy() } @@ -1134,11 +1585,377 @@ public struct EditorView: View { selectionManager.selectedEntity = entityId editor_entities = getAllGameEntities() sceneGraphModel.refreshHierarchy() + if fromExploreMode, fileExtension != "untold" { + completeExploreQuickPreviewLoad(fileName: fileName, mode: mode) + } + + print("ℹ️ Quick Preview mode: File loaded with absolute path") + print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") + } + + private func loadQuickPreviewSceneAuthored( + url: URL, + fileExtension: String, + isRuntimeAsset: Bool, + existingEntityIds: Set, + completion: @escaping (Bool) -> Void + ) { + guard fileExtension == "untold" || fileExtension == "json" else { + completion(false) + return + } + + if isRuntimeAsset { + loadSceneAuthored(filename: url.path, withExtension: fileExtension) { success in + DispatchQueue.main.async { + completeDemoSceneAuthoredLoad(success, existingEntityIds: existingEntityIds, completion: completion) + } + } + } else { + loadSceneAuthored(url: url) { success in + DispatchQueue.main.async { + completeDemoSceneAuthoredLoad(success, existingEntityIds: existingEntityIds, completion: completion) + } + } + } + } + + private func isUSDSourceAsset(_ url: URL) -> Bool { + ["usd", "usda", "usdc", "usdz"].contains(url.pathExtension.lowercased()) + } + + private func queueQuickPreviewRuntimeExport(sourceURL: URL) { + let cacheDirectory = QuickPreviewRuntimeExportCache.cacheDirectory(for: sourceURL) + let outputURL = QuickPreviewRuntimeExportCache.outputURL(for: sourceURL, in: cacheDirectory) + + QuickPreviewRuntimeExportCache.pruneStaleCaches(preserving: [cacheDirectory]) + + pendingQuickPreviewExport = QuickPreviewRuntimeExportRequest( + sourceURL: sourceURL, + outputURL: outputURL + ) + } + + private func quickPreviewRuntimeExportSheet(for request: QuickPreviewRuntimeExportRequest) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("Convert to Untold Preview Asset") + .font(.title2) + .bold() + + Text("This USD file needs to be converted to Untold Engine's .untold runtime format before it can be previewed.") + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 6) { + Text("Source") + .font(.caption) + .foregroundColor(.secondary) + Text(request.sourceURL.path) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + + Text("Output") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 6) + Text(request.outputURL.path) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Convert orientation", isOn: $quickPreviewConvertOrientation) + + Picker("Source orientation", selection: $quickPreviewSourceOrientation) { + Text("Blender native").tag("blender-native") + Text("Engine oriented").tag("engine-oriented") + } + .disabled(!quickPreviewConvertOrientation) + + Toggle("Compress geometry (LZ4)", isOn: $quickPreviewCompressGeometry) + .help("Compresses vertex and index data with LZ4. Requires the Python lz4 package.") + if quickPreviewCompressGeometry { + Text("Requires: pip install lz4") + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 20) + } + + Toggle("Compress textures (ASTC)", isOn: $quickPreviewCompressTextures) + .help("Converts textures to GPU-native ASTC format. Requires astcenc and the Python Pillow package.") + if quickPreviewCompressTextures { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Link("Install astcenc ->", destination: URL(string: "https://github.com/ARM-software/astc-encoder/releases")!) + .font(.caption) + Text("-") + .font(.caption) + .foregroundColor(.secondary) + Text("Also requires: pip install Pillow") + .font(.caption) + .foregroundColor(.secondary) + } + VStack(alignment: .leading, spacing: 4) { + Text("astcenc path (optional)") + .font(.caption) + .foregroundColor(.secondary) + HStack { + TextField("/opt/homebrew/bin/astcenc", text: $quickPreviewAstcencBinPath) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + Button("Browse...") { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.title = "Select astcenc binary" + if panel.runModal() == .OK, let url = panel.url { + quickPreviewAstcencBinPath = url.path + } + } + } + } + } + .padding(.leading, 20) + } + } + + if isExportingQuickPreviewAsset { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Exporting...") + .foregroundColor(.secondary) + } + } + + HStack { + Spacer() + Button("Cancel") { + pendingQuickPreviewExport = nil + } + .disabled(isExportingQuickPreviewAsset) + + Button("Export and Load") { + exportAndLoadQuickPreviewRuntimeAsset(request) + } + .keyboardShortcut(.defaultAction) + .disabled(isExportingQuickPreviewAsset) + } + } + .padding(20) + .frame(width: 560) + } + + private func exportAndLoadQuickPreviewRuntimeAsset(_ request: QuickPreviewRuntimeExportRequest) { + guard !isExportingQuickPreviewAsset else { return } + guard let exporterScript = findExportUntoldScript() else { + Logger.log(message: "❌ export-untold script not found. Expected at .build/checkouts/UntoldEngine/scripts/export-untold") + pendingQuickPreviewExport = nil + return + } + + isExportingQuickPreviewAsset = true + let convertOrientation = quickPreviewConvertOrientation + let sourceOrientation = quickPreviewSourceOrientation + let compressGeometry = quickPreviewCompressGeometry + let compressTextures = quickPreviewCompressTextures + let astcencBin = quickPreviewAstcencBinPath.trimmingCharacters(in: .whitespacesAndNewlines) + + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let tempDirectory = FileManager.default.temporaryDirectory + let outputLogURL = tempDirectory.appendingPathComponent("quick-preview-export-\(UUID().uuidString).out") + let errorLogURL = tempDirectory.appendingPathComponent("quick-preview-export-\(UUID().uuidString).err") + + do { + try FileManager.default.createDirectory(at: request.outputURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: request.outputURL.path) { + try FileManager.default.removeItem(at: request.outputURL) + } + FileManager.default.createFile(atPath: outputLogURL.path, contents: nil) + FileManager.default.createFile(atPath: errorLogURL.path, contents: nil) + let outputHandle = try FileHandle(forWritingTo: outputLogURL) + let errorHandle = try FileHandle(forWritingTo: errorLogURL) + defer { + try? outputHandle.close() + try? errorHandle.close() + try? FileManager.default.removeItem(at: outputLogURL) + try? FileManager.default.removeItem(at: errorLogURL) + } + + process.executableURL = exporterScript + var arguments = [ + "--input", request.sourceURL.path, + "--output", request.outputURL.path, + ] + if convertOrientation { + arguments.append("--ConvertOrientation") + arguments.append(contentsOf: ["--source-orientation", sourceOrientation]) + } + if compressGeometry { + arguments.append("--compress-geometry") + } + process.arguments = arguments + process.standardOutput = outputHandle + process.standardError = errorHandle + + try process.run() + process.waitUntilExit() + + let stdout = (try? String(contentsOf: outputLogURL, encoding: .utf8)) ?? "" + let stderr = (try? String(contentsOf: errorLogURL, encoding: .utf8)) ?? "" + let exportSucceeded = process.terminationStatus == 0 + + DispatchQueue.main.async { + if !stdout.isEmpty { Logger.log(message: stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !stderr.isEmpty { Logger.log(message: stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + } + + if exportSucceeded, compressTextures { + let texturesDir = request.outputURL.deletingLastPathComponent().appendingPathComponent("Textures") + if FileManager.default.fileExists(atPath: texturesDir.path), + let texbakeScript = findTexbakeScript() + { + let bakeResult = runTexbakeStep(script: texbakeScript, arguments: ["--dir", texturesDir.path], astcencBin: astcencBin) + let patchResult = runTexbakeStep(script: texbakeScript, arguments: ["--patch-refs", request.outputURL.path], astcencBin: astcencBin) + DispatchQueue.main.async { + if !bakeResult.stdout.isEmpty { Logger.log(message: bakeResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !bakeResult.stderr.isEmpty { Logger.log(message: bakeResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !patchResult.stdout.isEmpty { Logger.log(message: patchResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !patchResult.stderr.isEmpty { Logger.log(message: patchResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + if bakeResult.status != 0 || patchResult.status != 0 { + Logger.log(message: "⚠️ ASTC compression had errors — preview asset exported without compressed textures") + } + } + } else { + DispatchQueue.main.async { + Logger.log(message: "⚠️ ASTC skipped — texbake.py not found or no Textures folder present") + } + } + } + + DispatchQueue.main.async { + isExportingQuickPreviewAsset = false + pendingQuickPreviewExport = nil + if exportSucceeded { + editor_loadQuickPreviewAsset(from: request.outputURL, originalSourceURL: request.sourceURL) + } else { + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: request.outputURL.deletingLastPathComponent()) + Logger.log(message: "❌ Quick Preview export failed for \(request.sourceURL.lastPathComponent)") + } + } + } catch { + DispatchQueue.main.async { + isExportingQuickPreviewAsset = false + pendingQuickPreviewExport = nil + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: request.outputURL.deletingLastPathComponent()) + Logger.log(message: "❌ Quick Preview export failed: \(error)") + } + } + } + } + + private func editor_loadQuickPreviewAsset(from loadURL: URL, originalSourceURL: URL? = nil) { + let sourceURL = originalSourceURL ?? loadURL + let fileExtension = loadURL.pathExtension.lowercased() + let absolutePath = loadURL.path + let fileName = sourceURL.deletingPathExtension().lastPathComponent + + deleteExistingQuickPreviewEntities() + let existingEntityIds = Set(getAllGameEntities()) + removeGizmo() + + let entityId = createEntity() + let uniqueName = "QuickPreview-\(fileName)-\(entityId)" + setEntityName(entityId: entityId, name: uniqueName) + + if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + quickPreviewComp.absoluteFilePath = sourceURL.path + quickPreviewComp.fileExtension = sourceURL.pathExtension.lowercased() + quickPreviewComp.originalFileName = fileName + if originalSourceURL != nil { + quickPreviewComp.runtimePreviewDirectoryPath = loadURL.deletingLastPathComponent().path + } + } + + if fileExtension == "untold" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = false + + setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in + DispatchQueue.main.async { + if success { + loadQuickPreviewSceneAuthored( + url: loadURL, + fileExtension: fileExtension, + isRuntimeAsset: true, + existingEntityIds: existingEntityIds + ) { _ in + if pendingQuickPreviewLoadsInExplore { + completeExploreQuickPreviewLoad(fileName: fileName, mode: .untoldAsset) + } else { + sceneGraphModel.refreshHierarchy() + } + } + print("✅ Quick Preview loaded: \(loadURL.lastPathComponent)") + } else { + print("⚠️ Failed to load Quick Preview, using fallback: \(loadURL.lastPathComponent)") + if pendingQuickPreviewLoadsInExplore { + showPreviewImportGallery = true + pendingQuickPreviewLoadsInExplore = false + } + } + } + } + } else if fileExtension == "ply" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = false + + setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) + print("✅ Quick Preview Gaussian loaded: \(loadURL.lastPathComponent)") + } + + 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: spawnPosition) + + selectionManager.selectedEntity = entityId + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + if pendingQuickPreviewLoadsInExplore, fileExtension != "untold" { + completeExploreQuickPreviewLoad(fileName: fileName, mode: .untoldAsset) + } print("ℹ️ Quick Preview mode: File loaded with absolute path") print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") } + private func completeExploreQuickPreviewLoad(fileName: String, mode: QuickPreviewImportMode) { + experienceMode = .explore + showWelcomeStart = true + showDemoGallery = false + showPreviewImportGallery = false + activeDemoScene = nil + activeDemoCameraFrame = nil + activePreviewSceneTitle = fileName + activePreviewImportMode = mode + pendingQuickPreviewLoadsInExplore = false + clearExploreSelection() + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + enableExploreNavigationMode() + revealCameraControlHintsIfNeeded() + } + private func deleteExistingQuickPreviewEntities() { let previewEntityIds = getAllGameEntities() .filter { hasComponent(entityId: $0, componentType: QuickPreviewComponent.self) } @@ -1148,6 +1965,11 @@ public struct EditorView: View { } for entityId in previewEntityIds { + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId), + quickPreviewComp.runtimePreviewDirectoryPath.isEmpty == false + { + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: URL(fileURLWithPath: quickPreviewComp.runtimePreviewDirectoryPath)) + } destroyEntity(entityId: entityId) } @@ -1192,6 +2014,11 @@ public struct EditorView: View { private func deleteQuickPreviewEntitiesAndSave() { // Delete all Quick Preview entities for (entityId, entityName) in quickPreviewEntities { + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId), + quickPreviewComp.runtimePreviewDirectoryPath.isEmpty == false + { + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: URL(fileURLWithPath: quickPreviewComp.runtimePreviewDirectoryPath)) + } destroyEntity(entityId: entityId) print("🗑️ Deleted Quick Preview entity: \(entityName)") } diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 4f32449..da492c8 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -362,6 +362,7 @@ private enum EditorAntiAliasingOption: String, CaseIterable, Hashable, Identifia case off = "Off" case fxaa = "FXAA" case smaa = "SMAA" + case msaa = "MSAA" var id: String { rawValue @@ -375,6 +376,8 @@ private enum EditorAntiAliasingOption: String, CaseIterable, Hashable, Identifia return .fxaa case .smaa: return .smaa + case .msaa: + return .msaa } } @@ -386,6 +389,8 @@ private enum EditorAntiAliasingOption: String, CaseIterable, Hashable, Identifia return .fxaa case .smaa: return .smaa + case .msaa: + return .msaa } } } @@ -463,6 +468,8 @@ struct AntiAliasingEditorView: View { } .padding(.top, 4) } + case .msaa: + EmptyView() } } .padding() diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index de55dc1..cadf8cf 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -1487,7 +1487,7 @@ struct CameraEditorView: View { TextInputVectorView(label: "Eye", value: Binding( get: { eye }, set: { newEye in - cameraLookAt(entityId: findGameCamera(), eye: newEye, target: target, up: up) + cameraLookAt(entityId: entityId, eye: newEye, target: target, up: up) refreshView() } )) @@ -1495,7 +1495,7 @@ struct CameraEditorView: View { TextInputVectorView(label: "Up", value: Binding( get: { up }, set: { newUp in - cameraLookAt(entityId: findGameCamera(), eye: eye, target: target, up: newUp) + cameraLookAt(entityId: entityId, eye: eye, target: target, up: newUp) refreshView() } )) @@ -1503,7 +1503,7 @@ struct CameraEditorView: View { TextInputVectorView(label: "Target", value: Binding( get: { target }, set: { newTarget in - cameraLookAt(entityId: findGameCamera(), eye: eye, target: newTarget, up: up) + cameraLookAt(entityId: entityId, eye: eye, target: newTarget, up: up) refreshView() } )) diff --git a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift index 851e0cd..b06f27e 100644 --- a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift +++ b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift @@ -19,7 +19,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var menuTitle: String { switch self { case .untoldAsset: - return "Load Untold Asset (.untold)" + return "Load Untold Asset (.untold, USD)" case .tiledScene: return "Load Tiled Stream (.json)" case .gaussian: @@ -41,7 +41,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var filePickerTitle: String { switch self { case .untoldAsset: - return "Load Preview - Select Untold Asset" + return "Load Preview - Select Untold or USD Asset" case .tiledScene: return "Load Preview - Select Tiled Stream Manifest" case .gaussian: @@ -52,7 +52,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var filePickerMessage: String { switch self { case .untoldAsset: - return "Select an Untold runtime asset to preview without creating a project" + return "Select an Untold runtime asset or USD source asset to preview without creating a project" case .tiledScene: return "Select a tiled stream manifest to preview without creating a project" case .gaussian: @@ -63,7 +63,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var allowedContentTypes: [UTType] { switch self { case .untoldAsset: - return [UTType(filenameExtension: "untold") ?? .data] + return ["untold", "usd", "usda", "usdc", "usdz"].compactMap { UTType(filenameExtension: $0) } case .tiledScene: return [.json] case .gaussian: @@ -84,15 +84,96 @@ public class QuickPreviewComponent: Component { /// The original filename without extension public var originalFileName: String + /// Disposable temp cache directory used by converted quick-preview assets. + public var runtimePreviewDirectoryPath: String + public required init() { absoluteFilePath = "" fileExtension = "" originalFileName = "" + runtimePreviewDirectoryPath = "" } public init(absoluteFilePath: String, fileExtension: String, originalFileName: String) { self.absoluteFilePath = absoluteFilePath self.fileExtension = fileExtension self.originalFileName = originalFileName + runtimePreviewDirectoryPath = "" + } +} + +struct QuickPreviewRuntimeExportRequest: Identifiable { + let id = UUID() + let sourceURL: URL + let outputURL: URL +} + +enum QuickPreviewRuntimeExportCache { + static let directoryName = "UntoldEditorQuickPreviewExports" + static let defaultStaleAge: TimeInterval = 24 * 60 * 60 + + static func rootDirectory(baseDirectory: URL = FileManager.default.temporaryDirectory) -> URL { + baseDirectory.appendingPathComponent(directoryName, isDirectory: true) + } + + static func cacheDirectory( + for sourceURL: URL, + exportID: UUID = UUID(), + rootDirectory: URL = rootDirectory() + ) -> URL { + let baseName = sanitizedFileName(sourceURL.deletingPathExtension().lastPathComponent) + return rootDirectory.appendingPathComponent("\(baseName)-\(exportID.uuidString)", isDirectory: true) + } + + static func outputURL(for sourceURL: URL, in cacheDirectory: URL) -> URL { + cacheDirectory + .appendingPathComponent(sanitizedFileName(sourceURL.deletingPathExtension().lastPathComponent)) + .appendingPathExtension("untold") + } + + static func pruneStaleCaches( + in rootDirectory: URL = rootDirectory(), + preserving preservedDirectories: Set = [], + olderThan staleAge: TimeInterval = defaultStaleAge, + now: Date = Date(), + fileManager: FileManager = .default + ) { + guard let contents = try? fileManager.contentsOfDirectory( + at: rootDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let preservedPaths = Set(preservedDirectories.map(\.standardizedFileURL.path)) + for url in contents where preservedPaths.contains(url.standardizedFileURL.path) == false { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isDirectoryKey]), + let modifiedAt = values.contentModificationDate + else { + continue + } + + if now.timeIntervalSince(modifiedAt) > staleAge { + try? fileManager.removeItem(at: url) + } + } + } + + static func removeCacheDirectory(at url: URL, fileManager: FileManager = .default) { + let rootPath = rootDirectory().standardizedFileURL.path + let targetURL = url.standardizedFileURL + guard targetURL.path.hasPrefix(rootPath) else { + return + } + + try? fileManager.removeItem(at: targetURL) + } + + private static func sanitizedFileName(_ rawName: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let scalars = rawName.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } + let sanitized = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_")) + return sanitized.isEmpty ? "QuickPreview" : sanitized } } diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 558ab02..12afb72 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -15,7 +15,7 @@ @ObservedObject var selectionManager: SelectionManager @ObservedObject var editorBasePath = EditorAssetBasePath.shared @ObservedObject private var statsStore = EditorEngineStatsStore.shared - private let editorVersionLabel = "v0.13.0" + private let editorVersionLabel = "v0.14.0" var onSave: () -> Void var onSaveAs: () -> Void @@ -31,8 +31,6 @@ var onCreatePlane: () -> Void var onCreateCylinder: () -> Void var onCreateCone: () -> Void - var onStarterStreamSelected: (StreamModelCatalogItem) -> Void = { _ in } - var onQuickPreview: (QuickPreviewImportMode) -> Void @State private var isPlaying = false @State private var showCreateProject = false @@ -103,56 +101,6 @@ .buttonStyle(.plain) .focusable(false) - Menu { - ForEach(starterStreamModels) { item in - Button { - onStarterStreamSelected(item) - } label: { - Label(item.title, systemImage: "square.stack.3d.up.fill") - } - } - } label: { - HStack(spacing: 6) { - Image(systemName: "square.stack.3d.up.fill") - Text("Starter Streams") - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .bold)) - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.editorSecondaryAccent) - .foregroundColor(.white) - .cornerRadius(6) - } - .menuStyle(.borderlessButton) - .focusable(false) - .help("Load a starter stream model without creating a project") - - Menu { - ForEach(QuickPreviewImportMode.allCases, id: \.self) { mode in - Button { - onQuickPreview(mode) - } label: { - Label(mode.menuTitle, systemImage: mode.systemImageName) - } - } - } label: { - HStack(spacing: 6) { - Image(systemName: "eye.fill") - Text("Load Preview") - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .bold)) - } - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.editorAccent) - .foregroundColor(.white) - .cornerRadius(6) - } - .menuStyle(.borderlessButton) - .focusable(false) - .help("Load a preview asset without creating a project") - Divider().frame(height: 24) } } diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index 32082cd..7b35f45 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -12,6 +12,15 @@ import Foundation import MetalKit import UntoldEngine +/// Builds the selected area-light debug mesh transform so the plane normal points along light emission. +func areaLightDebugModelMatrix(worldTransform: simd_float4x4) -> simd_float4x4 { + let areaDebugRotation = matrix4x4Rotation( + radians: degreesToRadians(degrees: -90.0), + axis: simd_float3(1.0, 0.0, 0.0) + ) + return simd_mul(worldTransform, areaDebugRotation) +} + extension RenderPasses { static let editorPreCompositeExecution: (MTLCommandBuffer) -> Void = { commandBuffer in let wasSSAOEnabled = SSAOParams.shared.enabled @@ -28,7 +37,9 @@ extension RenderPasses { return } - guard let gizmoPipeline = PipelineManager.shared.renderPipelinesByType[.gizmo] else { + guard let gizmoPipeline = PipelineManager.shared.renderPipelinesByType[.editorGizmo] + ?? PipelineManager.shared.renderPipelinesByType[.gizmo] + else { handleError(.pipelineStateNulled, "gizmoPipeline is nil") return } @@ -607,13 +618,9 @@ extension RenderPasses { axis: simd_float3(1.0, 0.0, 0.0) ) debugModelMatrix = simd_mul(worldTransform.space, spotDebugRotation) - } else if let areaLightComponent = scene.get(component: AreaLightComponent.self, for: activeEntity) { + } else if scene.get(component: AreaLightComponent.self, for: activeEntity) != nil { lightMesh = areaLightDebugMesh - let areaDebugRotation = matrix4x4Rotation( - radians: degreesToRadians(degrees: 90.0), - axis: simd_float3(1.0, 0.0, 0.0) - ) - debugModelMatrix = simd_mul(worldTransform.space, areaDebugRotation) + debugModelMatrix = areaLightDebugModelMatrix(worldTransform: worldTransform.space) } else if let dirLightComponent = scene.get(component: DirectionalLightComponent.self, for: activeEntity) { lightMesh = dirLightDebugMesh } diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPipeLines.swift b/Sources/UntoldEditor/Renderer/EditorRenderPipeLines.swift index ca9785d..28475fd 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPipeLines.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPipeLines.swift @@ -39,12 +39,11 @@ public func InitDebugPipeline() -> RenderPipeline? { public extension RenderPipelineType { static let gizmo: RenderPipelineType = "gizmo" - static let debug: RenderPipelineType = "debug" + static let editorGizmo: RenderPipelineType = "untold.editor.gizmoPipeline" } public func EditorDefaultPipeLines() -> [(RenderPipelineType, RenderPipelineInitBlock)] { DefaultPipeLines() + [ (.gizmo, InitGizmoPipeline), - (.debug, InitDebugPipeline), ] } diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift index 3a55707..2d62d9a 100644 --- a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift +++ b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift @@ -265,16 +265,30 @@ extension UntoldRenderer { let lightPos = getPosition(entityId: parentEntityIdGizmo) let gizmoPos = getPosition(entityId: lightDirEntity) - let zAxis = simd_normalize(gizmoPos - lightPos) * -1.0 + let emissionDirection = gizmoPos - lightPos + guard simd_length_squared(emissionDirection) > 0.0001 else { + break + } + + // Engine light emission is local -Z, so local +Z must point opposite + // the dragged direction handle. + let zAxis = -simd_normalize(emissionDirection) let worldUp = simd_float3(0, 1, 0) - var xAxis = simd_normalize(simd_cross(worldUp, zAxis)) - if simd_length(xAxis) < 0.001 { - xAxis = simd_normalize(simd_cross(simd_float3(1, 0, 0), zAxis)) + var xAxis = simd_cross(worldUp, zAxis) + if simd_length_squared(xAxis) < 0.000001 { + xAxis = simd_cross(simd_float3(1, 0, 0), zAxis) } + xAxis = simd_normalize(xAxis) let yAxis = simd_normalize(simd_cross(zAxis, xAxis)) let rotM = simd_float3x3(columns: (xAxis, yAxis, zAxis)) - localTransformComponent.rotation = transformMatrix3nToQuaternion(m: rotM) + let rotation = transformMatrix3nToQuaternion(m: rotM) + let euler = transformQuaternionToEulerAngles(q: rotation) + localTransformComponent.rotation = rotation + localTransformComponent.rotationX = euler.pitch + localTransformComponent.rotationY = euler.yaw + localTransformComponent.rotationZ = euler.roll + translateTo(entityId: activeEntity, position: localTransformComponent.position) default: break diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift index be6a7a6..573cf00 100644 --- a/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift +++ b/Sources/UntoldEditor/Renderer/EditorUntoldRendererConfig.swift @@ -10,12 +10,8 @@ import UntoldEngine public extension UntoldRendererConfig { + @MainActor static var editor: UntoldRendererConfig { - UntoldRendererConfig( - initPipelineBlocks: EditorDefaultPipeLines(), - updateRenderingSystemCallback: { view in - EditorUpdateRenderingSystem(in: view) - } - ) + .default } } diff --git a/Sources/UntoldEditor/Resources/Thumbnails/airplane.jpg b/Sources/UntoldEditor/Resources/Thumbnails/airplane.jpg new file mode 100644 index 0000000..13cc0f1 Binary files /dev/null and b/Sources/UntoldEditor/Resources/Thumbnails/airplane.jpg differ diff --git a/Sources/UntoldEditor/Resources/Thumbnails/city.jpg b/Sources/UntoldEditor/Resources/Thumbnails/city.jpg new file mode 100644 index 0000000..102f40d Binary files /dev/null and b/Sources/UntoldEditor/Resources/Thumbnails/city.jpg differ diff --git a/Sources/UntoldEditor/Resources/Thumbnails/dungeon.jpg b/Sources/UntoldEditor/Resources/Thumbnails/dungeon.jpg new file mode 100644 index 0000000..5fc04c7 Binary files /dev/null and b/Sources/UntoldEditor/Resources/Thumbnails/dungeon.jpg differ diff --git a/Sources/UntoldEditor/Resources/Thumbnails/f1car.png b/Sources/UntoldEditor/Resources/Thumbnails/f1car.png new file mode 100644 index 0000000..a813f4c Binary files /dev/null and b/Sources/UntoldEditor/Resources/Thumbnails/f1car.png differ diff --git a/Sources/UntoldEditor/Resources/Thumbnails/porsche964.png b/Sources/UntoldEditor/Resources/Thumbnails/porsche964.png new file mode 100644 index 0000000..16a162b Binary files /dev/null and b/Sources/UntoldEditor/Resources/Thumbnails/porsche964.png differ diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index 98fb030..5b40619 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -1,311 +1,61 @@ // -// RenderingSystem.swift -// UntoldEngine +// EditorRenderingSystem.swift +// UntoldEditor // // Copyright (C) Untold Engine Studios // Licensed under the GNU LGPL v3.0 or later. // See the LICENSE file or for details. // -import MetalKit -import QuartzCore -import UntoldEngine - -@MainActor -func EditorUpdateRenderingSystem(in view: MTKView) { - // While assets are loading, keep rendering from the last-known-good visible - // list and avoid ECS traversal in culling / gaussian prep. - let loading = AssetLoadingGate.shared.isLoadingAny - - if !loading { - visibleEntityIds = tripleVisibleEntities.snapshotForRead(frame: cullFrameIndex) - } - - // Limit in-flight command buffers so triple-buffered culling data isn't overwritten - commandBufferSemaphore.wait() - - if let commandBuffer = renderInfo.commandQueue.makeCommandBuffer() { - #if ENGINE_STATS_ENABLED - let renderTotalStart = CACurrentMediaTime() - #endif - renderInfo.lastCommandBuffer = commandBuffer - renderInfo.currentInFlightFrameSlot = acquireUniformFrameSlot() - // Keep scene-root-derived camera/light matrices current before culling - // and render passes read them. - SceneRootTransform.shared.updateIfNeeded() - - if !loading { - #if ENGINE_STATS_ENABLED - let renderPrepStart = CACurrentMediaTime() - let cullingStart = CACurrentMediaTime() - #endif - EngineProfiler.shared.beginScope(.renderPrep) - EngineProfiler.shared.beginScope(.culling) - performFrustumCulling(commandBuffer: commandBuffer) - EngineProfiler.shared.endScope(.culling) - #if ENGINE_STATS_ENABLED - let cullingMs = (CACurrentMediaTime() - cullingStart) * 1000.0 - EngineStatsMonitor.shared.update { snapshot in - snapshot.timing.cullingMs += cullingMs - } - #endif +import UntoldEngine - executeGaussianDepth(commandBuffer) - executeBitonicSort(commandBuffer) - EngineProfiler.shared.endScope(.renderPrep) - #if ENGINE_STATS_ENABLED - let renderPrepMs = (CACurrentMediaTime() - renderPrepStart) * 1000.0 - EngineStatsMonitor.shared.update { snapshot in - snapshot.timing.renderPrepMs += renderPrepMs - } - #endif - } - if let renderPassDescriptor = view.currentRenderPassDescriptor { - renderInfo.renderPassDescriptor = renderPassDescriptor +/// Adds editor-only overlays to the engine-owned render graph. +/// +/// The engine compiles and executes the graph. Keeping the editor passes in a +/// rendering extension means they participate in graph validation and continue +/// to work as the engine adds or reorders its internal passes. +final class EditorRenderExtension: RenderExtension, @unchecked Sendable { + static let shared = EditorRenderExtension() - commandBuffer.label = "Rendering Command Buffer" + let id = "untold.editor.rendering" - // build a render graph - let (graph, _) = gameMode ? buildGameModeGraph() : buildEditModeGraph() + private init() {} -// if visualDebug == false { -// let compositePass = RenderPass( -// id: "composite", dependencies: [preCompID], execute: RenderPasses.compositeExecution -// ) -// -// graph[compositePass.id] = compositePass -// } else { -// let debugPass = RenderPass( -// id: "debug", dependencies: [preCompID], execute: RenderPasses.debuggerExecution -// ) -// -// graph[debugPass.id] = debugPass -// } - - // sorted it - let sortedPasses = try! topologicalSortGraph(graph: graph) + func registerPipelines(_ registry: RenderPipelineRegistry) { + registry.registerRenderPipeline(.editorGizmo, initBlock: InitGizmoPipeline) + } - // execute it - #if ENGINE_STATS_ENABLED - let encodeStart = CACurrentMediaTime() - #endif - EngineProfiler.shared.beginScope(.encode) - executeGraph(graph, sortedPasses, commandBuffer) - // Keep editor in sync with runtime temporal HZB: - // render depth this frame -> build HZB -> consume next frame during culling - buildHZBDepthPyramid(commandBuffer) - EngineProfiler.shared.endScope(.encode) - #if ENGINE_STATS_ENABLED - let encodeMs = (CACurrentMediaTime() - encodeStart) * 1000.0 - EngineStatsMonitor.shared.update { snapshot in - snapshot.timing.encodeMs += encodeMs - } - #endif + func buildGraph( + _ builder: inout RenderGraphBuilder, + context _: RenderGraphBuildContext + ) { + // Play mode uses the unmodified runtime graph. + guard !gameMode else { return } + + builder.addPass( + id: "untold.editor.highlight", + stage: .beforeComposite + ) { context in + RenderPasses.highlightExecution(context.commandBuffer) } - if let drawable = view.currentDrawable { - commandBuffer.present(drawable) + builder.addPass( + id: "untold.editor.lightVisuals", + stage: .beforeComposite + ) { context in + RenderPasses.lightVisualPass(context.commandBuffer) } - EngineProfiler.shared.attach(to: commandBuffer, label: "EditorFrame") - let visibleEntityIdsAtSubmission = visibleEntityIds - commandBuffer.addCompletedHandler { cb in - #if ENGINE_STATS_ENABLED - let gpuExecutionMs = (cb.gpuEndTime - cb.gpuStartTime) * 1000.0 - EngineStatsMonitor.shared.recordGPUCompletion(executionMs: gpuExecutionMs) - #endif - // Release the in-flight slot - commandBufferSemaphore.signal() - needsFinalizeDestroys = true - MemoryBudgetManager.shared.markUsed(entityIds: visibleEntityIdsAtSubmission) + builder.addPass( + id: "untold.editor.gizmo", + stage: .beforeComposite + ) { context in + RenderPasses.gizmoExecution(context.commandBuffer) } - - #if ENGINE_STATS_ENABLED - let submitStart = CACurrentMediaTime() - #endif - commandBuffer.commit() - #if ENGINE_STATS_ENABLED - let submitMs = (CACurrentMediaTime() - submitStart) * 1000.0 - let renderTotalMs = (CACurrentMediaTime() - renderTotalStart) * 1000.0 - EngineStatsMonitor.shared.update { snapshot in - snapshot.timing.submitMs += submitMs - snapshot.timing.renderTotalMs += renderTotalMs - } - #endif - } else { - // Failed to create command buffer - release slot - commandBufferSemaphore.signal() } } -func buildEditModeGraph() -> RenderGraphResult { - var graph = [String: RenderPass]() - - let basePassID: String - if renderEnvironment { - let environmentPass = RenderPass( - id: "environment", dependencies: [], execute: RenderPasses.executeEnvironmentPass - ) - graph[environmentPass.id] = environmentPass - basePassID = environmentPass.id - } else { - let gridPass = RenderPass( - id: "grid", dependencies: [], execute: RenderPasses.gridExecution - ) - graph[gridPass.id] = gridPass - basePassID = gridPass.id - } - - let shadowPass = RenderPass( - id: "shadow", dependencies: [basePassID], execute: RenderPasses.shadowExecution - ) - graph[shadowPass.id] = shadowPass - - // Add batched shadow pass (runs after regular shadow pass) - let batchedShadowPass = RenderPass( - id: "batchedShadow", dependencies: [shadowPass.id], execute: RenderPasses.batchedShadowExecution - ) - graph[batchedShadowPass.id] = batchedShadowPass - - let modelPass = RenderPass( - id: "model", dependencies: [batchedShadowPass.id], execute: RenderPasses.combinedModelLightExecution - ) - graph[modelPass.id] = modelPass - - // Geometry and lighting now execute inside the TBDR model pass. Keep the - // legacy graph nodes as dependency anchors for editor overlays. - let batchedModelPass = RenderPass(id: "batchedModel", dependencies: [modelPass.id], execute: nil) - graph[batchedModelPass.id] = batchedModelPass - - let lightPass = RenderPass(id: "lightPass", dependencies: [batchedModelPass.id, modelPass.id, shadowPass.id], execute: nil) - graph[lightPass.id] = lightPass - - let transparencyPass = RenderPass( - id: "transparency", dependencies: [lightPass.id], execute: RenderPasses.transparencyExecution - ) - graph[transparencyPass.id] = transparencyPass - - // Spatial debug overlays are rendered on top of lit scene color. - let spatialDebugPass = RenderPass( - id: "spatialDebug", - dependencies: [transparencyPass.id], - execute: RenderPasses.spatialDebugBoundsExecution - ) - graph[spatialDebugPass.id] = spatialDebugPass - - let highlightPass = RenderPass( - id: "outline", dependencies: [batchedModelPass.id], execute: RenderPasses.highlightExecution - ) - graph[highlightPass.id] = highlightPass - - let lightVisualsPass = RenderPass(id: "lightVisualPass", dependencies: [highlightPass.id], execute: RenderPasses.lightVisualPass) - - graph[lightVisualsPass.id] = lightVisualsPass - - let gizmoPass = RenderPass(id: "gizmo", dependencies: [lightVisualsPass.id], execute: RenderPasses.gizmoExecution) - - graph[gizmoPass.id] = gizmoPass - - // Gaussian pass depends on model pass - needs depth buffer from 3D models - let gaussianPass = RenderPass(id: "gaussian", dependencies: [modelPass.id], execute: RenderPasses.gaussianExecution) - graph[gaussianPass.id] = gaussianPass - - let preCompPass = RenderPass( - id: "precomp", dependencies: [modelPass.id, gizmoPass.id, spatialDebugPass.id, gaussianPass.id], execute: RenderPasses.editorPreCompositeExecution - ) - graph[preCompPass.id] = preCompPass - - let lookPass = RenderPass( - id: "look", - dependencies: [preCompPass.id], - execute: lookRenderPass - ) - graph[lookPass.id] = lookPass - - let outputDependency: String - if renderDebugViewMode == .fxaaEdgeDebug { - let fxaaEdgeDebugPass = RenderPass(id: "fxaaEdgeDebug", dependencies: [lookPass.id], execute: fxaaEdgeDebugRenderPass) - graph[fxaaEdgeDebugPass.id] = fxaaEdgeDebugPass - outputDependency = fxaaEdgeDebugPass.id - } else if renderDebugViewMode == .smaaEdges { - let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass - outputDependency = smaaEdgesPass.id - } else if renderDebugViewMode == .smaaBlend { - let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass - - let smaaBlendWeightsPass = RenderPass( - id: "smaaBlendWeights", - dependencies: [smaaEdgesPass.id], - execute: smaaBlendWeightsRenderPass - ) - graph[smaaBlendWeightsPass.id] = smaaBlendWeightsPass - outputDependency = smaaBlendWeightsPass.id - } else if renderDebugViewMode == .smaaDifference { - let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass - - let smaaBlendWeightsPass = RenderPass( - id: "smaaBlendWeights", - dependencies: [smaaEdgesPass.id], - execute: smaaBlendWeightsRenderPass - ) - graph[smaaBlendWeightsPass.id] = smaaBlendWeightsPass - - let smaaNeighborhoodPass = RenderPass( - id: "smaaNeighborhood", - dependencies: [smaaBlendWeightsPass.id], - execute: smaaNeighborhoodRenderPass - ) - graph[smaaNeighborhoodPass.id] = smaaNeighborhoodPass - - let smaaDifferencePass = RenderPass( - id: "smaaDifference", - dependencies: [smaaNeighborhoodPass.id], - execute: smaaDifferenceRenderPass - ) - graph[smaaDifferencePass.id] = smaaDifferencePass - outputDependency = smaaDifferencePass.id - } else { - switch antiAliasingMode { - case .fxaa: - let fxaaPass = RenderPass( - id: "fxaa", - dependencies: [lookPass.id], - execute: fxaaRenderPass - ) - graph[fxaaPass.id] = fxaaPass - outputDependency = fxaaPass.id - case .smaa: - let smaaEdgesPass = RenderPass(id: "smaaEdges", dependencies: [lookPass.id], execute: smaaEdgesRenderPass) - graph[smaaEdgesPass.id] = smaaEdgesPass - - let smaaBlendWeightsPass = RenderPass( - id: "smaaBlendWeights", - dependencies: [smaaEdgesPass.id], - execute: smaaBlendWeightsRenderPass - ) - graph[smaaBlendWeightsPass.id] = smaaBlendWeightsPass - - let smaaNeighborhoodPass = RenderPass( - id: "smaaNeighborhood", - dependencies: [smaaBlendWeightsPass.id], - execute: smaaNeighborhoodRenderPass - ) - graph[smaaNeighborhoodPass.id] = smaaNeighborhoodPass - outputDependency = smaaNeighborhoodPass.id - case .none: - outputDependency = lookPass.id - } - } - - let outputPass = RenderPass( - id: "outputTransform", - dependencies: [outputDependency], - execute: outputTransformRenderPass - ) - graph[outputPass.id] = outputPass - - return (graph, outputPass.id) +@discardableResult +func registerEditorRenderExtension() -> RenderExtensionRegistrationResult { + RenderExtensionRegistry.shared.register(EditorRenderExtension.shared) } diff --git a/Sources/UntoldEditor/Systems/GizmoSystem.swift b/Sources/UntoldEditor/Systems/GizmoSystem.swift index cf034c1..25ace76 100644 --- a/Sources/UntoldEditor/Systems/GizmoSystem.swift +++ b/Sources/UntoldEditor/Systems/GizmoSystem.swift @@ -399,8 +399,8 @@ private func initialLightDirectionHandleOffset() -> simd_float3 { return simd_float3(0.0, GizmoDimensions.directionHandleOffsetY, 0.0) } - let forward = -1.0 * getForwardAxisVector(entityId: activeEntity) - let handleDirection = simd_length(forward) > 0.0001 ? simd_normalize(forward) : simd_float3(0.0, -1.0, 0.0) + let emissionDirection = getLightEmissionDirection(entityId: activeEntity) + let handleDirection = simd_length(emissionDirection) > 0.0001 ? simd_normalize(emissionDirection) : simd_float3(0.0, -1.0, 0.0) return handleDirection * abs(GizmoDimensions.directionHandleOffsetY) } diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index e295f78..ab13420 100644 --- a/Sources/UntoldEditor/main.swift +++ b/Sources/UntoldEditor/main.swift @@ -17,7 +17,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! func applicationDidFinishLaunching(_: Notification) { - Logger.log(message: "Launching Untold Engine Editor v0.13.0") + Logger.log(message: "Launching Untold Engine Editor v0.14.0") // Step 1. Create and configure the window window = NSWindow( @@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { defer: false ) - window.title = "Untold Engine Editor v0.13.0" + window.title = "Untold Engine Editor v0.14.0" window.center() let hostingView = NSHostingView(rootView: EditorView()) diff --git a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift index 61821bc..2027a8b 100644 --- a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift +++ b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift @@ -30,14 +30,16 @@ final class AssetBrowserViewTests: XCTestCase { selectedAsset: Binding, selectionManager: SelectionManager = SelectionManager(), sceneGraphModel: SceneGraphModel = SceneGraphModel(), - editor_addEntityWithAsset: @escaping () -> Void = {}) -> AssetBrowserView + editor_addEntityWithAsset: @escaping () -> Void = {}, + editor_loadSceneAuthoredFromAsset: @escaping (Asset) -> Void = { _ in }) -> AssetBrowserView { AssetBrowserView( assets: assets, selectedAsset: selectedAsset, selectionManager: selectionManager, sceneGraphModel: sceneGraphModel, - editor_addEntityWithAsset: editor_addEntityWithAsset + editor_addEntityWithAsset: editor_addEntityWithAsset, + editor_loadSceneAuthoredFromAsset: editor_loadSceneAuthoredFromAsset ) } diff --git a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift deleted file mode 100644 index 7a9c11b..0000000 --- a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift +++ /dev/null @@ -1,206 +0,0 @@ -// -// BuildEditModeGraphTests.swift -// UntoldEditor -// -// Copyright (C) Untold Engine Studios -// Licensed under the GNU LGPL v3.0 or later. -// See the LICENSE file or for details. -// - -// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. - -@testable import UntoldEditor -@testable import UntoldEngine -import XCTest - -final class BuildEditModeGraphTests: XCTestCase { - private func assertNoCycles(_ graph: [String: RenderPass], file: StaticString = #file, line: UInt = #line) { - enum Mark { case temp, perm } - var marks: [String: Mark] = [:] - - func dfs(_ node: String) -> Bool { - if marks[node] == .temp { return false } // cycle - if marks[node] == .perm { return true } // already ok - marks[node] = .temp - for dep in graph[node]?.dependencies ?? [] { - guard dfs(dep) else { return false } - } - marks[node] = .perm - return true - } - - for id in graph.keys { - XCTAssertTrue(dfs(id), "Cycle detected involving \(id)", file: file, line: line) - } - } - - private func assertDeps( - _ graph: [String: RenderPass], - _ id: String, - _ expected: [String], - file: StaticString = #file, line: UInt = #line - ) { - let got = graph[id]?.dependencies ?? [] - XCTAssertEqual( - Set(got), Set(expected), - "Dependencies for \(id) differ. got=\(got) expected=\(expected)", - file: file, line: line - ) - } - - // MARK: - Tests - - func test_buildEditModeGraph_withEnvironmentRoot_fxaaDisabled() { - let originalEnv = renderEnvironment - renderEnvironment = true - defer { renderEnvironment = originalEnv } - - let originalAntiAliasingMode = antiAliasingMode - antiAliasingMode = .none - defer { antiAliasingMode = originalAntiAliasingMode } - - let (graph, finalID) = buildEditModeGraph() - - XCTAssertEqual(finalID, "outputTransform") - - let expectedIDs: Set = [ - "environment", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "outputTransform", - ] - XCTAssertEqual(Set(graph.keys), expectedIDs) - - assertDeps(graph, "environment", []) - assertDeps(graph, "shadow", ["environment"]) - assertDeps(graph, "batchedShadow", ["shadow"]) - assertDeps(graph, "model", ["batchedShadow"]) - assertDeps(graph, "batchedModel", ["model"]) - assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) - assertDeps(graph, "transparency", ["lightPass"]) - assertDeps(graph, "spatialDebug", ["transparency"]) - assertDeps(graph, "outline", ["batchedModel"]) - assertDeps(graph, "lightVisualPass", ["outline"]) - assertDeps(graph, "gizmo", ["lightVisualPass"]) - assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) - assertDeps(graph, "look", ["precomp"]) - assertDeps(graph, "outputTransform", ["look"]) - - assertNoCycles(graph) - } - - func test_buildEditModeGraph_withEnvironmentRoot_fxaaEnabled() { - let originalEnv = renderEnvironment - renderEnvironment = true - defer { renderEnvironment = originalEnv } - - let originalAntiAliasingMode = antiAliasingMode - antiAliasingMode = .fxaa - defer { antiAliasingMode = originalAntiAliasingMode } - - let (graph, finalID) = buildEditModeGraph() - - XCTAssertEqual(finalID, "outputTransform") - - let expectedIDs: Set = [ - "environment", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "fxaa", "outputTransform", - ] - XCTAssertEqual(Set(graph.keys), expectedIDs) - - assertDeps(graph, "environment", []) - assertDeps(graph, "shadow", ["environment"]) - assertDeps(graph, "batchedShadow", ["shadow"]) - assertDeps(graph, "model", ["batchedShadow"]) - assertDeps(graph, "batchedModel", ["model"]) - assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) - assertDeps(graph, "transparency", ["lightPass"]) - assertDeps(graph, "spatialDebug", ["transparency"]) - assertDeps(graph, "outline", ["batchedModel"]) - assertDeps(graph, "lightVisualPass", ["outline"]) - assertDeps(graph, "gizmo", ["lightVisualPass"]) - assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) - assertDeps(graph, "look", ["precomp"]) - assertDeps(graph, "fxaa", ["look"]) - assertDeps(graph, "outputTransform", ["fxaa"]) - - assertNoCycles(graph) - } - - func test_buildEditModeGraph_withGridRoot_fxaaDisabled() { - let originalEnv = renderEnvironment - renderEnvironment = false - defer { renderEnvironment = originalEnv } - - let originalAntiAliasingMode = antiAliasingMode - antiAliasingMode = .none - defer { antiAliasingMode = originalAntiAliasingMode } - - let (graph, finalID) = buildEditModeGraph() - - XCTAssertEqual(finalID, "outputTransform") - - let expectedIDs: Set = [ - "grid", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "outputTransform", - ] - XCTAssertEqual(Set(graph.keys), expectedIDs) - - assertDeps(graph, "grid", []) - assertDeps(graph, "shadow", ["grid"]) - assertDeps(graph, "batchedShadow", ["shadow"]) - assertDeps(graph, "model", ["batchedShadow"]) - assertDeps(graph, "batchedModel", ["model"]) - assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) - assertDeps(graph, "transparency", ["lightPass"]) - assertDeps(graph, "spatialDebug", ["transparency"]) - assertDeps(graph, "outline", ["batchedModel"]) - assertDeps(graph, "lightVisualPass", ["outline"]) - assertDeps(graph, "gizmo", ["lightVisualPass"]) - assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) - assertDeps(graph, "look", ["precomp"]) - assertDeps(graph, "outputTransform", ["look"]) - - assertNoCycles(graph) - } - - func test_buildEditModeGraph_withGridRoot_fxaaEnabled() { - let originalEnv = renderEnvironment - renderEnvironment = false - defer { renderEnvironment = originalEnv } - - let originalAntiAliasingMode = antiAliasingMode - antiAliasingMode = .fxaa - defer { antiAliasingMode = originalAntiAliasingMode } - - let (graph, finalID) = buildEditModeGraph() - - XCTAssertEqual(finalID, "outputTransform") - - let expectedIDs: Set = [ - "grid", "shadow", "batchedShadow", "model", "batchedModel", "lightPass", - "transparency", "spatialDebug", "outline", "lightVisualPass", "gizmo", "precomp", "gaussian", "look", "fxaa", "outputTransform", - ] - XCTAssertEqual(Set(graph.keys), expectedIDs) - - assertDeps(graph, "grid", []) - assertDeps(graph, "shadow", ["grid"]) - assertDeps(graph, "batchedShadow", ["shadow"]) - assertDeps(graph, "model", ["batchedShadow"]) - assertDeps(graph, "batchedModel", ["model"]) - assertDeps(graph, "lightPass", ["batchedModel", "model", "shadow"]) - assertDeps(graph, "transparency", ["lightPass"]) - assertDeps(graph, "spatialDebug", ["transparency"]) - assertDeps(graph, "outline", ["batchedModel"]) - assertDeps(graph, "lightVisualPass", ["outline"]) - assertDeps(graph, "gizmo", ["lightVisualPass"]) - assertDeps(graph, "gaussian", ["model"]) - assertDeps(graph, "precomp", ["model", "gizmo", "spatialDebug", "gaussian"]) - assertDeps(graph, "look", ["precomp"]) - assertDeps(graph, "fxaa", ["look"]) - assertDeps(graph, "outputTransform", ["fxaa"]) - - assertNoCycles(graph) - } -} diff --git a/Tests/UntoldEditorTests/DemoGalleryViewTests.swift b/Tests/UntoldEditorTests/DemoGalleryViewTests.swift new file mode 100644 index 0000000..f98beb8 --- /dev/null +++ b/Tests/UntoldEditorTests/DemoGalleryViewTests.swift @@ -0,0 +1,127 @@ +// +// DemoGalleryViewTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import SwiftUI +@testable import UntoldEditor +import XCTest + +#if canImport(AppKit) + final class DemoGalleryViewTests: XCTestCase { + func test_demoSceneCatalog_wrapsStarterStreamsForExploreMode() { + XCTAssertEqual(demoSceneCatalog.count, starterStreamModels.count) + XCTAssertEqual(demoSceneCatalog.map(\.id), starterStreamModels.map(\.id)) + XCTAssertTrue(demoSceneCatalog.allSatisfy { $0.cameraFrame != nil }) + XCTAssertTrue(demoSceneCatalog.allSatisfy { !$0.title.isEmpty }) + XCTAssertTrue(demoSceneCatalog.allSatisfy { !$0.subtitle.isEmpty }) + XCTAssertTrue(demoSceneCatalog.allSatisfy { !$0.systemImageName.isEmpty }) + } + + func test_demoSceneCatalog_usesRemoteManifestSourcesForCurrentStarterDemos() { + for demo in demoSceneCatalog { + guard case let .remoteManifest(url) = demo.source else { + XCTFail("Expected current starter demo to use a remote manifest source.") + return + } + + XCTAssertEqual(url.pathExtension, "json") + XCTAssertEqual(demo.source.resolvedURL, url) + } + } + + func test_demoGalleryView_wiresCallbacks() { + let firstDemo = demoSceneCatalog[0] + var selectedDemoId: String? + var didTryOwnScene = false + var didCreateProject = false + var didOpenProject = false + var didOpenFullEditor = false + + let sut = DemoGalleryView( + demos: demoSceneCatalog, + onDemoSelected: { selectedDemoId = $0.id }, + onTryOwnScene: { didTryOwnScene = true }, + onCreateProject: { didCreateProject = true }, + onOpenProject: { didOpenProject = true }, + onOpenFullEditor: { didOpenFullEditor = true } + ) + + sut.onDemoSelected(firstDemo) + sut.onTryOwnScene() + sut.onCreateProject() + sut.onOpenProject() + sut.onOpenFullEditor() + + XCTAssertEqual(selectedDemoId, firstDemo.id) + XCTAssertTrue(didTryOwnScene) + XCTAssertTrue(didCreateProject) + XCTAssertTrue(didOpenProject) + XCTAssertTrue(didOpenFullEditor) + } + + func test_demoGalleryView_composesWithoutCrash() { + let sut = DemoGalleryView( + demos: demoSceneCatalog, + onDemoSelected: { _ in }, + onTryOwnScene: {}, + onCreateProject: {}, + onOpenProject: {}, + onOpenFullEditor: {} + ) + + let host = NSHostingController(rootView: sut) + XCTAssertNotNil(host.view) + } + + func test_previewImportGalleryView_wiresCallbacks() { + var selectedModes: [QuickPreviewImportMode] = [] + var didBackToDemos = false + var didOpenFullEditor = false + + let sut = PreviewImportGalleryView( + onModeSelected: { selectedModes.append($0) }, + onBackToDemos: { didBackToDemos = true }, + onOpenFullEditor: { didOpenFullEditor = true } + ) + + sut.onModeSelected(.untoldAsset) + sut.onModeSelected(.tiledScene) + sut.onModeSelected(.gaussian) + sut.onBackToDemos() + sut.onOpenFullEditor() + + XCTAssertEqual(selectedModes, [.untoldAsset, .tiledScene, .gaussian]) + XCTAssertTrue(didBackToDemos) + XCTAssertTrue(didOpenFullEditor) + } + + func test_previewImportGalleryView_composesWithoutCrash() { + let sut = PreviewImportGalleryView( + onModeSelected: { _ in }, + onBackToDemos: {}, + onOpenFullEditor: {} + ) + + let host = NSHostingController(rootView: sut) + XCTAssertNotNil(host.view) + } + + func test_quickPreviewSceneOverlayView_composesWithoutCrash() { + let sut = QuickPreviewSceneOverlayView( + title: "Kitchen Preview", + mode: .untoldAsset, + onLoadAnother: {}, + onChooseDemo: {}, + onOpenFullEditor: {} + ) + + let host = NSHostingController(rootView: sut) + XCTAssertNotNil(host.view) + } + } +#endif diff --git a/Tests/UntoldEditorTests/EditorRenderPassesTests.swift b/Tests/UntoldEditorTests/EditorRenderPassesTests.swift index 13506da..7231ffe 100644 --- a/Tests/UntoldEditorTests/EditorRenderPassesTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderPassesTests.swift @@ -429,6 +429,28 @@ final class EditorRenderPassesTests: XCTestCase { "Non-light entities should use scale of 1.2") } + func test_areaLightDebugPlaneNormalMatchesEmissionDirection() { + testEntity = createEntity() + createAreaLight(entityId: testEntity) + + guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: testEntity) else { + XCTFail("Area light should have a world transform.") + return + } + + let debugModelMatrix = areaLightDebugModelMatrix(worldTransform: worldTransform.space) + let debugPlaneNormal = simd_normalize(simd_float3( + debugModelMatrix.columns.1.x, + debugModelMatrix.columns.1.y, + debugModelMatrix.columns.1.z + )) + let emissionDirection = simd_normalize(getLightEmissionDirection(entityId: testEntity)) + + XCTAssertEqual(debugPlaneNormal.x, emissionDirection.x, accuracy: 0.0001) + XCTAssertEqual(debugPlaneNormal.y, emissionDirection.y, accuracy: 0.0001) + XCTAssertEqual(debugPlaneNormal.z, emissionDirection.z, accuracy: 0.0001) + } + // MARK: - Buffer Resource Tests func test_debuggerExecution_usesQuadBuffers() { diff --git a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift index 560b5c2..6efda4f 100644 --- a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift @@ -5,349 +5,72 @@ // Copyright (C) Untold Engine Studios // Licensed under the GNU LGPL v3.0 or later. // See the LICENSE file or for details. +// -// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue. - -import MetalKit -import ModelIO @testable import UntoldEditor @testable import UntoldEngine import XCTest +@MainActor final class EditorRenderingSystemTests: XCTestCase { - private var originalRenderEnvironment: Bool! - private var originalVisualDebug: Bool! - private var originalGameMode: Bool! - private var originalAntiAliasingMode: AntiAliasingMode! + private var originalGameMode = false + private var renderer: UntoldRenderer? override func setUp() { super.setUp() - - // Save original state - originalRenderEnvironment = renderEnvironment - originalVisualDebug = visualDebug originalGameMode = gameMode - originalAntiAliasingMode = antiAliasingMode - - // Set up Metal device - guard let device = MTLCreateSystemDefaultDevice() else { - assertionFailure("Metal device is not available.") - return - } - - renderInfo.device = device - renderInfo.commandQueue = device.makeCommandQueue() - vertexDescriptor.model = MDLVertexDescriptor() - - // Reset to default test state - renderEnvironment = false - visualDebug = false - gameMode = false - antiAliasingMode = .none + renderer = UntoldRenderer.create() + XCTAssertNotNil(renderer) + _ = registerEditorRenderExtension() } override func tearDown() { - // Restore original state - renderEnvironment = originalRenderEnvironment - visualDebug = originalVisualDebug gameMode = originalGameMode - antiAliasingMode = originalAntiAliasingMode - + RenderExtensionRegistry.shared.unregister(id: EditorRenderExtension.shared.id) + renderer = nil super.tearDown() } - // MARK: - buildEditModeGraph Tests - - func test_buildEditModeGraph_withGridMode_createsCorrectGraphStructure() { - // Arrange - renderEnvironment = false - - // Act - let (graph, finalPassID) = buildEditModeGraph() - - // Assert - Verify grid mode structure - XCTAssertNotNil(graph["grid"], "Grid mode should create grid pass") - XCTAssertNil(graph["environment"], "Grid mode should not create environment pass") - - // Verify essential passes exist - XCTAssertNotNil(graph["shadow"], "Shadow pass should exist") - XCTAssertNotNil(graph["batchedShadow"], "Batched shadow pass should exist") - XCTAssertNotNil(graph["model"], "Model pass should exist") - XCTAssertNotNil(graph["batchedModel"], "Batched model pass should exist") - XCTAssertNotNil(graph["lightPass"], "Light pass should exist") - XCTAssertNotNil(graph["outline"], "Highlight/outline pass should exist") - XCTAssertNotNil(graph["lightVisualPass"], "Light visual pass should exist") - XCTAssertNotNil(graph["gizmo"], "Gizmo pass should exist") - XCTAssertNotNil(graph["spatialDebug"], "Spatial debug pass should exist") - XCTAssertNotNil(graph["precomp"], "Pre-composite pass should exist") - XCTAssertNotNil(graph["look"], "Look pass should exist") - XCTAssertNotNil(graph["outputTransform"], "Output transform pass should exist") - - // Verify final pass ID - XCTAssertEqual(finalPassID, "outputTransform", "Final pass ID should be outputTransform") - } - - func test_buildEditModeGraph_withEnvironmentMode_createsCorrectGraphStructure() { - // Arrange - renderEnvironment = true - - // Act - let (graph, finalPassID) = buildEditModeGraph() - - // Assert - Verify environment mode structure - XCTAssertNotNil(graph["environment"], "Environment mode should create environment pass") - XCTAssertNil(graph["grid"], "Environment mode should not create grid pass") - - // Verify essential passes exist - XCTAssertNotNil(graph["shadow"], "Shadow pass should exist") - XCTAssertNotNil(graph["batchedShadow"], "Batched shadow pass should exist") - XCTAssertNotNil(graph["model"], "Model pass should exist") - XCTAssertNotNil(graph["batchedModel"], "Batched model pass should exist") - XCTAssertNotNil(graph["lightPass"], "Light pass should exist") - XCTAssertNotNil(graph["outline"], "Highlight/outline pass should exist") - XCTAssertNotNil(graph["lightVisualPass"], "Light visual pass should exist") - XCTAssertNotNil(graph["gizmo"], "Gizmo pass should exist") - XCTAssertNotNil(graph["spatialDebug"], "Spatial debug pass should exist") - XCTAssertNotNil(graph["precomp"], "Pre-composite pass should exist") - XCTAssertNotNil(graph["look"], "Look pass should exist") - XCTAssertNotNil(graph["outputTransform"], "Output transform pass should exist") - - // Verify final pass ID - XCTAssertEqual(finalPassID, "outputTransform", "Final pass ID should be outputTransform") - } - - func test_buildEditModeGraph_gridMode_hasCorrectDependencies() { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() - - // Assert - Verify dependency chain - XCTAssertEqual(graph["grid"]?.dependencies.count, 0, "Grid pass should have no dependencies") - XCTAssertEqual(graph["shadow"]?.dependencies, ["grid"], "Shadow should depend on grid") - XCTAssertEqual(graph["batchedShadow"]?.dependencies, ["shadow"], "Batched shadow should depend on shadow") - XCTAssertEqual(graph["model"]?.dependencies, ["batchedShadow"], "Model should depend on batchedShadow") - XCTAssertEqual(graph["batchedModel"]?.dependencies, ["model"], "Batched model should depend on model") - XCTAssertTrue(graph["lightPass"]?.dependencies.contains("model") ?? false, "Light pass should depend on model") - XCTAssertTrue(graph["lightPass"]?.dependencies.contains("shadow") ?? false, "Light pass should depend on shadow") - XCTAssertTrue(graph["lightPass"]?.dependencies.contains("batchedModel") ?? false, "Light pass should depend on batchedModel") - XCTAssertEqual(graph["outline"]?.dependencies, ["batchedModel"], "Outline should depend on batchedModel") - XCTAssertEqual(graph["lightVisualPass"]?.dependencies, ["outline"], "Light visual pass should depend on outline") - XCTAssertEqual(graph["gizmo"]?.dependencies, ["lightVisualPass"], "Gizmo should depend on light visual pass") - XCTAssertEqual(graph["transparency"]?.dependencies, ["lightPass"], "Transparency should depend on light pass") - XCTAssertEqual(graph["spatialDebug"]?.dependencies, ["transparency"], "Spatial debug should depend on transparency") - - let precompDeps = graph["precomp"]?.dependencies ?? [] - XCTAssertTrue(precompDeps.contains("model"), "Precomp should depend on model") - XCTAssertTrue(precompDeps.contains("gizmo"), "Precomp should depend on gizmo") - XCTAssertTrue(precompDeps.contains("spatialDebug"), "Precomp should depend on spatialDebug") - - XCTAssertEqual(graph["look"]?.dependencies, ["precomp"], "Look should depend on precomp") - XCTAssertEqual(graph["outputTransform"]?.dependencies, ["look"], "Output transform should depend on look") - } - - func test_buildEditModeGraph_environmentMode_hasCorrectDependencies() { - // Arrange - renderEnvironment = true - - // Act - let (graph, _) = buildEditModeGraph() - - // Assert - Verify dependency chain - XCTAssertEqual(graph["environment"]?.dependencies.count, 0, "Environment pass should have no dependencies") - XCTAssertEqual(graph["shadow"]?.dependencies, ["environment"], "Shadow should depend on environment") - XCTAssertEqual(graph["batchedShadow"]?.dependencies, ["shadow"], "Batched shadow should depend on shadow") - XCTAssertEqual(graph["model"]?.dependencies, ["batchedShadow"], "Model should depend on batchedShadow") - XCTAssertEqual(graph["batchedModel"]?.dependencies, ["model"], "Batched model should depend on model") - } - - func test_buildEditModeGraph_canBeTopologicallySorted() throws { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() - - // Assert - Verify graph can be sorted without cycles - XCTAssertNoThrow(try topologicalSortGraph(graph: graph), "Edit mode graph should be sortable without cycles") - - let sortedPasses = try topologicalSortGraph(graph: graph) - XCTAssertTrue(sortedPasses.count > 0, "Sorted passes should not be empty") - XCTAssertEqual(sortedPasses.count, graph.count, "Sorted passes count should match graph size") + func testRegistrationAddsEditorExtension() { + XCTAssertTrue( + RenderExtensionRegistry.shared.registeredIDs().contains(EditorRenderExtension.shared.id) + ) } - func test_buildEditModeGraph_topologicalOrder_respectsDependencies() throws { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() - let sortedPasses = try topologicalSortGraph(graph: graph) - let order = sortedPasses.map(\.id) - - // Assert - Verify topological constraints - assertTopologicalConstraints(order: order, constraints: [ - ("grid", "shadow"), - ("shadow", "model"), - ("model", "lightPass"), - ("model", "outline"), - ("outline", "lightVisualPass"), - ("lightVisualPass", "gizmo"), - ("transparency", "spatialDebug"), - ("spatialDebug", "precomp"), - ("gizmo", "precomp"), - ("lightPass", "precomp"), - ("precomp", "look"), - ("look", "outputTransform"), - ]) - } - - func test_buildEditModeGraph_switchingBetweenModes_producesCorrectBasePass() { - // Test grid mode - renderEnvironment = false - var (graph, _) = buildEditModeGraph() - XCTAssertNotNil(graph["grid"], "Grid mode should have grid pass") - XCTAssertNil(graph["environment"], "Grid mode should not have environment pass") - - // Switch to environment mode - renderEnvironment = true - (graph, _) = buildEditModeGraph() - XCTAssertNotNil(graph["environment"], "Environment mode should have environment pass") - XCTAssertNil(graph["grid"], "Environment mode should not have grid pass") - - // Switch back to grid mode - renderEnvironment = false - (graph, _) = buildEditModeGraph() - XCTAssertNotNil(graph["grid"], "Grid mode should have grid pass after switching back") - XCTAssertNil(graph["environment"], "Grid mode should not have environment pass after switching back") - } - - func test_buildEditModeGraph_executablePassesHaveExecutionFunctions() { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() - - // Assert - Verify non-stub passes have execution functions - let stubPassIDs: Set = ["batchedModel", "lightPass"] - for (passID, pass) in graph { - if stubPassIDs.contains(passID) { - XCTAssertNil(pass.execute, "Pass '\(passID)' should be a dependency-only stub") - } else { - XCTAssertNotNil(pass.execute, "Pass '\(passID)' should have an execution function") - } - } - } - - func test_buildEditModeGraph_passIDsMatchKeys() { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() - - // Assert - Verify pass IDs match dictionary keys - for (key, pass) in graph { - XCTAssertEqual(key, pass.id, "Dictionary key '\(key)' should match pass ID '\(pass.id)'") - } - } - - func test_buildEditModeGraph_allDependenciesExist() { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() - - // Assert - Verify all dependencies exist in the graph - for (passID, pass) in graph { - for dependency in pass.dependencies { - XCTAssertNotNil(graph[dependency], - "Pass '\(passID)' depends on '\(dependency)', but '\(dependency)' doesn't exist in graph") - } - } - } - - // MARK: - Graph Structure Validation Tests - - func test_buildEditModeGraph_noCyclicDependencies() { - // Test both modes for cycles - for useEnvironment in [true, false] { - renderEnvironment = useEnvironment - let (graph, _) = buildEditModeGraph() - - XCTAssertNoThrow( - try topologicalSortGraph(graph: graph), - "Graph should not contain cycles in \(useEnvironment ? "environment" : "grid") mode" - ) - } - } - - func test_buildEditModeGraph_precompPass_hasMultipleDependencies() { - // Arrange - renderEnvironment = false + func testEditModeInjectsEditorPassesBeforeComposite() throws { + gameMode = false - // Act - let (graph, _) = buildEditModeGraph() + let (graph, _) = try buildGameModeGraph() + let order = try topologicalSortGraph(graph: graph).map(\.id) + let editorPasses = [ + "untold.editor.highlight", + "untold.editor.lightVisuals", + "untold.editor.gizmo", + ] - // Assert - guard let precompPass = graph["precomp"] else { - XCTFail("Precomp pass should exist") - return + for passID in editorPasses { + XCTAssertNotNil(graph[passID]) } - XCTAssertEqual(precompPass.dependencies.count, 4, "Precomp should have exactly 4 dependencies") - XCTAssertTrue(precompPass.dependencies.contains("model"), "Precomp should depend on model") - XCTAssertTrue(precompPass.dependencies.contains("gizmo"), "Precomp should depend on gizmo") - XCTAssertTrue(precompPass.dependencies.contains("spatialDebug"), "Precomp should depend on spatialDebug") - XCTAssertTrue(precompPass.dependencies.contains("gaussian"), "Precomp should depend on gaussian") + XCTAssertLessThan(try XCTUnwrap(order.firstIndex(of: editorPasses[0])), try XCTUnwrap(order.firstIndex(of: editorPasses[1]))) + XCTAssertLessThan(try XCTUnwrap(order.firstIndex(of: editorPasses[1])), try XCTUnwrap(order.firstIndex(of: editorPasses[2]))) + XCTAssertLessThan(try XCTUnwrap(order.firstIndex(of: editorPasses[2])), try XCTUnwrap(order.firstIndex(of: "precomp"))) } - func test_buildEditModeGraph_lightPass_dependsOnModelAndShadow() { - // Arrange - renderEnvironment = false - - // Act - let (graph, _) = buildEditModeGraph() + func testPlayModeUsesRuntimeGraphWithoutEditorPasses() throws { + gameMode = true - // Assert - guard let lightPass = graph["lightPass"] else { - XCTFail("Light pass should exist") - return - } + let (graph, _) = try buildGameModeGraph() - XCTAssertNil(lightPass.execute, "Light pass should be a dependency-only stub") - XCTAssertEqual(lightPass.dependencies.count, 3, "Light pass should have exactly 3 dependencies") - XCTAssertTrue(lightPass.dependencies.contains("model"), "Light pass should depend on model") - XCTAssertTrue(lightPass.dependencies.contains("shadow"), "Light pass should depend on shadow") - XCTAssertTrue(lightPass.dependencies.contains("batchedModel"), "Light pass should depend on batchedModel") + XCTAssertNil(graph["untold.editor.highlight"]) + XCTAssertNil(graph["untold.editor.lightVisuals"]) + XCTAssertNil(graph["untold.editor.gizmo"]) } - // MARK: - Helper Methods - - private func assertTopologicalConstraints( - order: [String], - constraints: [(String, String)], - file: StaticString = #file, - line: UInt = #line - ) { - for (before, after) in constraints { - guard let beforeIndex = order.firstIndex(of: before), - let afterIndex = order.firstIndex(of: after) - else { - XCTFail("Both '\(before)' and '\(after)' should be in the sorted order", file: file, line: line) - continue - } + func testEditorGraphCompilesWithoutCycles() throws { + gameMode = false + let (graph, _) = try buildGameModeGraph() - XCTAssertLessThan( - beforeIndex, - afterIndex, - "'\(before)' should come before '\(after)' in topological order", - file: file, - line: line - ) - } + XCTAssertNoThrow(try topologicalSortGraph(graph: graph)) } } diff --git a/Tests/UntoldEditorTests/GizmoSystemTest.swift b/Tests/UntoldEditorTests/GizmoSystemTest.swift index c202a5e..8bc71cf 100644 --- a/Tests/UntoldEditorTests/GizmoSystemTest.swift +++ b/Tests/UntoldEditorTests/GizmoSystemTest.swift @@ -396,7 +396,7 @@ final class GizmoSystemTests: XCTestCase { XCTAssertFalse(proxies.isEmpty, "Expected hidden hit proxy for the light direction handle.") } - func test_createGizmo_placesLightDirectionHandleAlongCurrentLightForwardAfterReselect() { + func test_createGizmo_placesLightDirectionHandleAlongCurrentLightEmissionAfterReselect() { let light = makeEntity(name: "DirectionalLight", pos: SIMD3(0, 0, 0), isLight: true) activeEntity = light rotateTo(entityId: light, angle: 35.0, axis: SIMD3(0.0, 1.0, 0.0)) @@ -413,10 +413,28 @@ final class GizmoSystemTests: XCTestCase { return } - let expectedOffset = simd_normalize(-1.0 * getForwardAxisVector(entityId: light)) + let expectedOffset = simd_normalize(getLightEmissionDirection(entityId: light)) assertNearlyEqual(getLocalPosition(entityId: directionHandle), expectedOffset, accuracy: 0.0002) } + func test_createGizmo_placesAreaLightDirectionHandleAlongAreaEmission() { + let area = createEntity() + createAreaLight(entityId: area) + activeEntity = area + + createGizmo(mode: .translate) + + let directionHandle = findGizmoHandle(mode: .lightRotate, axis: .none) + guard directionHandle != .invalid else { + XCTFail("Expected light direction handle.") + return + } + + let expectedOffset = simd_normalize(getLightEmissionDirection(entityId: area)) + assertNearlyEqual(getLocalPosition(entityId: directionHandle), expectedOffset, accuracy: 0.0002) + assertNearlyEqual(expectedOffset, simd_float3(0.0, -1.0, 0.0), accuracy: 0.0002) + } + func test_gizmoRootWorldPosition_usesGizmoParentWhenAvailable() { let active = makeEntity(name: "OffsetBox", pos: SIMD3(1, 2, 3)) activeEntity = active diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index ba4d2b7..d309a75 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -31,8 +31,7 @@ import XCTest onCreateSphereCalled: UnsafeMutablePointer, onCreatePlaneCalled: UnsafeMutablePointer, onCreateCylinderCalled: UnsafeMutablePointer, - onCreateConeCalled: UnsafeMutablePointer, - onQuickPreviewModes: UnsafeMutablePointer<[QuickPreviewImportMode]>? = nil + onCreateConeCalled: UnsafeMutablePointer ) -> ToolbarView { ToolbarView( selectionManager: selectionManager, @@ -49,8 +48,7 @@ import XCTest onCreateSphere: { onCreateSphereCalled.pointee = true }, onCreatePlane: { onCreatePlaneCalled.pointee = true }, onCreateCylinder: { onCreateCylinderCalled.pointee = true }, - onCreateCone: { onCreateConeCalled.pointee = true }, - onQuickPreview: { mode in onQuickPreviewModes?.pointee.append(mode) } + onCreateCone: { onCreateConeCalled.pointee = true } ) } @@ -69,7 +67,6 @@ import XCTest var onPlane = false var onCylinder = false var onCone = false - var quickPreviewModes: [QuickPreviewImportMode] = [] let sut = makeSUT( onSaveCalled: &onSave, @@ -85,8 +82,7 @@ import XCTest onCreateSphereCalled: &onSphere, onCreatePlaneCalled: &onPlane, onCreateCylinderCalled: &onCylinder, - onCreateConeCalled: &onCone, - onQuickPreviewModes: &quickPreviewModes + onCreateConeCalled: &onCone ) // We cannot programmatically tap SwiftUI Buttons without a host and introspection. @@ -103,8 +99,6 @@ import XCTest sut.onCreatePlane() sut.onCreateCylinder() sut.onCreateCone() - sut.onQuickPreview(.untoldAsset) - sut.onQuickPreview(.tiledScene) XCTAssertTrue(onSave, "onSave should be wired.") XCTAssertTrue(onSaveAs, "onSaveAs should be wired.") @@ -118,7 +112,6 @@ import XCTest XCTAssertTrue(onPlane, "onCreatePlane should be wired.") XCTAssertTrue(onCylinder, "onCreateCylinder should be wired.") XCTAssertTrue(onCone, "onCreateCone should be wired.") - XCTAssertEqual(quickPreviewModes, [.untoldAsset, .tiledScene], "onQuickPreview should pass selected preview modes.") // For play toggle, verify the closure records values we pass. // Since @State is internal, we mimic the button behavior by calling the closure directly. @@ -147,8 +140,7 @@ import XCTest onCreateSphere: {}, onCreatePlane: {}, onCreateCylinder: {}, - onCreateCone: {}, - onQuickPreview: { _ in } + onCreateCone: {} ) // Wrap in a hosting controller to ensure SwiftUI can build the body. @@ -183,8 +175,7 @@ import XCTest onCreateSphere: { sphereCreated = true }, onCreatePlane: { planeCreated = true }, onCreateCylinder: { cylinderCreated = true }, - onCreateCone: { coneCreated = true }, - onQuickPreview: { _ in } + onCreateCone: { coneCreated = true } ) // When: Invoking the primitive creation closures @@ -220,8 +211,7 @@ import XCTest onCreateSphere: { callCount += 1 }, onCreatePlane: { callCount += 1 }, onCreateCylinder: {}, - onCreateCone: {}, - onQuickPreview: { _ in } + onCreateCone: {} ) // When: Calling the primitive closures @@ -254,8 +244,7 @@ import XCTest onCreateSphere: { sphereCount += 1 }, onCreatePlane: { planeCount += 1 }, onCreateCylinder: {}, - onCreateCone: {}, - onQuickPreview: { _ in } + onCreateCone: {} ) // When: Calling specific primitive closures multiple times @@ -268,19 +257,5 @@ import XCTest XCTAssertEqual(sphereCount, 1, "Sphere should be created once") XCTAssertEqual(planeCount, 0, "Plane should not be created") } - - func test_quickPreviewModes_exposeExpectedPickerConfiguration() { - XCTAssertEqual(QuickPreviewImportMode.allCases, [.untoldAsset, .tiledScene, .gaussian]) - - XCTAssertEqual(QuickPreviewImportMode.untoldAsset.menuTitle, "Load Untold Asset (.untold)") - let runtimePreviewExtensions = Set(QuickPreviewImportMode.untoldAsset.allowedContentTypes.compactMap(\.preferredFilenameExtension)) - XCTAssertEqual(runtimePreviewExtensions, ["untold"]) - - XCTAssertEqual(QuickPreviewImportMode.tiledScene.menuTitle, "Load Tiled Stream (.json)") - XCTAssertEqual(QuickPreviewImportMode.tiledScene.allowedContentTypes, [.json]) - - XCTAssertEqual(QuickPreviewImportMode.gaussian.menuTitle, "Load Gaussian (.ply)") - XCTAssertEqual(QuickPreviewImportMode.gaussian.allowedContentTypes.first?.preferredFilenameExtension, "ply") - } } #endif