diff --git a/CHANGELOG.md b/CHANGELOG.md index a4fb66d..d4d781f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,20 @@ # Changelog +## v0.12.14 - 2026-05-22 +### 🐞 Fixes +- [Patch] Migrate editor ray picking to ScenePickingSystem (c059ced…) +- [Patch] Added FPS Stats (4e80c55…) +- [Patch] Implemented undo redo feature (9ddf6e3…) +- [Patch] Fix ci build failure (9c7b8e4…) +- [Patch] add support to assetbrowser to import remote asset (5438b79…) +- [Patch] Fix anti-aliasing failure (8ac8c8b…) +- [Patch] Fix anti-aliasing failure (b3eb245…) +- [Patch] Fixed the quick load preview (36825d8…) +- [Patch] fixed rotation gizmo (9d23a4a…) +- [Patch] Modify quick preview (59f1ae2…) +- [Patch] Fixed gizmo parent-child selection (77432da…) +- [Patch] Fixed the direction handler (a19d897…) +- [Patch] Remove debug view from editor (5a592ef…) +- [Patch] Removed export tools (4e279db…) ## v0.12.10 - 2026-04-29 ### 🐞 Fixes - [Patch] Fixed bundle script to include required helper scripts (8bec369…) diff --git a/Package.swift b/Package.swift index 7d84d8f..094b464 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.12.10"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.12.14"), ], targets: [ .executableTarget( diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 3ccd431..0021e21 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -15,14 +15,6 @@ 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 @@ -222,10 +214,6 @@ 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) } @@ -318,6 +306,42 @@ enum AssetCategory: String, CaseIterable { } } +private struct RemoteStreamImportSheet: View { + @Binding var urlString: String + var onImport: () -> Void + var onCancel: () -> Void + + @FocusState private var isURLFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Import Remote Stream") + .font(.headline) + + VStack(alignment: .leading, spacing: 6) { + Text("Manifest URL") + .font(.system(size: 12)) + .foregroundColor(.secondary) + TextField("https://cdn.example.com/dungeon/dungeon.json", text: $urlString) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isURLFocused) + } + + HStack { + Spacer() + Button("Cancel", action: onCancel) + .buttonStyle(.bordered) + Button("Import", action: onImport) + .disabled(urlString.trimmingCharacters(in: .whitespaces).isEmpty) + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 420) + .onAppear { isURLFocused = true } + } +} + struct AssetBrowserView: View { @Binding var assets: [String: [Asset]] @Binding var selectedAsset: Asset? @@ -337,11 +361,8 @@ struct AssetBrowserView: View { @State private var statusIsError = false @State private var targetEntityName: String = "None" @State private var showImportMenu = false - @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 showRemoteStreamSheet = false + @State private var remoteStreamURLString = "" @State private var pendingTilesExport: TilesExportRequest? @State private var tilesExportQueue: [TilesExportRequest] = [] @State private var isExportingTilesAsset = false @@ -383,6 +404,13 @@ struct AssetBrowserView: View { } } } + Divider() + Button(action: { showRemoteStreamSheet = true }) { + HStack { + Image(systemName: "globe") + Text("Import Remote Stream") + } + } } label: { HStack(spacing: 6) { Text("Import") @@ -612,12 +640,18 @@ 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) } + .sheet(isPresented: $showRemoteStreamSheet) { + RemoteStreamImportSheet(urlString: $remoteStreamURLString) { + saveRemoteStream() + showRemoteStreamSheet = false + } onCancel: { + remoteStreamURLString = "" + showRemoteStreamSheet = false + } + } .overlay(alignment: .bottom) { if let statusMessage { Text(statusMessage) @@ -644,9 +678,7 @@ struct AssetBrowserView: View { // Set allowed file types based on category switch category { case .models, .animations: - openPanel.allowedContentTypes = ([runtimeAssetExtension] + sourceAssetExtensions.sorted()).compactMap { - UTType(filenameExtension: $0) - } + openPanel.allowedContentTypes = [UTType(filenameExtension: runtimeAssetExtension)!] case .streamModels: openPanel.allowedContentTypes = ([UTType(filenameExtension: "json")!] + sourceAssetExtensions.sorted().compactMap { UTType(filenameExtension: $0) }) case .scripts: @@ -713,12 +745,6 @@ 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": @@ -766,9 +792,7 @@ struct AssetBrowserView: View { } loadAssets() - if runtimeExportQueue.isEmpty, pendingRuntimeExport == nil, - tilesExportQueue.isEmpty, pendingTilesExport == nil - { + if tilesExportQueue.isEmpty, pendingTilesExport == nil { showStatus("Queued import of \(openPanel.urls.count) item(s) (see Console)") } } @@ -785,262 +809,6 @@ 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( @@ -1406,6 +1174,11 @@ struct AssetBrowserView: View { category: category.rawValue, path: item, isFolder: false)) + } else if item.pathExtension.lowercased() == "remotestream" { + categoryAssets.append(Asset(name: item.deletingPathExtension().lastPathComponent, + category: category.rawValue, + path: item, + isFolder: false)) } } else if category == .scenes { // For Scenes, allow files directly in the Scenes folder @@ -1462,9 +1235,10 @@ struct AssetBrowserView: View { } private func assetRow(_ asset: Asset) -> some View { - HStack { - Image(systemName: asset.isFolder ? "folder.fill" : "cube.fill") - .foregroundColor(.gray) + let isRemote = asset.path.pathExtension.lowercased() == "remotestream" + return HStack { + Image(systemName: asset.isFolder ? "folder.fill" : isRemote ? "globe" : "cube.fill") + .foregroundColor(isRemote ? .blue : .gray) Text(asset.name) .font(.system(size: 14, weight: .regular, design: .monospaced)) Spacer() @@ -1486,7 +1260,7 @@ struct AssetBrowserView: View { if isDir.boolValue { return Asset(name: item.lastPathComponent, category: selectedCategory ?? "", path: item, isFolder: true) } else { - let allowedExtensions: Set = [runtimeAssetExtension, "utex", "png", "jpg", "jpeg", "hdr", "tif", "tiff", "ply", "json", "uscript"] + let allowedExtensions: Set = [runtimeAssetExtension, "utex", "png", "jpg", "jpeg", "hdr", "tif", "tiff", "ply", "json", "uscript", "remotestream"] guard allowedExtensions.contains(item.pathExtension.lowercased()) else { return nil } return Asset(name: item.lastPathComponent, @@ -1635,9 +1409,77 @@ struct AssetBrowserView: View { showStatus("Loading stream model: \(sceneName)...") } + private func saveRemoteStream() { + guard let basePath = assetBasePath else { + showStatus("No project loaded", isError: true) + return + } + + let urlStr = remoteStreamURLString.trimmingCharacters(in: .whitespaces) + + guard let remoteURL = URL(string: urlStr), + remoteURL.scheme?.lowercased() == "https", + let host = remoteURL.host, !host.isEmpty, + remoteURL.pathExtension.lowercased() == "json" + else { + showStatus("URL must be a valid https:// link ending in .json", isError: true) + return + } + + let baseName = remoteURL.deletingPathExtension().lastPathComponent + // Hash the full URL to avoid collisions between manifests with the same filename on different hosts. + let urlHash = String(format: "%08x", urlStr.utf8.reduce(UInt32(5381)) { ($0 &* 31) &+ UInt32($1) }) + let name = "\(baseName)-\(urlHash)" + + let streamModelsFolder = basePath.appendingPathComponent("StreamModels", isDirectory: true) + do { + try FileManager.default.createDirectory(at: streamModelsFolder, withIntermediateDirectories: true) + let fileURL = streamModelsFolder + .appendingPathComponent(name) + .appendingPathExtension("remotestream") + try urlStr.write(to: fileURL, atomically: true, encoding: .utf8) + remoteStreamURLString = "" + loadAssets() + showStatus("Remote stream '\(baseName)' added") + } catch { + showStatus("Failed to save remote stream: \(error.localizedDescription)", isError: true) + } + } + + private func loadRemoteStreamModel(from asset: Asset) { + guard let urlStr = try? String(contentsOf: asset.path, encoding: .utf8), + let url = URL(string: urlStr.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + showStatus("Invalid URL in \(asset.name)", isError: true) + return + } + + let sceneRoot = createEntity() + let sceneName = asset.name + setEntityName(entityId: sceneRoot, name: sceneName) + + setEntityStreamScene(entityId: sceneRoot, url: url) { success in + DispatchQueue.main.async { + if success { + showStatus("Loaded remote stream: \(sceneName)") + } else { + showStatus("Failed to load remote stream: \(sceneName)", isError: true) + } + sceneGraphModel.refreshHierarchy() + } + } + + selectionManager.selectedEntity = sceneRoot + showStatus("Loading remote stream: \(sceneName)...") + } + private func handle_add_model_double_click(asset: Asset) { if asset.category == AssetCategory.streamModels.rawValue { - loadStreamModel(from: asset) + if asset.path.pathExtension.lowercased() == "remotestream" { + loadRemoteStreamModel(from: asset) + } else { + loadStreamModel(from: asset) + } return } diff --git a/Sources/UntoldEditor/Editor/AssetNodeEditingPolicy.swift b/Sources/UntoldEditor/Editor/AssetNodeEditingPolicy.swift index fc36b5f..635e6c6 100644 --- a/Sources/UntoldEditor/Editor/AssetNodeEditingPolicy.swift +++ b/Sources/UntoldEditor/Editor/AssetNodeEditingPolicy.swift @@ -20,17 +20,21 @@ func editableAssetRootEntity(for entityId: EntityID) -> EntityID { assetRootEntityId(for: entityId) ?? entityId } +func sceneTransformEntity(for entityId: EntityID) -> EntityID { + entityId +} + func isAssetInstanceRoot(_ entityId: EntityID) -> Bool { scene.get(component: AssetInstanceComponent.self, for: entityId) != nil } func canEditSceneTransform(entityId: EntityID) -> Bool { - isDerivedAssetNode(entityId) == false + hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) } func selectableTransformEntity(for entityId: EntityID) -> EntityID { - let editableRoot = editableAssetRootEntity(for: entityId) - return canEditSceneTransform(entityId: editableRoot) ? editableRoot : .invalid + let transformEntity = sceneTransformEntity(for: entityId) + return canEditSceneTransform(entityId: transformEntity) ? transformEntity : .invalid } func isBindableAssetMeshNode(_ entityId: EntityID) -> Bool { @@ -55,7 +59,8 @@ func canShowComponentInInspector(componentType: Any.Type, for entityId: EntityID if EditorAuthoringMode.sceneCompositionOnly { if isDerivedAssetNode(entityId) { - return false + return key == ObjectIdentifier(RenderComponent.self) + || key == ObjectIdentifier(LocalTransformComponent.self) } return key == ObjectIdentifier(RenderComponent.self) diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index 5dd8213..7898e22 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -713,17 +713,13 @@ public struct EditorView: View { // MARK: - Quick Preview - private func editor_handleQuickPreview() { + private func editor_handleQuickPreview(mode: QuickPreviewImportMode) { let openPanel = NSOpenPanel() - openPanel.title = "Quick Preview - Select 3D File" - openPanel.allowedContentTypes = [ - UTType(filenameExtension: "untold")!, - UTType(filenameExtension: "ply")!, - UTType(filenameExtension: "json")!, - ] + openPanel.title = mode.filePickerTitle + openPanel.allowedContentTypes = mode.allowedContentTypes openPanel.allowsMultipleSelection = false openPanel.canChooseDirectories = false - openPanel.message = "Select an Untold asset, PLY Gaussian, or tiled scene manifest to preview" + openPanel.message = mode.filePickerMessage guard openPanel.runModal() == .OK, let fileURL = openPanel.url else { return @@ -737,6 +733,8 @@ public struct EditorView: View { return } + deleteExistingQuickPreviewEntities() + // Create a new entity for the preview removeGizmo() let entityId = createEntity() @@ -753,6 +751,9 @@ public struct EditorView: View { } if fileExtension == "untold" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = false + // Load Untold runtime asset using absolute path setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in if success { @@ -762,10 +763,16 @@ public struct EditorView: View { } } } else if fileExtension == "ply" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = false + // Load Gaussian PLY using absolute path setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) print("✅ Quick Preview Gaussian loaded: \(fileName).\(fileExtension)") } else if fileExtension == "json" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = true + setEntityStreamScene(entityId: entityId, url: fileURL) { success in DispatchQueue.main.async { if success { @@ -809,6 +816,30 @@ public struct EditorView: View { print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") } + private func deleteExistingQuickPreviewEntities() { + let previewEntityIds = getAllGameEntities() + .filter { hasComponent(entityId: $0, componentType: QuickPreviewComponent.self) } + + guard previewEntityIds.isEmpty == false else { + return + } + + for entityId in previewEntityIds { + destroyEntity(entityId: entityId) + } + + if let selectedId = selectionManager.selectedEntity, + previewEntityIds.contains(selectedId) + { + selectionManager.selectedEntity = nil + activeEntity = .invalid + } + + editor_entities = getAllGameEntities() + selectionManager.objectWillChange.send() + sceneGraphModel.refreshHierarchy() + } + // MARK: - Quick Preview Save Validation /// Checks if the scene contains any Quick Preview entities. diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 1872b24..8a22ed9 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -364,6 +364,120 @@ struct SSAOEditorView: View { } } +private enum EditorAntiAliasingOption: String, CaseIterable, Hashable, Identifiable { + case off = "Off" + case fxaa = "FXAA" + case smaa = "SMAA" + + var id: String { + rawValue + } + + var engineMode: AntiAliasingMode { + switch self { + case .off: + return .none + case .fxaa: + return .fxaa + case .smaa: + return .smaa + } + } + + static func currentEngineMode() -> EditorAntiAliasingOption { + switch antiAliasingMode { + case .none: + return .off + case .fxaa: + return .fxaa + case .smaa: + return .smaa + } + } +} + +struct AntiAliasingEditorView: View { + @ObservedObject var fxaaSettings = FXAAParams.shared + @ObservedObject var smaaSettings = SMAAParams.shared + @State private var selectedMode = EditorAntiAliasingOption.currentEngineMode() + @State private var showAdvanced = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Picker("Mode", selection: $selectedMode) { + ForEach(EditorAntiAliasingOption.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.segmented) + .onChange(of: selectedMode) { oldValue, newValue in + antiAliasingMode = newValue.engineMode + EditorUndoManager.shared.registerValueChange( + name: "Change Anti-Aliasing", + oldValue: oldValue, + newValue: newValue, + apply: { option in + antiAliasingMode = option.engineMode + selectedMode = option + } + ) + } + + switch selectedMode { + case .off: + EmptyView() + case .fxaa: + DisclosureGroup("Advanced", isExpanded: $showAdvanced) { + VStack(alignment: .leading, spacing: 8) { + UndoableEffectSlider( + label: "Subpixel Quality", + undoName: "Change FXAA Subpixel Quality", + range: 0.0 ... 1.0, + get: { fxaaSettings.subpixelQuality }, + set: { fxaaSettings.subpixelQuality = $0 } + ) + UndoableEffectSlider( + label: "Edge Threshold", + undoName: "Change FXAA Edge Threshold", + range: 0.0312 ... 0.3333, + format: "%.4f", + get: { fxaaSettings.edgeThreshold }, + set: { fxaaSettings.edgeThreshold = $0 } + ) + UndoableEffectSlider( + label: "Minimum Edge Threshold", + undoName: "Change FXAA Minimum Edge Threshold", + range: 0.0 ... 0.125, + format: "%.4f", + get: { fxaaSettings.edgeThresholdMin }, + set: { fxaaSettings.edgeThresholdMin = $0 } + ) + } + .padding(.top, 4) + } + case .smaa: + DisclosureGroup("Advanced", isExpanded: $showAdvanced) { + VStack(alignment: .leading, spacing: 8) { + UndoableEffectSlider( + label: "Edge Threshold", + undoName: "Change SMAA Edge Threshold", + range: 0.01 ... 0.5, + format: "%.4f", + get: { smaaSettings.edgeThreshold }, + set: { smaaSettings.edgeThreshold = $0 } + ) + } + .padding(.top, 4) + } + } + } + .padding() + .onAppear { + selectedMode = EditorAntiAliasingOption.currentEngineMode() + } + } +} + struct PostProcessingEditorView: View { private enum PresetOption: String, CaseIterable, Identifiable { case neutral = "Neutral" @@ -389,6 +503,7 @@ struct PostProcessingEditorView: View { @State private var showPresets = true @State private var selectedPreset: PresetOption = .neutral + @State private var showAntiAliasing = false @State private var showToneMapping = false @State private var showWhiteBalance = false @State private var showColorGrading = false @@ -397,7 +512,6 @@ struct PostProcessingEditorView: View { @State private var showChromatic = false @State private var showDoF = false @State private var showSSAO = false - @State private var showDebugPostProccessTexture = false var body: some View { ScrollView { @@ -423,6 +537,10 @@ struct PostProcessingEditorView: View { .padding() } + DisclosureGroup("Anti-Aliasing", isExpanded: $showAntiAliasing) { + AntiAliasingEditorView() + } + DisclosureGroup("Depth of Field", isExpanded: $showDoF) { DepthOfFieldEditorView() } @@ -450,131 +568,8 @@ struct PostProcessingEditorView: View { DisclosureGroup("SSAO", isExpanded: $showSSAO) { SSAOEditorView() } - - DisclosureGroup("Debug", isExpanded: $showDebugPostProccessTexture) { - DebuggerEditorView() - } } .padding() } } } - -struct DebuggerEditorView: View { - @ObservedObject var settings = DebugSettings.shared - @State private var spatialEnabled = false - @State private var showOctreeLeafBounds = false - @State private var occupiedOnly = true - @State private var maxLeafNodeCount = 2000 - @State private var leafColorMode: SpatialDebugLeafColorMode = .plain - @State private var colorRenderablesByLOD = false - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Debugger ").font(.headline) - - Picker("Debug Texture", selection: $settings.selectedName) { - ForEach(DebugTextureRegistry.allNames(), id: \.self) { name in - Text(name) - } - } - - Divider() - - Text("Spatial Debugger") - .font(.headline) - - Toggle("Enable Spatial Debug", isOn: $spatialEnabled) - .onChange(of: spatialEnabled) { _, enabled in - if enabled, !showOctreeLeafBounds, !colorRenderablesByLOD { - showOctreeLeafBounds = true - } - applySpatialDebugSettings() - } - - VStack(alignment: .leading, spacing: 8) { - Toggle("Show Octree Leaf Bounds", isOn: $showOctreeLeafBounds) - .onChange(of: showOctreeLeafBounds) { _, _ in - applySpatialDebugSettings() - } - - Picker("Leaf Color Mode", selection: $leafColorMode) { - Text("Plain").tag(SpatialDebugLeafColorMode.plain) - Text("Residency").tag(SpatialDebugLeafColorMode.residency) - Text("Culling").tag(SpatialDebugLeafColorMode.culling) - } - .onChange(of: leafColorMode) { _, _ in - applySpatialDebugSettings() - } - - Toggle("Occupied Leaves Only", isOn: $occupiedOnly) - .onChange(of: occupiedOnly) { _, _ in - applySpatialDebugSettings() - } - - HStack(spacing: 8) { - Text("Max Leaf Nodes") - .font(.system(size: 12)) - - CommitAndDefocusIntField(value: Binding( - get: { maxLeafNodeCount }, - set: { newValue in - maxLeafNodeCount = max(0, newValue) - } - )) - .frame(width: 80) - .onChange(of: maxLeafNodeCount) { _, _ in - applySpatialDebugSettings() - } - } - } - .disabled(!spatialEnabled) - - Toggle("Color Renderables by LOD", isOn: $colorRenderablesByLOD) - .onChange(of: colorRenderablesByLOD) { _, _ in - applySpatialDebugSettings() - } - .disabled(!spatialEnabled) - - Button("Disable All Spatial Debug") { - disableSpatialDebugVisualization() - syncSpatialDebugSettingsFromEngine() - } - - Divider() - - Text("Stats overlay controls are in the top toolbar.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - .padding() - .onAppear { - syncSpatialDebugSettingsFromEngine() - } - } - - private func applySpatialDebugSettings() { - guard spatialEnabled else { - disableSpatialDebugVisualization() - return - } - - setOctreeLeafBoundsDebug( - enabled: showOctreeLeafBounds, - maxLeafNodeCount: maxLeafNodeCount, - occupiedOnly: occupiedOnly, - colorMode: leafColorMode - ) - setLODLevelDebug(enabled: colorRenderablesByLOD) - } - - private func syncSpatialDebugSettingsFromEngine() { - let spatialDebug = SpatialDebugVisualization.shared - spatialEnabled = spatialDebug.enabled - showOctreeLeafBounds = spatialDebug.showOctreeLeafBounds - occupiedOnly = spatialDebug.octreeLeafOccupiedOnly - maxLeafNodeCount = spatialDebug.maxLeafNodeCount - leafColorMode = spatialDebug.octreeLeafColorMode - colorRenderablesByLOD = spatialDebug.colorRenderablesByLOD - } -} diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index b4d04b9..1c74d7f 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -270,15 +270,12 @@ struct InspectorView: View { TextField("Set Entity Name", text: Binding( get: { getEntityName(entityId: entityId) }, set: { - if isDerivedAssetNode(entityId) == false { - setEntityName(entityId: entityId, name: $0) - } + setEntityName(entityId: entityId, name: $0) } )) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() .focused($isNameTextFieldFocused) - .disabled(isDerivedAssetNode(entityId)) .onSubmit { if let oldName = nameEditStartValue { EditorUndoManager.shared.registerNameChange( @@ -360,6 +357,31 @@ struct InspectorView: View { } Divider() } + + let addableComponents = availableComponentsWithFlags() + if addableComponents.isEmpty == false { + Menu { + ForEach(addableComponents, id: \.id) { component in + Button(component.name) { + addComponentToEntity_Editor(componentType: component.type) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + Text("Add Component") + .fontWeight(.regular) + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(6) + } + .menuStyle(.borderlessButton) + .padding(.top, 8) + } + } else { Text("No entity selected").foregroundColor(.gray) } @@ -382,7 +404,11 @@ struct InspectorView: View { let key = ObjectIdentifier(componentType) - if let component = availableComponents_Editor.first(where: { ObjectIdentifier($0.type) == key }) { + var allComponents = availableComponents_Editor + if EditorFeatureFlags.enableScriptComponent, EditorAuthoringMode.sceneCompositionOnly == false { + allComponents.append(scriptComponent_Editor) + } + if let component = allComponents.first(where: { ObjectIdentifier($0.type) == key }) { // Ensure the entity has an entry in the dictionary if editorComponentsState.components[entityId] == nil { editorComponentsState.components[entityId] = [:] @@ -899,7 +925,7 @@ struct RenderingEditorView: View { } let mesh = renderComponent.mesh[meshIndex] - let name = mesh.modelMDLMesh.name.trimmingCharacters(in: .whitespacesAndNewlines) + let name = mesh.name.trimmingCharacters(in: .whitespacesAndNewlines) if inspectionOnly, !name.isEmpty { return name } @@ -1002,10 +1028,8 @@ struct TransformationEditorView: View { var body: some View { Text("Transform Properties") - let isReadOnlyAssetNode = isDerivedAssetNode(entityId) - // Warning banner if entity is marked as static - if !isReadOnlyAssetNode, hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { + if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) @@ -1023,56 +1047,50 @@ struct TransformationEditorView: View { let orientation = simd_float3(localTransformComponent.rotationX, localTransformComponent.rotationY, localTransformComponent.rotationZ) let scale = getScale(entityId: entityId) - if isReadOnlyAssetNode { - ReadOnlyVectorView(label: "Position", value: position) - ReadOnlyVectorView(label: "Orientation", value: orientation) - ReadOnlyVectorView(label: "Scale", value: scale) - } else { - TextInputVectorView(label: "Position", value: Binding( - get: { position }, - set: { newPosition in - let before = EditorTransformSnapshot(entityId: entityId) - handleTransformChange() - translateTo(entityId: entityId, position: newPosition) - EditorUndoManager.shared.registerTransformChange( - entityId: entityId, - before: before, - after: EditorTransformSnapshot(entityId: entityId) - ) - refreshView() - } - )) + TextInputVectorView(label: "Position", value: Binding( + get: { position }, + set: { newPosition in + let before = EditorTransformSnapshot(entityId: entityId) + handleTransformChange() + translateTo(entityId: entityId, position: newPosition) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) + refreshView() + } + )) - TextInputVectorView(label: "Orientation", value: Binding( - get: { orientation }, - set: { newOrientation in - let before = EditorTransformSnapshot(entityId: entityId) - handleTransformChange() - applyAxisRotations(entityId: entityId, axis: newOrientation) - EditorUndoManager.shared.registerTransformChange( - entityId: entityId, - before: before, - after: EditorTransformSnapshot(entityId: entityId) - ) - refreshView() - } - )) + TextInputVectorView(label: "Orientation", value: Binding( + get: { orientation }, + set: { newOrientation in + let before = EditorTransformSnapshot(entityId: entityId) + handleTransformChange() + applyAxisRotations(entityId: entityId, axis: newOrientation) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) + refreshView() + } + )) - TextInputVectorView(label: "Scale", value: Binding( - get: { scale }, - set: { newScale in - let before = EditorTransformSnapshot(entityId: entityId) - handleTransformChange() - scaleTo(entityId: entityId, scale: newScale) - EditorUndoManager.shared.registerTransformChange( - entityId: entityId, - before: before, - after: EditorTransformSnapshot(entityId: entityId) - ) - refreshView() - } - )) - } + TextInputVectorView(label: "Scale", value: Binding( + get: { scale }, + set: { newScale in + let before = EditorTransformSnapshot(entityId: entityId) + handleTransformChange() + scaleTo(entityId: entityId, scale: newScale) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) + refreshView() + } + )) } else { Text("No transform data") .font(.caption) diff --git a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift index 87ab27e..851e0cd 100644 --- a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift +++ b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift @@ -8,8 +8,70 @@ // import Foundation +import UniformTypeIdentifiers import UntoldEngine +enum QuickPreviewImportMode: String, CaseIterable { + case untoldAsset + case tiledScene + case gaussian + + var menuTitle: String { + switch self { + case .untoldAsset: + return "Load Untold Asset (.untold)" + case .tiledScene: + return "Load Tiled Stream (.json)" + case .gaussian: + return "Load Gaussian (.ply)" + } + } + + var systemImageName: String { + switch self { + case .untoldAsset: + return "cube.fill" + case .tiledScene: + return "square.stack.3d.up.fill" + case .gaussian: + return "sparkles" + } + } + + var filePickerTitle: String { + switch self { + case .untoldAsset: + return "Load Preview - Select Untold Asset" + case .tiledScene: + return "Load Preview - Select Tiled Stream Manifest" + case .gaussian: + return "Load Preview - Select Gaussian" + } + } + + var filePickerMessage: String { + switch self { + case .untoldAsset: + return "Select an Untold runtime asset to preview without creating a project" + case .tiledScene: + return "Select a tiled stream manifest to preview without creating a project" + case .gaussian: + return "Select a Gaussian PLY file to preview without creating a project" + } + } + + var allowedContentTypes: [UTType] { + switch self { + case .untoldAsset: + return [UTType(filenameExtension: "untold") ?? .data] + case .tiledScene: + return [.json] + case .gaussian: + return [UTType(filenameExtension: "ply") ?? .data] + } + } +} + /// Marks an entity as being loaded via Quick Preview with an absolute path. /// These entities cannot be serialized and must be removed before saving the scene. public class QuickPreviewComponent: Component { diff --git a/Sources/UntoldEditor/Editor/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index 4e82444..194d581 100644 --- a/Sources/UntoldEditor/Editor/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/SelectionManager.swift @@ -55,9 +55,7 @@ class SceneGraphModel: ObservableObject { @Published var childrenMap: [EntityID: [EntityID]] = [:] func refreshHierarchy() { - let allEntities = getAllGameEntities().filter { entityId in - EditorAuthoringMode.sceneCompositionOnly == false || isDerivedAssetNode(entityId) == false - } + let allEntities = getAllGameEntities() childrenMap = Dictionary(grouping: allEntities) { entityId in // If there's no ScenegraphComponent (e.g., camera), treat as root @@ -81,16 +79,17 @@ class SelectionManager: ObservableObject { func selectEntity(entityId: EntityID) { inspectedMesh = nil - selectEntity(entityId: editableAssetRootEntity(for: entityId), inspectEntityId: editableAssetRootEntity(for: entityId)) + let selectedEntityId = editableAssetRootEntity(for: entityId) + selectEntity(entityId: selectedEntityId, inspectEntityId: selectedEntityId) } func inspectEntity(entityId: EntityID) { inspectedMesh = nil - selectEntity(entityId: editableAssetRootEntity(for: entityId), inspectEntityId: entityId) + selectEntity(entityId: sceneTransformEntity(for: entityId), inspectEntityId: entityId) } func inspectMesh(entityId: EntityID, meshIndex: Int) { - let transformEntityId = editableAssetRootEntity(for: entityId) + let transformEntityId = sceneTransformEntity(for: entityId) selectedEntity = entityId inspectedMesh = MeshInspectionSelection( transformEntityId: transformEntityId, @@ -295,10 +294,8 @@ class SelectionManager: ObservableObject { } func meshLocalBounds(_ mesh: Mesh) -> (min: simd_float3, max: simd_float3) { - let bounds = mesh.modelMDLMesh.boundingBox - let minBounds = simd_float3(bounds.minBounds.x, bounds.minBounds.y, bounds.minBounds.z) - let maxBounds = simd_float3(bounds.maxBounds.x, bounds.maxBounds.y, bounds.maxBounds.z) - return (min: simd_min(minBounds, maxBounds), max: simd_max(minBounds, maxBounds)) + let bounds = mesh.localBounds + return (min: simd_min(bounds.min, bounds.max), max: simd_max(bounds.min, bounds.max)) } func transformedBoundingBox( diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 4d0bc80..0225ee0 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.12.10" + private let editorVersionLabel = "v0.12.14" var onSave: () -> Void var onSaveAs: () -> Void @@ -31,7 +31,7 @@ var onCreatePlane: () -> Void var onCreateCylinder: () -> Void var onCreateCone: () -> Void - var onQuickPreview: () -> Void + var onQuickPreview: (QuickPreviewImportMode) -> Void @State private var isPlaying = false @State private var showCreateProject = false @@ -102,10 +102,20 @@ .buttonStyle(.plain) .focusable(false) - Button(action: onQuickPreview) { + 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("Quick Preview") + Text("Load Preview") + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .bold)) } .padding(.vertical, 6) .padding(.horizontal, 12) @@ -113,9 +123,9 @@ .foregroundColor(.white) .cornerRadius(6) } - .buttonStyle(.plain) + .menuStyle(.borderlessButton) .focusable(false) - .help("Preview a 3D file without creating a project") + .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 a55bb51..1be0d8e 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -90,6 +90,10 @@ extension RenderPasses { // Iterate over the entities found by the component query for entityId in entities { + if hasComponent(entityId: entityId, componentType: GizmoHitProxyComponent.self) { + continue + } + guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { handleError(.noRenderComponent, entityId) continue diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift index c8ed74a..3a55707 100644 --- a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift +++ b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift @@ -50,11 +50,14 @@ extension UntoldRenderer { handleError(.noLocalTransformComponent) return } + guard let editorController else { + return + } /// Convenience to avoid repeating the optional chaining @inline(__always) func refreshInspector() { - editorController?.refreshInspector() + editorController.refreshInspector() } /// Remove static batching when entity is transformed via gizmo @@ -68,7 +71,13 @@ extension UntoldRenderer { } } - switch (editorController!.activeMode, editorController!.activeAxis) { + if hasActiveAxisGizmoDrag() { + handleStaticBatchOnTransform(entityId: activeEntity) + refreshInspector() + return + } + + switch (editorController.activeMode, editorController.activeAxis) { // MARK: - Translate case (.translate, .x) where InputSystem.shared.mouseActive: @@ -76,7 +85,7 @@ extension UntoldRenderer { let axis = simd_float3(1, 0, 0) let amt = computeAxisTranslationGizmo( axisWorldDir: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, @@ -91,7 +100,7 @@ extension UntoldRenderer { handleStaticBatchOnTransform(entityId: activeEntity) let axis = simd_float3(0, 1, 0) let amt = computeAxisTranslationGizmo(axisWorldDir: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, @@ -105,7 +114,7 @@ extension UntoldRenderer { handleStaticBatchOnTransform(entityId: activeEntity) let axis = simd_float3(0, 0, 1) let amt = computeAxisTranslationGizmo(axisWorldDir: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, @@ -122,7 +131,7 @@ extension UntoldRenderer { let axis = simd_float3(1, 0, 0) let angle = computeRotationAngleFromGizmo( axis: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), lastMousePos: simd_float2(InputSystem.shared.lastMouseX, InputSystem.shared.lastMouseY), currentMousePos: simd_float2(InputSystem.shared.mouseX, InputSystem.shared.mouseY), viewMatrix: cameraComponent.viewSpace, @@ -130,9 +139,7 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort, sensitivity: 100.0 ) - var r = getAxisRotations(entityId: activeEntity) - r.x -= angle * 10 - applyAxisRotations(entityId: activeEntity, axis: r) + applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: -angle * 10) refreshInspector() case (.rotate, .y) where InputSystem.shared.mouseActive: @@ -140,7 +147,7 @@ extension UntoldRenderer { let axis = simd_float3(0, 1, 0) let angle = computeRotationAngleFromGizmo( axis: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), lastMousePos: simd_float2(InputSystem.shared.lastMouseX, InputSystem.shared.lastMouseY), currentMousePos: simd_float2(InputSystem.shared.mouseX, InputSystem.shared.mouseY), viewMatrix: cameraComponent.viewSpace, @@ -148,9 +155,7 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort, sensitivity: 100.0 ) - var r = getAxisRotations(entityId: activeEntity) - r.y += angle * 10 - applyAxisRotations(entityId: activeEntity, axis: r) + applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: angle * 10) refreshInspector() case (.rotate, .z) where InputSystem.shared.mouseActive: @@ -158,7 +163,7 @@ extension UntoldRenderer { let axis = simd_float3(0, 0, 1) let angle = computeRotationAngleFromGizmo( axis: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), lastMousePos: simd_float2(InputSystem.shared.lastMouseX, InputSystem.shared.lastMouseY), currentMousePos: simd_float2(InputSystem.shared.mouseX, InputSystem.shared.mouseY), viewMatrix: cameraComponent.viewSpace, @@ -166,9 +171,7 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort, sensitivity: 100.0 ) - var r = getAxisRotations(entityId: activeEntity) - r.z += angle * 10 - applyAxisRotations(entityId: activeEntity, axis: r) + applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: angle * 10) refreshInspector() // MARK: - Scale @@ -177,7 +180,7 @@ extension UntoldRenderer { handleStaticBatchOnTransform(entityId: activeEntity) let axis = simd_float3(1, 0, 0) let amt = computeAxisTranslationGizmo(axisWorldDir: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, @@ -193,7 +196,7 @@ extension UntoldRenderer { handleStaticBatchOnTransform(entityId: activeEntity) let axis = simd_float3(0, 1, 0) let amt = computeAxisTranslationGizmo(axisWorldDir: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, @@ -209,7 +212,7 @@ extension UntoldRenderer { handleStaticBatchOnTransform(entityId: activeEntity) let axis = simd_float3(0, 0, 1) let amt = computeAxisTranslationGizmo(axisWorldDir: axis, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, @@ -244,14 +247,14 @@ extension UntoldRenderer { }() let p1 = computeAxisTranslationGizmo(axisWorldDir: axis1, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, viewportSize: renderInfo.viewPort) let p2 = computeAxisTranslationGizmo(axisWorldDir: axis2, - gizmoWorldPosition: getLocalPosition(entityId: activeEntity), + gizmoWorldPosition: gizmoRootWorldPosition(), mouseDelta: simd_float2(InputSystem.shared.mouseDeltaX, InputSystem.shared.mouseDeltaY), viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index e9ec122..2ec6309 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -188,13 +188,8 @@ activeHitGizmoEntity = .invalid if hit { - let transformEntityId = hasComponent(entityId: entityId, componentType: GizmoComponent.self) - ? entityId - : editableAssetRootEntity(for: entityId) - - activeEntity = selectableTransformEntity(for: transformEntityId) - if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { + activeEntity = selectableTransformEntity(for: entityId) selectionDelegate?.didSelectEntity(entityId) } else if keyState.shiftPressed, let rayContext, @@ -204,9 +199,12 @@ rayDirection: rayContext.rayDirection ) { + activeEntity = selectableTransformEntity(for: entityId) selectionDelegate?.didInspectMesh(entityId, meshIndex: meshIndex) } else { - selectionDelegate?.didInspectEntity(entityId) + let transformEntityId = editableAssetRootEntity(for: entityId) + activeEntity = selectableTransformEntity(for: transformEntityId) + selectionDelegate?.didSelectEntity(entityId) } selectionDelegate?.resetActiveAxis() @@ -251,6 +249,15 @@ let (hitEntityId, hit) = getRaycastedEntity(currentLocation: currentLocation, view: view) if hit { activeHitGizmoEntity = hitEntityId + processGizmoAction(entityId: activeHitGizmoEntity) + if let rayContext = raycastContext(currentLocation: currentLocation, view: view) { + beginGizmoDrag( + ray: GizmoDragRay( + origin: rayContext.rayOrigin, + direction: rayContext.rayDirection + ) + ) + } if activeEntity != .invalid { EditorUndoManager.shared.beginTransformEdit(entityId: activeEntity) } @@ -264,6 +271,16 @@ case .changed: // Editor-only: process gizmo if we hit one if isEditorEnabled { + if activeHitGizmoEntity != .invalid, + let rayContext = raycastContext(currentLocation: currentLocation, view: view) + { + updateGizmoDrag( + ray: GizmoDragRay( + origin: rayContext.rayOrigin, + direction: rayContext.rayDirection + ) + ) + } processGizmoAction(entityId: activeHitGizmoEntity) if activeHitGizmoEntity != .invalid { // While dragging a gizmo, skip camera orbit updates @@ -306,6 +323,7 @@ initialPanLocation = nil currentPanGestureState = .ended cameraControlMode = .idle + endGizmoDrag() default: break diff --git a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift index f523d4c..84cc2a3 100644 --- a/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift +++ b/Sources/UntoldEditor/Systems/EditorRenderingSystem.swift @@ -46,7 +46,7 @@ func EditorUpdateRenderingSystem(in view: MTKView) { commandBuffer.label = "Rendering Command Buffer" // build a render graph - var (graph, _) = gameMode ? buildGameModeGraph() : buildEditModeGraph() + let (graph, _) = gameMode ? buildGameModeGraph() : buildEditModeGraph() // if visualDebug == false { // let compositePass = RenderPass( @@ -202,16 +202,81 @@ func buildEditModeGraph() -> RenderGraphResult { graph[lookPass.id] = lookPass let outputDependency: String - if FXAAParams.shared.enabled { - let fxaaPass = RenderPass( - id: "fxaa", - dependencies: [lookPass.id], - execute: fxaaRenderPass + 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[fxaaPass.id] = fxaaPass - outputDependency = fxaaPass.id + 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 { - outputDependency = lookPass.id + 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( diff --git a/Sources/UntoldEditor/Systems/GizmoSystem.swift b/Sources/UntoldEditor/Systems/GizmoSystem.swift index 5521a7f..cf034c1 100644 --- a/Sources/UntoldEditor/Systems/GizmoSystem.swift +++ b/Sources/UntoldEditor/Systems/GizmoSystem.swift @@ -19,11 +19,253 @@ private enum GizmoDimensions { static let scaleCubeExtent: Float = 0.16 static let rotateRingRadius: Float = 0.9 static let rotateRingThickness: Float = 0.01 + static let rotateHitRingThickness: Float = 0.08 static let rotateRingSegments: Int = 48 + static let rotateHitRingSegments: Int = 36 static let directionHandleRadius: Float = 0.08 + static let directionHandleHitRadius: Float = 0.2 static let directionHandleOffsetY: Float = -1.0 } +enum GizmoMode: String { + case translate + case rotate + case scale + + init(name: String) { + switch name { + case "rotateGizmo": + self = .rotate + case "scaleGizmo": + self = .scale + default: + self = .translate + } + } +} + +final class GizmoHandleComponent: Component { + var mode: TransformManipulationMode = .none + var axis: TransformAxis = .none + + required init() {} +} + +final class GizmoHitProxyComponent: Component { + required init() {} +} + +private struct GizmoHandleDescriptor { + let mode: TransformManipulationMode + let axis: TransformAxis +} + +struct GizmoDragRay { + let origin: simd_float3 + let direction: simd_float3 +} + +private struct GizmoDragState { + let mode: TransformManipulationMode + let axis: TransformAxis + let axisWorldDirection: simd_float3 + let startAxisParameter: Float + let startActiveLocalPosition: simd_float3 + let startGizmoWorldPosition: simd_float3 + var appliedAxisAmount: Float = 0.0 + + var isAxisDriven: Bool { + mode == .translate || mode == .scale + } +} + +private var gizmoDragState: GizmoDragState? + +func gizmoRootWorldPosition() -> simd_float3 { + guard parentEntityIdGizmo != .invalid else { + return activeEntity == .invalid ? .zero : getPosition(entityId: activeEntity) + } + + return getPosition(entityId: parentEntityIdGizmo) +} + +func beginGizmoDrag(ray: GizmoDragRay) { + guard activeEntity != .invalid, + parentEntityIdGizmo != .invalid, + let handleComponent = scene.get(component: GizmoHandleComponent.self, for: activeHitGizmoEntity) + else { + gizmoDragState = nil + return + } + + let axisDirection = worldDirection(for: handleComponent.axis) + guard handleComponent.mode == .translate || handleComponent.mode == .scale, + simd_length_squared(axisDirection) > 0.0001 + else { + gizmoDragState = nil + return + } + + let normalizedRayDirection = normalizeOrNil(ray.direction) ?? ray.direction + guard let startParameter = closestParameterOnAxis( + axisPoint: gizmoRootWorldPosition(), + axisDirection: axisDirection, + rayOrigin: ray.origin, + rayDirection: normalizedRayDirection + ) else { + gizmoDragState = nil + return + } + + gizmoDragState = GizmoDragState( + mode: handleComponent.mode, + axis: handleComponent.axis, + axisWorldDirection: axisDirection, + startAxisParameter: startParameter, + startActiveLocalPosition: getLocalPosition(entityId: activeEntity), + startGizmoWorldPosition: gizmoRootWorldPosition() + ) +} + +func updateGizmoDrag(ray: GizmoDragRay) { + guard var state = gizmoDragState, + state.isAxisDriven + else { + return + } + + let normalizedRayDirection = normalizeOrNil(ray.direction) ?? ray.direction + guard let currentParameter = closestParameterOnAxis( + axisPoint: state.startGizmoWorldPosition, + axisDirection: state.axisWorldDirection, + rayOrigin: ray.origin, + rayDirection: normalizedRayDirection + ) else { + return + } + + let axisAmount = currentParameter - state.startAxisParameter + let incrementalAmount = axisAmount - state.appliedAxisAmount + guard incrementalAmount.isFinite else { + return + } + + switch state.mode { + case .translate: + let translation = state.axisWorldDirection * axisAmount + translateTo(entityId: activeEntity, position: state.startActiveLocalPosition + translation) + translateTo(entityId: parentEntityIdGizmo, position: state.startGizmoWorldPosition + translation) + + case .scale: + if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { + handleLightScaleInput(projectedAmount: incrementalAmount, axis: state.axisWorldDirection) + } else { + applyWorldSpaceScaleDelta( + entityId: activeEntity, + worldAxis: state.axisWorldDirection, + projectedAmount: incrementalAmount + ) + } + + default: + break + } + + state.appliedAxisAmount = axisAmount + gizmoDragState = state +} + +func endGizmoDrag() { + gizmoDragState = nil +} + +func hasActiveAxisGizmoDrag() -> Bool { + gizmoDragState?.isAxisDriven == true +} + +func applyGizmoRotationDelta(entityId: EntityID, axis: simd_float3, degrees: Float) { + guard entityId != .invalid, + degrees.isFinite, + simd_length_squared(axis) > 0.0001, + let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) + else { + return + } + + let delta = simd_quatf(angle: degreesToRadians(degrees: degrees), axis: simd_normalize(axis)) + let currentRotation = normalizedRotationOrIdentity(localTransform.rotation) + localTransform.rotation = simd_normalize(simd_mul(delta, currentRotation)) + translateTo(entityId: entityId, position: localTransform.position) + syncStoredAxisRotationsFromQuaternion(entityId: entityId) +} + +private func normalizedRotationOrIdentity(_ rotation: simd_quatf) -> simd_quatf { + let lengthSquared = rotation.real * rotation.real + simd_length_squared(rotation.vector) + guard lengthSquared.isFinite, lengthSquared > 0.0001 else { + return simd_quatf(real: 1.0, imag: .zero) + } + + return simd_normalize(rotation) +} + +private func syncStoredAxisRotationsFromQuaternion(entityId: EntityID) { + guard let localTransform = scene.get(component: LocalTransformComponent.self, for: entityId) else { + return + } + + let euler = transformQuaternionToEulerAngles(q: localTransform.rotation) + localTransform.rotationX = euler.pitch + localTransform.rotationY = euler.yaw + localTransform.rotationZ = euler.roll +} + +private func worldDirection(for axis: TransformAxis) -> simd_float3 { + switch axis { + case .x: + return simd_float3(1.0, 0.0, 0.0) + case .y: + return simd_float3(0.0, 1.0, 0.0) + case .z: + return simd_float3(0.0, 0.0, 1.0) + case .none: + return .zero + } +} + +private func normalizeOrNil(_ value: simd_float3) -> simd_float3? { + let lengthSquared = simd_length_squared(value) + guard lengthSquared.isFinite, lengthSquared > 0.0001 else { + return nil + } + return value / sqrt(lengthSquared) +} + +private func closestParameterOnAxis( + axisPoint: simd_float3, + axisDirection: simd_float3, + rayOrigin: simd_float3, + rayDirection: simd_float3 +) -> Float? { + guard let axis = normalizeOrNil(axisDirection), + let ray = normalizeOrNil(rayDirection) + else { + return nil + } + + let w = axisPoint - rayOrigin + let axisRayDot = simd_dot(axis, ray) + let axisPointDot = simd_dot(axis, w) + let rayPointDot = simd_dot(ray, w) + let denominator = 1.0 - axisRayDot * axisRayDot + + guard abs(denominator) > 0.0001 else { + return nil + } + + let parameter = (axisRayDot * rayPointDot - axisPointDot) / denominator + return parameter.isFinite ? parameter : nil +} + private func applyGizmoHandleColor(entityId: EntityID, color: simd_float4) { guard let renderComponent = scene.get(component: RenderComponent.self, for: entityId) else { return @@ -102,7 +344,9 @@ private func createGizmoHandle( meshes: [Mesh], localPosition: simd_float3, color: simd_float4, - rotation: (angle: Float, axis: simd_float3)? = nil + descriptor: GizmoHandleDescriptor, + rotation: (angle: Float, axis: simd_float3)? = nil, + isHitProxy: Bool = false ) -> EntityID { let handle = createEntity() setEntityName(entityId: handle, name: name) @@ -113,6 +357,13 @@ private func createGizmoHandle( rotateTo(entityId: handle, angle: rotation.angle, axis: rotation.axis) } registerComponent(entityId: handle, componentType: GizmoComponent.self) + if let handleComponent = scene.assign(to: handle, component: GizmoHandleComponent.self) { + handleComponent.mode = descriptor.mode + handleComponent.axis = descriptor.axis + } + if isHitProxy { + registerComponent(entityId: handle, componentType: GizmoHitProxyComponent.self) + } applyGizmoHandleColor(entityId: handle, color: color) return handle } @@ -120,13 +371,37 @@ private func createGizmoHandle( @discardableResult private func makeDirectionHandle() -> EntityID { let handleColor = simd_float4(1.0, 1.0, 0.0, 1.0) - return createGizmoHandle( + let localPosition = initialLightDirectionHandleOffset() + let visibleHandle = createGizmoHandle( parentId: parentEntityIdGizmo, name: "directionHandle", meshes: BasicPrimitives.createSphere(extent: GizmoDimensions.directionHandleRadius, segments: [24, 12]), - localPosition: simd_float3(0.0, GizmoDimensions.directionHandleOffsetY, 0.0), - color: handleColor + localPosition: localPosition, + color: handleColor, + descriptor: GizmoHandleDescriptor(mode: .lightRotate, axis: .none) ) + + createGizmoHandle( + parentId: parentEntityIdGizmo, + name: "directionHandleHitProxy", + meshes: BasicPrimitives.createSphere(extent: GizmoDimensions.directionHandleHitRadius, segments: [16, 8]), + localPosition: localPosition, + color: simd_float4(0.0, 0.0, 0.0, 0.0), + descriptor: GizmoHandleDescriptor(mode: .lightRotate, axis: .none), + isHitProxy: true + ) + + return visibleHandle +} + +private func initialLightDirectionHandleOffset() -> simd_float3 { + guard activeEntity != .invalid else { + 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) + return handleDirection * abs(GizmoDimensions.directionHandleOffsetY) } private func rotationFromYAxis(to direction: simd_float3) -> (angle: Float, axis: simd_float3)? { @@ -195,11 +470,71 @@ private func makeRotationRing( meshes: makeRingSegmentMesh(), localPosition: localPos, color: color, + descriptor: GizmoHandleDescriptor(mode: .rotate, axis: axisForRotationHandleName(handleName)), rotation: rotation ) } } +private func makeRotationHitRing( + handleName: String, + axisA: simd_float3, + axisB: simd_float3, + startAngle: Float = 0.0, + sweepAngle: Float = 2.0 * Float.pi +) { + let fullTurn = 2.0 * Float.pi + let normalizedSweep = max(0.0001, abs(sweepAngle)) + let segmentCount = max(1, Int(round(Float(GizmoDimensions.rotateHitRingSegments) * (normalizedSweep / fullTurn)))) + let radius = GizmoDimensions.rotateRingRadius + let delta = sweepAngle / Float(segmentCount) + let segmentLength = 2.0 * radius * sin(abs(delta) * 0.5) + let descriptor = GizmoHandleDescriptor(mode: .rotate, axis: axisForRotationHandleName(handleName)) + + @inline(__always) + func makeHitSegmentMesh() -> [Mesh] { + BasicPrimitives.createCylinder( + height: segmentLength, + radius: GizmoDimensions.rotateHitRingThickness, + segments: [16, 1] + ) + } + + for i in 0 ..< segmentCount { + let theta = startAngle + (Float(i) + 0.5) * delta + let c = cos(theta) + let s = sin(theta) + + let localPos = axisA * (radius * c) + axisB * (radius * s) + let tangent = axisA * -s + axisB * c + let rotation = rotationFromYAxis(to: tangent) + + createGizmoHandle( + parentId: parentEntityIdGizmo, + name: "\(handleName)HitProxy", + meshes: makeHitSegmentMesh(), + localPosition: localPos, + color: simd_float4(0.0, 0.0, 0.0, 0.0), + descriptor: descriptor, + rotation: rotation, + isHitProxy: true + ) + } +} + +private func axisForRotationHandleName(_ handleName: String) -> TransformAxis { + switch handleName { + case "xAxisRotate": + return .x + case "yAxisRotate": + return .y + case "zAxisRotate": + return .z + default: + return .none + } +} + func makeTranslateGizmo() { @inline(__always) func makeShaftMeshes() -> [Mesh] { @@ -233,6 +568,7 @@ func makeTranslateGizmo() { meshes: makeShaftMeshes(), localPosition: simd_float3(halfShaft, 0.0, 0.0), color: xColor, + descriptor: GizmoHandleDescriptor(mode: .translate, axis: .x), rotation: (90.0, simd_float3(0.0, 0.0, 1.0)) ) createGizmoHandle( @@ -241,6 +577,7 @@ func makeTranslateGizmo() { meshes: makeArrowMeshes(), localPosition: simd_float3(tipOffset, 0.0, 0.0), color: xColor, + descriptor: GizmoHandleDescriptor(mode: .translate, axis: .x), rotation: (-90.0, simd_float3(0.0, 0.0, 1.0)) ) @@ -250,14 +587,16 @@ func makeTranslateGizmo() { name: "yAxisTranslate", meshes: makeShaftMeshes(), localPosition: simd_float3(0.0, halfShaft, 0.0), - color: yColor + color: yColor, + descriptor: GizmoHandleDescriptor(mode: .translate, axis: .y) ) createGizmoHandle( parentId: parentEntityIdGizmo, name: "yAxisTranslate", meshes: makeArrowMeshes(), localPosition: simd_float3(0.0, tipOffset, 0.0), - color: yColor + color: yColor, + descriptor: GizmoHandleDescriptor(mode: .translate, axis: .y) ) // Z axis @@ -267,6 +606,7 @@ func makeTranslateGizmo() { meshes: makeShaftMeshes(), localPosition: simd_float3(0.0, 0.0, halfShaft), color: zColor, + descriptor: GizmoHandleDescriptor(mode: .translate, axis: .z), rotation: (90.0, simd_float3(1.0, 0.0, 0.0)) ) createGizmoHandle( @@ -275,6 +615,7 @@ func makeTranslateGizmo() { meshes: makeArrowMeshes(), localPosition: simd_float3(0.0, 0.0, tipOffset), color: zColor, + descriptor: GizmoHandleDescriptor(mode: .translate, axis: .z), rotation: (90.0, simd_float3(1.0, 0.0, 0.0)) ) } @@ -308,6 +649,7 @@ func makeScaleGizmo() { meshes: makeShaftMeshes(), localPosition: simd_float3(halfShaft, 0.0, 0.0), color: xColor, + descriptor: GizmoHandleDescriptor(mode: .scale, axis: .x), rotation: (90.0, simd_float3(0.0, 0.0, 1.0)) ) createGizmoHandle( @@ -315,7 +657,8 @@ func makeScaleGizmo() { name: "xAxisScale", meshes: makeTipCubeMeshes(), localPosition: simd_float3(cubeCenterOffset, 0.0, 0.0), - color: xColor + color: xColor, + descriptor: GizmoHandleDescriptor(mode: .scale, axis: .x) ) // Y axis @@ -324,14 +667,16 @@ func makeScaleGizmo() { name: "yAxisScale", meshes: makeShaftMeshes(), localPosition: simd_float3(0.0, halfShaft, 0.0), - color: yColor + color: yColor, + descriptor: GizmoHandleDescriptor(mode: .scale, axis: .y) ) createGizmoHandle( parentId: parentEntityIdGizmo, name: "yAxisScale", meshes: makeTipCubeMeshes(), localPosition: simd_float3(0.0, cubeCenterOffset, 0.0), - color: yColor + color: yColor, + descriptor: GizmoHandleDescriptor(mode: .scale, axis: .y) ) // Z axis @@ -341,6 +686,7 @@ func makeScaleGizmo() { meshes: makeShaftMeshes(), localPosition: simd_float3(0.0, 0.0, halfShaft), color: zColor, + descriptor: GizmoHandleDescriptor(mode: .scale, axis: .z), rotation: (90.0, simd_float3(1.0, 0.0, 0.0)) ) createGizmoHandle( @@ -348,7 +694,8 @@ func makeScaleGizmo() { name: "zAxisScale", meshes: makeTipCubeMeshes(), localPosition: simd_float3(0.0, 0.0, cubeCenterOffset), - color: zColor + color: zColor, + descriptor: GizmoHandleDescriptor(mode: .scale, axis: .z) ) } @@ -368,6 +715,13 @@ func makeRotationGizmo() { startAngle: positiveArcStart, sweepAngle: positiveArcSweep ) + makeRotationHitRing( + handleName: "xAxisRotate", + axisA: simd_float3(0.0, 1.0, 0.0), + axisB: simd_float3(0.0, 0.0, 1.0), + startAngle: positiveArcStart, + sweepAngle: positiveArcSweep + ) // Y-axis rotation ring (XZ plane) makeRotationRing( @@ -378,6 +732,13 @@ func makeRotationGizmo() { startAngle: positiveArcStart, sweepAngle: positiveArcSweep ) + makeRotationHitRing( + handleName: "yAxisRotate", + axisA: simd_float3(1.0, 0.0, 0.0), + axisB: simd_float3(0.0, 0.0, 1.0), + startAngle: positiveArcStart, + sweepAngle: positiveArcSweep + ) // Z-axis rotation ring (XY plane) makeRotationRing( @@ -388,9 +749,20 @@ func makeRotationGizmo() { startAngle: positiveArcStart, sweepAngle: positiveArcSweep ) + makeRotationHitRing( + handleName: "zAxisRotate", + axisA: simd_float3(1.0, 0.0, 0.0), + axisB: simd_float3(0.0, 1.0, 0.0), + startAngle: positiveArcStart, + sweepAngle: positiveArcSweep + ) } func createGizmo(name: String) { + createGizmo(mode: GizmoMode(name: name)) +} + +func createGizmo(mode: GizmoMode) { removeGizmo() directionHandleEntityId = .invalid @@ -407,14 +779,13 @@ func createGizmo(name: String) { translateTo(entityId: parentEntityIdGizmo, position: gizmoAnchorWorldPosition(entityId: activeEntity)) - if name == "translateGizmo" { + switch mode { + case .translate: makeTranslateGizmo() - } else if name == "rotateGizmo" { + case .rotate: makeRotationGizmo() - } else if name == "scaleGizmo" { + case .scale: makeScaleGizmo() - } else { - makeTranslateGizmo() } if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { @@ -430,41 +801,19 @@ func processGizmoAction(entityId: EntityID) { return } - if getEntityName(entityId: entityId) == "xAxisTranslate" { - editorController!.activeAxis = .x - editorController!.activeMode = .translate - } else if getEntityName(entityId: entityId) == "yAxisTranslate" { - editorController!.activeAxis = .y - editorController!.activeMode = .translate - } else if getEntityName(entityId: entityId) == "zAxisTranslate" { - editorController!.activeAxis = .z - editorController!.activeMode = .translate - } else if getEntityName(entityId: entityId) == "yAxisRotate" { - editorController!.activeAxis = .y - editorController!.activeMode = .rotate - } else if getEntityName(entityId: entityId) == "xAxisRotate" { - editorController!.activeAxis = .x - editorController!.activeMode = .rotate - } else if getEntityName(entityId: entityId) == "zAxisRotate" { - editorController!.activeAxis = .z - editorController!.activeMode = .rotate - } else if getEntityName(entityId: entityId) == "xAxisScale" { - editorController!.activeAxis = .x - editorController!.activeMode = .scale - } else if getEntityName(entityId: entityId) == "yAxisScale" { - editorController!.activeAxis = .y - editorController!.activeMode = .scale - } else if getEntityName(entityId: entityId) == "zAxisScale" { - editorController!.activeAxis = .z - editorController!.activeMode = .scale - } else if getEntityName(entityId: entityId) == "directionHandle" { - editorController!.activeMode = .lightRotate - editorController!.activeAxis = .none - } else { + guard let editorController else { + return + } + + guard let handleComponent = scene.get(component: GizmoHandleComponent.self, for: entityId) else { activeHitGizmoEntity = .invalid - editorController?.activeMode = .none - editorController?.activeAxis = .none + editorController.activeMode = .none + editorController.activeAxis = .none + return } + + editorController.activeAxis = handleComponent.axis + editorController.activeMode = handleComponent.mode #endif } @@ -473,20 +822,7 @@ func hitGizmoToolAxis(entityId: EntityID) -> Bool { return false } - let name = getEntityName(entityId: entityId) - - let validNames: Set = [ - "xAxisTranslate", "yAxisTranslate", "zAxisTranslate", - "xAxisRotate", "yAxisRotate", "zAxisRotate", - "xAxisScale", "yAxisScale", "zAxisScale", - "directionHandle", - ] - - if validNames.contains(name) { - return true - } else { - return false - } + return scene.get(component: GizmoHandleComponent.self, for: entityId) != nil } func removeGizmo() { diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 1c2e4ee..851a736 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.12.10") + Logger.log(message: "Launching Untold Engine Editor v0.12.14") // 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.12.10" + window.title = "Untold Engine Editor v0.12.14" window.center() let hostingView = NSHostingView(rootView: EditorView()) diff --git a/Tests/UntoldEditorTests/AssetNodeEditingPolicyTests.swift b/Tests/UntoldEditorTests/AssetNodeEditingPolicyTests.swift new file mode 100644 index 0000000..5e52268 --- /dev/null +++ b/Tests/UntoldEditorTests/AssetNodeEditingPolicyTests.swift @@ -0,0 +1,144 @@ +// +// AssetNodeEditingPolicyTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. +// + +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class AssetNodeEditingPolicyTests: XCTestCase { + private var selectionManager: SelectionManager! + private var sceneGraphModel: SceneGraphModel! + + override func setUp() { + super.setUp() + scene = Scene() + selectionManager = SelectionManager() + sceneGraphModel = SceneGraphModel() + } + + override func tearDown() { + sceneGraphModel = nil + selectionManager = nil + super.tearDown() + } + + private func createTransformEntity(name: String) -> EntityID { + let entityId = createEntity() + setEntityName(entityId: entityId, name: name) + registerTransformComponent(entityId: entityId) + registerSceneGraphComponent(entityId: entityId) + return entityId + } + + private func markAssetRoot(_ entityId: EntityID) { + registerComponent(entityId: entityId, componentType: AssetInstanceComponent.self) + let assetInstance = scene.get(component: AssetInstanceComponent.self, for: entityId) + assetInstance?.assetURL = URL(fileURLWithPath: "/tmp/room.untold") + assetInstance?.assetName = "room" + assetInstance?.importMode = "preserveHierarchy" + } + + private func markDerived(_ entityId: EntityID, rootId: EntityID, nodePath: String) { + registerComponent(entityId: entityId, componentType: DerivedAssetNodeComponent.self) + let derived = scene.get(component: DerivedAssetNodeComponent.self, for: entityId) + derived?.assetRootEntityId = rootId + derived?.nodePath = nodePath + } + + func testDerivedAssetNodeCanBeSelectedAsTransformTarget() { + let rootId = createTransformEntity(name: "Room") + markAssetRoot(rootId) + + let chairId = createTransformEntity(name: "Chair") + setParent(childId: chairId, parentId: rootId) + markDerived(chairId, rootId: rootId, nodePath: "Room/Chair") + + XCTAssertEqual(assetRootEntityId(for: chairId), rootId) + XCTAssertEqual(sceneTransformEntity(for: chairId), chairId) + XCTAssertTrue(canEditSceneTransform(entityId: chairId)) + XCTAssertEqual(selectableTransformEntity(for: chairId), chairId) + } + + func testNormalSelectionTargetsAssetRootForDerivedNode() { + let rootId = createTransformEntity(name: "Room") + markAssetRoot(rootId) + + let chairId = createTransformEntity(name: "Chair") + setParent(childId: chairId, parentId: rootId) + markDerived(chairId, rootId: rootId, nodePath: "Room/Chair") + + selectionManager.selectEntity(entityId: chairId) + + XCTAssertEqual(selectionManager.selectedEntity, rootId) + } + + func testHierarchyInspectionTargetsExactDerivedNode() { + let rootId = createTransformEntity(name: "Room") + markAssetRoot(rootId) + + let chairId = createTransformEntity(name: "Chair") + setParent(childId: chairId, parentId: rootId) + markDerived(chairId, rootId: rootId, nodePath: "Room/Chair") + + selectionManager.inspectEntity(entityId: chairId) + + XCTAssertEqual(selectionManager.selectedEntity, chairId) + } + + func testSceneHierarchyIncludesDerivedAssetNodes() { + let rootId = createTransformEntity(name: "Room") + markAssetRoot(rootId) + + let chairId = createTransformEntity(name: "Chair") + setParent(childId: chairId, parentId: rootId) + markDerived(chairId, rootId: rootId, nodePath: "Room/Chair") + + sceneGraphModel.refreshHierarchy() + + XCTAssertTrue(sceneGraphModel.getChildren(entityId: nil).contains(rootId)) + XCTAssertTrue(sceneGraphModel.getChildren(entityId: rootId).contains(chairId)) + } + + func testSceneCompositionInspectorShowsDerivedTransformComponent() { + let rootId = createTransformEntity(name: "Room") + markAssetRoot(rootId) + + let chairId = createTransformEntity(name: "Chair") + setParent(childId: chairId, parentId: rootId) + markDerived(chairId, rootId: rootId, nodePath: "Room/Chair") + + XCTAssertTrue(canShowComponentInInspector(componentType: LocalTransformComponent.self, for: chairId)) + XCTAssertTrue(canShowComponentInInspector(componentType: RenderComponent.self, for: chairId)) + } + + func testSerializeSceneStoresNestedDerivedTransformOverride() { + let rootId = createTransformEntity(name: "Room") + markAssetRoot(rootId) + + let groupId = createTransformEntity(name: "Furniture") + setParent(childId: groupId, parentId: rootId) + markDerived(groupId, rootId: rootId, nodePath: "Room/Furniture") + + let chairId = createTransformEntity(name: "Chair") + setParent(childId: chairId, parentId: groupId) + markDerived(chairId, rootId: rootId, nodePath: "Room/Furniture/Chair") + translateTo(entityId: chairId, position: simd_float3(1.0, 2.0, 3.0)) + scaleTo(entityId: chairId, scale: simd_float3(1.5, 1.5, 1.5)) + + let sceneData = serializeScene() + let overrides = sceneData.entities.first?.assetInstance?.overrides ?? [] + let chairOverride = overrides.first { $0.nodePath == "Room/Furniture/Chair" } + + XCTAssertEqual(sceneData.entities.count, 1) + XCTAssertNotNil(chairOverride) + XCTAssertEqual(chairOverride?.transform?.position, simd_float3(1.0, 2.0, 3.0)) + XCTAssertEqual(chairOverride?.transform?.scale, simd_float3(1.5, 1.5, 1.5)) + } +} diff --git a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift index 521ef75..7a9c11b 100644 --- a/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift +++ b/Tests/UntoldEditorTests/BuildEditModeGraphTests.swift @@ -55,9 +55,9 @@ final class BuildEditModeGraphTests: XCTestCase { renderEnvironment = true defer { renderEnvironment = originalEnv } - let originalFXAA = FXAAParams.shared.enabled - FXAAParams.shared.enabled = false - defer { FXAAParams.shared.enabled = originalFXAA } + let originalAntiAliasingMode = antiAliasingMode + antiAliasingMode = .none + defer { antiAliasingMode = originalAntiAliasingMode } let (graph, finalID) = buildEditModeGraph() @@ -93,9 +93,9 @@ final class BuildEditModeGraphTests: XCTestCase { renderEnvironment = true defer { renderEnvironment = originalEnv } - let originalFXAA = FXAAParams.shared.enabled - FXAAParams.shared.enabled = true - defer { FXAAParams.shared.enabled = originalFXAA } + let originalAntiAliasingMode = antiAliasingMode + antiAliasingMode = .fxaa + defer { antiAliasingMode = originalAntiAliasingMode } let (graph, finalID) = buildEditModeGraph() @@ -132,9 +132,9 @@ final class BuildEditModeGraphTests: XCTestCase { renderEnvironment = false defer { renderEnvironment = originalEnv } - let originalFXAA = FXAAParams.shared.enabled - FXAAParams.shared.enabled = false - defer { FXAAParams.shared.enabled = originalFXAA } + let originalAntiAliasingMode = antiAliasingMode + antiAliasingMode = .none + defer { antiAliasingMode = originalAntiAliasingMode } let (graph, finalID) = buildEditModeGraph() @@ -170,9 +170,9 @@ final class BuildEditModeGraphTests: XCTestCase { renderEnvironment = false defer { renderEnvironment = originalEnv } - let originalFXAA = FXAAParams.shared.enabled - FXAAParams.shared.enabled = true - defer { FXAAParams.shared.enabled = originalFXAA } + let originalAntiAliasingMode = antiAliasingMode + antiAliasingMode = .fxaa + defer { antiAliasingMode = originalAntiAliasingMode } let (graph, finalID) = buildEditModeGraph() diff --git a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift index 6a19095..87f3c5e 100644 --- a/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift +++ b/Tests/UntoldEditorTests/EditorRenderingSystemTests.swift @@ -18,7 +18,7 @@ final class EditorRenderingSystemTests: XCTestCase { private var originalRenderEnvironment: Bool! private var originalVisualDebug: Bool! private var originalGameMode: Bool! - private var originalFXAAEnabled: Bool! + private var originalAntiAliasingMode: AntiAliasingMode! override func setUp() { super.setUp() @@ -27,7 +27,7 @@ final class EditorRenderingSystemTests: XCTestCase { originalRenderEnvironment = renderEnvironment originalVisualDebug = visualDebug originalGameMode = gameMode - originalFXAAEnabled = FXAAParams.shared.enabled + originalAntiAliasingMode = antiAliasingMode // Set up Metal device guard let device = MTLCreateSystemDefaultDevice() else { @@ -43,7 +43,7 @@ final class EditorRenderingSystemTests: XCTestCase { renderEnvironment = false visualDebug = false gameMode = false - FXAAParams.shared.enabled = false + antiAliasingMode = .none } override func tearDown() { @@ -51,7 +51,7 @@ final class EditorRenderingSystemTests: XCTestCase { renderEnvironment = originalRenderEnvironment visualDebug = originalVisualDebug gameMode = originalGameMode - FXAAParams.shared.enabled = originalFXAAEnabled + antiAliasingMode = originalAntiAliasingMode super.tearDown() } diff --git a/Tests/UntoldEditorTests/GizmoSystemTest.swift b/Tests/UntoldEditorTests/GizmoSystemTest.swift index 2e0c89c..c202a5e 100644 --- a/Tests/UntoldEditorTests/GizmoSystemTest.swift +++ b/Tests/UntoldEditorTests/GizmoSystemTest.swift @@ -18,6 +18,7 @@ final class GizmoSystemTests: XCTestCase { private var originalParentGizmo: EntityID! private var originalGizmoActive: Bool! private var originalActiveHitGizmoEntity: EntityID! + private var originalDirectionHandleEntityId: EntityID! #if canImport(AppKit) /// Editor controller mock up @@ -35,12 +36,14 @@ final class GizmoSystemTests: XCTestCase { originalParentGizmo = parentEntityIdGizmo originalGizmoActive = gizmoActive originalActiveHitGizmoEntity = activeHitGizmoEntity + originalDirectionHandleEntityId = directionHandleEntityId // Put engine in a clean state activeEntity = .invalid parentEntityIdGizmo = .invalid gizmoActive = false activeHitGizmoEntity = .invalid + directionHandleEntityId = .invalid guard let device = MTLCreateSystemDefaultDevice() else { assertionFailure("Metal device is not available.") @@ -58,10 +61,12 @@ final class GizmoSystemTests: XCTestCase { if parentEntityIdGizmo != .invalid { removeGizmo() } + endGizmoDrag() activeEntity = originalActiveEntity parentEntityIdGizmo = originalParentGizmo gizmoActive = originalGizmoActive activeHitGizmoEntity = originalActiveHitGizmoEntity + directionHandleEntityId = originalDirectionHandleEntityId super.tearDown() } @@ -73,12 +78,76 @@ final class GizmoSystemTests: XCTestCase { isLight: Bool = false) -> EntityID { let e = createEntity() + if !hasComponent(entityId: e, componentType: LocalTransformComponent.self) { + registerTransformComponent(entityId: e) + } if let name { setEntityName(entityId: e, name: name) } if let p = pos { translateTo(entityId: e, position: p) } if isLight { registerComponent(entityId: e, componentType: LightComponent.self) } return e } + private func makeGizmoHandle(mode: TransformManipulationMode, axis: TransformAxis) -> EntityID { + let e = makeEntity(name: "metadataHandle") + registerComponent(entityId: e, componentType: GizmoComponent.self) + let handle = scene.assign(to: e, component: GizmoHandleComponent.self) + handle?.mode = mode + handle?.axis = axis + return e + } + + private func findGizmoHandle(mode: TransformManipulationMode, axis: TransformAxis) -> EntityID { + getEntityChildren(parentId: parentEntityIdGizmo).first(where: { + guard let handle = scene.get(component: GizmoHandleComponent.self, for: $0) else { return false } + return handle.mode == mode && handle.axis == axis + }) ?? .invalid + } + + private func rayThroughGizmoAxis(_ axis: TransformAxis, amount: Float) -> GizmoDragRay { + switch axis { + case .x: + return GizmoDragRay(origin: SIMD3(amount, 1.0, 0.0), direction: SIMD3(0.0, -1.0, 0.0)) + case .y: + return GizmoDragRay(origin: SIMD3(1.0, amount, 0.0), direction: SIMD3(-1.0, 0.0, 0.0)) + case .z: + return GizmoDragRay(origin: SIMD3(0.0, 1.0, amount), direction: SIMD3(0.0, -1.0, 0.0)) + case .none: + return GizmoDragRay(origin: SIMD3(0.0, 1.0, 0.0), direction: SIMD3(0.0, -1.0, 0.0)) + } + } + + private func vector(for axis: TransformAxis, amount: Float) -> SIMD3 { + switch axis { + case .x: + return SIMD3(amount, 0.0, 0.0) + case .y: + return SIMD3(0.0, amount, 0.0) + case .z: + return SIMD3(0.0, 0.0, amount) + case .none: + return .zero + } + } + + private func component(of vector: SIMD3, along axis: TransformAxis) -> Float { + switch axis { + case .x: + return vector.x + case .y: + return vector.y + case .z: + return vector.z + case .none: + return 0.0 + } + } + + private func assertNearlyEqual(_ lhs: simd_float3, _ rhs: simd_float3, accuracy: Float = 0.0001, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(lhs.x, rhs.x, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(lhs.y, rhs.y, accuracy: accuracy, file: file, line: line) + XCTAssertEqual(lhs.z, rhs.z, accuracy: accuracy, file: file, line: line) + } + // MARK: - createGizmo() func test_createGizmo_noActiveEntity_doesNothing() { @@ -172,17 +241,12 @@ final class GizmoSystemTests: XCTestCase { // MARK: - hitGizmoToolAxis() - func test_hitGizmoToolAxis_validNamesReturnTrue_andInvalidReturnsFalse() { - let validNames = [ - "xAxisTranslate", "yAxisTranslate", "zAxisTranslate", - "xAxisRotate", "yAxisRotate", "zAxisRotate", - "xAxisScale", "yAxisScale", "zAxisScale", - "directionHandle", - ] - for n in validNames { - let e = makeEntity(name: n) - XCTAssertTrue(hitGizmoToolAxis(entityId: e), "Expected \(n) to be recognized as a gizmo handle.") - } + func test_hitGizmoToolAxis_usesHandleMetadata() { + let valid = makeGizmoHandle(mode: .translate, axis: .x) + XCTAssertTrue(hitGizmoToolAxis(entityId: valid), "Expected metadata handle to be recognized.") + + let legacyNameOnly = makeEntity(name: "xAxisTranslate") + XCTAssertFalse(hitGizmoToolAxis(entityId: legacyNameOnly), "Names alone should not make an entity a gizmo handle.") let invalid = makeEntity(name: "randomNode") XCTAssertFalse(hitGizmoToolAxis(entityId: invalid), "Non-gizmo nodes should return false.") @@ -202,26 +266,25 @@ final class GizmoSystemTests: XCTestCase { let controller = makeEditorController() editorController = controller - // (name → expected axis, expected mode) - let cases: [(String, TransformAxis, TransformManipulationMode)] = [ - ("xAxisTranslate", .x, .translate), - ("yAxisTranslate", .y, .translate), - ("zAxisTranslate", .z, .translate), - ("xAxisRotate", .x, .rotate), - ("yAxisRotate", .y, .rotate), - ("zAxisRotate", .z, .rotate), - ("xAxisScale", .x, .scale), - ("yAxisScale", .y, .scale), - ("zAxisScale", .z, .scale), - ("directionHandle", .none, .lightRotate), + let cases: [(TransformManipulationMode, TransformAxis)] = [ + (.translate, .x), + (.translate, .y), + (.translate, .z), + (.rotate, .x), + (.rotate, .y), + (.rotate, .z), + (.scale, .x), + (.scale, .y), + (.scale, .z), + (.lightRotate, .none), ] - for (name, expAxis, expMode) in cases { - let e = makeEntity(name: name) + for (expMode, expAxis) in cases { + let e = makeGizmoHandle(mode: expMode, axis: expAxis) processGizmoAction(entityId: e) - XCTAssertEqual(controller.activeAxis, expAxis, "Axis for \(name) incorrect.") - XCTAssertEqual(controller.activeMode, expMode, "Mode for \(name) incorrect.") + XCTAssertEqual(controller.activeAxis, expAxis) + XCTAssertEqual(controller.activeMode, expMode) } // Unknown handle → reset to none @@ -231,6 +294,15 @@ final class GizmoSystemTests: XCTestCase { XCTAssertEqual(controller.activeMode, .none) } + func test_processGizmoAction_withoutEditorControllerDoesNotCrash() { + editorController = nil + let e = makeGizmoHandle(mode: .translate, axis: .x) + + processGizmoAction(entityId: e) + + XCTAssertTrue(hitGizmoToolAxis(entityId: e)) + } + func test_processGizmoAction_earlyReturnOnInvalid() { // Arrange let controller = EditorController(selectionManager: SelectionManager()) @@ -267,4 +339,259 @@ final class GizmoSystemTests: XCTestCase { XCTAssertEqual(parentEntityIdGizmo, .invalid) XCTAssertFalse(gizmoActive) } + + func test_createGizmo_addsTypedHandleMetadataToChildren() { + let active = makeEntity(name: "Box", pos: SIMD3(0, 0, 0)) + activeEntity = active + + createGizmo(mode: .translate) + + let children = getEntityChildren(parentId: parentEntityIdGizmo) + XCTAssertFalse(children.isEmpty) + XCTAssertTrue(children.allSatisfy { hitGizmoToolAxis(entityId: $0) }) + XCTAssertTrue(children.contains { + guard let handle = scene.get(component: GizmoHandleComponent.self, for: $0) else { return false } + return handle.mode == .translate && handle.axis == .x + }) + } + + func test_createRotateGizmo_addsHiddenHitProxyHandlesForEveryAxis() { + let active = makeEntity(name: "RotatableBox", pos: SIMD3(0, 0, 0)) + activeEntity = active + + createGizmo(mode: .rotate) + + let children = getEntityChildren(parentId: parentEntityIdGizmo) + for axis in [TransformAxis.x, .y, .z] { + let proxies = children.filter { + guard hasComponent(entityId: $0, componentType: GizmoHitProxyComponent.self), + let handle = scene.get(component: GizmoHandleComponent.self, for: $0) + else { + return false + } + return handle.mode == .rotate && handle.axis == axis + } + + XCTAssertFalse(proxies.isEmpty, "Expected hidden rotate hit proxies for \(axis)-axis.") + } + } + + func test_createGizmo_addsHiddenHitProxyForLightDirectionHandle() { + let light = makeEntity(name: "DirectionalLight", pos: SIMD3(0, 0, 0), isLight: true) + activeEntity = light + + createGizmo(mode: .translate) + + let children = getEntityChildren(parentId: parentEntityIdGizmo) + let proxies = children.filter { + guard hasComponent(entityId: $0, componentType: GizmoHitProxyComponent.self), + let handle = scene.get(component: GizmoHandleComponent.self, for: $0) + else { + return false + } + + return handle.mode == .lightRotate && handle.axis == .none + } + + XCTAssertFalse(proxies.isEmpty, "Expected hidden hit proxy for the light direction handle.") + } + + func test_createGizmo_placesLightDirectionHandleAlongCurrentLightForwardAfterReselect() { + 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)) + rotateTo(entityId: light, angle: -20.0, axis: SIMD3(1.0, 0.0, 0.0)) + + createGizmo(mode: .translate) + removeGizmo() + + createGizmo(mode: .translate) + + let directionHandle = findGizmoHandle(mode: .lightRotate, axis: .none) + guard directionHandle != .invalid else { + XCTFail("Expected light direction handle.") + return + } + + let expectedOffset = simd_normalize(-1.0 * getForwardAxisVector(entityId: light)) + assertNearlyEqual(getLocalPosition(entityId: directionHandle), expectedOffset, accuracy: 0.0002) + } + + func test_gizmoRootWorldPosition_usesGizmoParentWhenAvailable() { + let active = makeEntity(name: "OffsetBox", pos: SIMD3(1, 2, 3)) + activeEntity = active + createGizmo(mode: .translate) + translateTo(entityId: parentEntityIdGizmo, position: SIMD3(9, 8, 7)) + + XCTAssertEqual(gizmoRootWorldPosition(), SIMD3(9, 8, 7)) + } + + func test_axisGizmoDrag_translatesFromCurrentRayConstraint() { + let active = makeEntity(name: "DraggedBox", pos: SIMD3(0, 0, 0)) + activeEntity = active + createGizmo(mode: .translate) + + let xHandle = findGizmoHandle(mode: .translate, axis: .x) + guard xHandle != .invalid else { + XCTFail("Expected x-axis translate handle.") + return + } + + activeHitGizmoEntity = xHandle + beginGizmoDrag( + ray: GizmoDragRay( + origin: SIMD3(0, 1, 0), + direction: SIMD3(0, -1, 0) + ) + ) + updateGizmoDrag( + ray: GizmoDragRay( + origin: SIMD3(2, 1, 0), + direction: SIMD3(0, -1, 0) + ) + ) + + XCTAssertEqual(getLocalPosition(entityId: active), SIMD3(2, 0, 0)) + XCTAssertEqual(getPosition(entityId: parentEntityIdGizmo), SIMD3(2, 0, 0)) + } + + func test_axisGizmoDrag_translationTracksAbsoluteAxisPositionForAllAxes() { + let axes: [TransformAxis] = [.x, .y, .z] + + for axis in axes { + let startPosition = SIMD3(0.25, -0.5, 1.0) + let active = makeEntity(name: "AbsoluteTranslate-\(axis)", pos: startPosition) + activeEntity = active + createGizmo(mode: .translate) + + let handle = findGizmoHandle(mode: .translate, axis: axis) + guard handle != .invalid else { + XCTFail("Expected translate handle for axis \(axis).") + return + } + + activeHitGizmoEntity = handle + let startAxisAmount = component(of: startPosition, along: axis) + beginGizmoDrag(ray: rayThroughGizmoAxis(axis, amount: startAxisAmount)) + updateGizmoDrag(ray: rayThroughGizmoAxis(axis, amount: startAxisAmount + 1.5)) + updateGizmoDrag(ray: rayThroughGizmoAxis(axis, amount: startAxisAmount - 0.75)) + + let expectedEntityPosition = startPosition + vector(for: axis, amount: -0.75) + let expectedGizmoPosition = startPosition + vector(for: axis, amount: -0.75) + assertNearlyEqual(getLocalPosition(entityId: active), expectedEntityPosition) + assertNearlyEqual(getPosition(entityId: parentEntityIdGizmo), expectedGizmoPosition) + + endGizmoDrag() + removeGizmo() + } + } + + func test_axisGizmoDrag_scalesFromCurrentRayConstraint() { + let active = makeEntity(name: "ScaledBox", pos: SIMD3(0, 0, 0)) + activeEntity = active + createGizmo(mode: .scale) + + let xHandle = findGizmoHandle(mode: .scale, axis: .x) + guard xHandle != .invalid else { + XCTFail("Expected x-axis scale handle.") + return + } + + activeHitGizmoEntity = xHandle + beginGizmoDrag( + ray: GizmoDragRay( + origin: SIMD3(0, 1, 0), + direction: SIMD3(0, -1, 0) + ) + ) + updateGizmoDrag( + ray: GizmoDragRay( + origin: SIMD3(0.5, 1, 0), + direction: SIMD3(0, -1, 0) + ) + ) + + let scale = scene.get(component: LocalTransformComponent.self, for: active)?.scale + XCTAssertEqual(scale, SIMD3(1.5, 1.0, 1.0)) + } + + func test_axisGizmoDrag_scaleTracksAbsoluteAxisPositionForAllAxes() { + let axes: [TransformAxis] = [.x, .y, .z] + + for axis in axes { + let active = makeEntity(name: "AbsoluteScale-\(axis)", pos: SIMD3(0, 0, 0)) + activeEntity = active + createGizmo(mode: .scale) + + let handle = findGizmoHandle(mode: .scale, axis: axis) + guard handle != .invalid else { + XCTFail("Expected scale handle for axis \(axis).") + return + } + + activeHitGizmoEntity = handle + beginGizmoDrag(ray: rayThroughGizmoAxis(axis, amount: 0.0)) + updateGizmoDrag(ray: rayThroughGizmoAxis(axis, amount: 0.4)) + updateGizmoDrag(ray: rayThroughGizmoAxis(axis, amount: 1.25)) + + let expectedScale = SIMD3(repeating: 1.0) + vector(for: axis, amount: 1.25) + guard let scale = scene.get(component: LocalTransformComponent.self, for: active)?.scale else { + XCTFail("Expected LocalTransformComponent for scaled entity.") + return + } + assertNearlyEqual(scale, expectedScale) + + endGizmoDrag() + removeGizmo() + } + } + + func test_applyGizmoRotationDelta_repeatedYAxisRotationsStayOnYAxis() { + let active = makeEntity(name: "RepeatedYRotation", pos: SIMD3(0, 0, 0)) + + applyGizmoRotationDelta(entityId: active, axis: SIMD3(0, 1, 0), degrees: 30) + applyGizmoRotationDelta(entityId: active, axis: SIMD3(0, 1, 0), degrees: 15) + + let orientation = getLocalOrientation(entityId: active) + let forward = simd_normalize(orientation * SIMD3(0, 0, 1)) + let expectedForward = simd_normalize( + simd_quatf(angle: Float.pi / 4, axis: SIMD3(0, 1, 0)).act(SIMD3(0, 0, 1)) + ) + + assertNearlyEqual(forward, expectedForward, accuracy: 0.0002) + XCTAssertEqual(forward.y, 0.0, accuracy: 0.0002) + } + + func test_applyGizmoRotationDelta_afterExistingRotationUsesWorldAxis() { + let active = makeEntity(name: "WorldAxisRotation", pos: SIMD3(0, 0, 0)) + + applyGizmoRotationDelta(entityId: active, axis: SIMD3(1, 0, 0), degrees: 45) + let beforeUp = getLocalOrientation(entityId: active) * SIMD3(0, 1, 0) + + applyGizmoRotationDelta(entityId: active, axis: SIMD3(0, 1, 0), degrees: 30) + let afterUp = getLocalOrientation(entityId: active) * SIMD3(0, 1, 0) + + let expectedAfterUp = simd_quatf(angle: Float.pi / 6, axis: SIMD3(0, 1, 0)).act(beforeUp) + assertNearlyEqual(afterUp, expectedAfterUp, accuracy: 0.0002) + } + + func test_applyGizmoRotationDelta_usesExplicitWorldAxisForEveryAxis() { + let cases: [(TransformAxis, SIMD3, Float)] = [ + (.x, SIMD3(1.0, 0.0, 0.0), 20.0), + (.y, SIMD3(0.0, 1.0, 0.0), -35.0), + (.z, SIMD3(0.0, 0.0, 1.0), 50.0), + ] + + for (axisName, axisVector, degrees) in cases { + let active = makeEntity(name: "WorldRotation-\(axisName)", pos: SIMD3(0, 0, 0)) + + applyGizmoRotationDelta(entityId: active, axis: axisVector, degrees: degrees) + + let orientation = getLocalOrientation(entityId: active) + let expected = simd_quatf(angle: degreesToRadians(degrees: degrees), axis: axisVector) + assertNearlyEqual(simd_mul(orientation, SIMD3(1.0, 0.0, 0.0)), expected.act(SIMD3(1.0, 0.0, 0.0)), accuracy: 0.0002) + assertNearlyEqual(simd_mul(orientation, SIMD3(0.0, 1.0, 0.0)), expected.act(SIMD3(0.0, 1.0, 0.0)), accuracy: 0.0002) + assertNearlyEqual(simd_mul(orientation, SIMD3(0.0, 0.0, 1.0)), expected.act(SIMD3(0.0, 0.0, 1.0)), accuracy: 0.0002) + } + } } diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index 5d6407f..ba4d2b7 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -31,7 +31,8 @@ import XCTest onCreateSphereCalled: UnsafeMutablePointer, onCreatePlaneCalled: UnsafeMutablePointer, onCreateCylinderCalled: UnsafeMutablePointer, - onCreateConeCalled: UnsafeMutablePointer + onCreateConeCalled: UnsafeMutablePointer, + onQuickPreviewModes: UnsafeMutablePointer<[QuickPreviewImportMode]>? = nil ) -> ToolbarView { ToolbarView( selectionManager: selectionManager, @@ -49,7 +50,7 @@ import XCTest onCreatePlane: { onCreatePlaneCalled.pointee = true }, onCreateCylinder: { onCreateCylinderCalled.pointee = true }, onCreateCone: { onCreateConeCalled.pointee = true }, - onQuickPreview: {} + onQuickPreview: { mode in onQuickPreviewModes?.pointee.append(mode) } ) } @@ -68,6 +69,7 @@ import XCTest var onPlane = false var onCylinder = false var onCone = false + var quickPreviewModes: [QuickPreviewImportMode] = [] let sut = makeSUT( onSaveCalled: &onSave, @@ -83,7 +85,8 @@ import XCTest onCreateSphereCalled: &onSphere, onCreatePlaneCalled: &onPlane, onCreateCylinderCalled: &onCylinder, - onCreateConeCalled: &onCone + onCreateConeCalled: &onCone, + onQuickPreviewModes: &quickPreviewModes ) // We cannot programmatically tap SwiftUI Buttons without a host and introspection. @@ -100,6 +103,8 @@ 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.") @@ -113,6 +118,7 @@ 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. @@ -142,7 +148,7 @@ import XCTest onCreatePlane: {}, onCreateCylinder: {}, onCreateCone: {}, - onQuickPreview: {} + onQuickPreview: { _ in } ) // Wrap in a hosting controller to ensure SwiftUI can build the body. @@ -178,7 +184,7 @@ import XCTest onCreatePlane: { planeCreated = true }, onCreateCylinder: { cylinderCreated = true }, onCreateCone: { coneCreated = true }, - onQuickPreview: {} + onQuickPreview: { _ in } ) // When: Invoking the primitive creation closures @@ -215,7 +221,7 @@ import XCTest onCreatePlane: { callCount += 1 }, onCreateCylinder: {}, onCreateCone: {}, - onQuickPreview: {} + onQuickPreview: { _ in } ) // When: Calling the primitive closures @@ -249,7 +255,7 @@ import XCTest onCreatePlane: { planeCount += 1 }, onCreateCylinder: {}, onCreateCone: {}, - onQuickPreview: {} + onQuickPreview: { _ in } ) // When: Calling specific primitive closures multiple times @@ -262,5 +268,19 @@ 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