From 5438b7947e3b6240ba543e8089f1836bd32f3f18 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 7 May 2026 16:15:29 -0700 Subject: [PATCH 01/12] [Patch] add support to assetbrowser to import remote asset --- .../Editor/AssetBrowserView.swift | 138 +++++++++++++++++- .../UntoldEditor/Editor/InspectorView.swift | 31 +++- 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 3ccd431..505cf12 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -318,6 +318,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,6 +373,8 @@ struct AssetBrowserView: View { @State private var statusIsError = false @State private var targetEntityName: String = "None" @State private var showImportMenu = false + @State private var showRemoteStreamSheet = false + @State private var remoteStreamURLString = "" @State private var pendingRuntimeExport: RuntimeExportRequest? @State private var runtimeExportQueue: [RuntimeExportRequest] = [] @State private var isExportingRuntimeAsset = false @@ -383,6 +421,13 @@ struct AssetBrowserView: View { } } } + Divider() + Button(action: { showRemoteStreamSheet = true }) { + HStack { + Image(systemName: "globe") + Text("Import Remote Stream") + } + } } label: { HStack(spacing: 6) { Text("Import") @@ -618,6 +663,15 @@ struct AssetBrowserView: View { .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) @@ -1406,6 +1460,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 +1521,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 +1546,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 +1695,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/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index b4d04b9..e87c940 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -360,6 +360,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 +407,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] = [:] From 8ac8c8bd38cb669b1f9ea5df621916b5dcb2d4cf Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 00:05:23 -0700 Subject: [PATCH 02/12] [Patch] Fix anti-aliasing failure --- .../Systems/EditorRenderingSystem.swift | 83 +++++++++++++++++-- .../BuildEditModeGraphTests.swift | 24 +++--- .../EditorRenderingSystemTests.swift | 8 +- 3 files changed, 90 insertions(+), 25 deletions(-) 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/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() } From b3eb2451b290467d8ea69e8760a44e41a66b224d Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 06:38:40 -0700 Subject: [PATCH 03/12] [Patch] Fix anti-aliasing failure --- .../UntoldEditor/Editor/EnvironmentView.swift | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 1872b24..129ba11 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 @@ -423,6 +538,10 @@ struct PostProcessingEditorView: View { .padding() } + DisclosureGroup("Anti-Aliasing", isExpanded: $showAntiAliasing) { + AntiAliasingEditorView() + } + DisclosureGroup("Depth of Field", isExpanded: $showDoF) { DepthOfFieldEditorView() } From 36825d8b441f28be9cec02f6b0f41f504ffd5ea6 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 07:06:12 -0700 Subject: [PATCH 04/12] [Patch] Fixed the quick load preview --- Sources/UntoldEditor/Editor/EditorView.swift | 47 +++++++++++--- .../Editor/QuickPreviewComponent.swift | 62 +++++++++++++++++++ Sources/UntoldEditor/Editor/ToolbarView.swift | 20 ++++-- .../UntoldEditorTests/ToolbarViewTests.swift | 33 +++++++--- 4 files changed, 142 insertions(+), 20 deletions(-) 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/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/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 4d0bc80..db4f95e 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -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/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index 5d6407f..2c0e7f1 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,18 @@ 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)") + XCTAssertEqual(QuickPreviewImportMode.untoldAsset.allowedContentTypes.first?.preferredFilenameExtension, "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 From 9d23a4a16c317307d6c7e7dbb470e264e4f154ad Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 09:01:58 -0700 Subject: [PATCH 05/12] [Patch] fixed rotation gizmo --- .../Renderer/EditorRenderPasses.swift | 4 + .../Renderer/EditorUntoldRenderer.swift | 47 +- .../Systems/EditorInputSystemAppKit.swift | 20 + .../UntoldEditor/Systems/GizmoSystem.swift | 446 +++++++++++++++--- Tests/UntoldEditorTests/GizmoSystemTest.swift | 340 +++++++++++-- 5 files changed, 747 insertions(+), 110 deletions(-) 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..a728bd7 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -251,6 +251,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 +273,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 +325,7 @@ initialPanLocation = nil currentPanGestureState = .ended cameraControlMode = .idle + endGizmoDrag() default: break diff --git a/Sources/UntoldEditor/Systems/GizmoSystem.swift b/Sources/UntoldEditor/Systems/GizmoSystem.swift index 5521a7f..47868e0 100644 --- a/Sources/UntoldEditor/Systems/GizmoSystem.swift +++ b/Sources/UntoldEditor/Systems/GizmoSystem.swift @@ -19,11 +19,252 @@ 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 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 +343,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 +356,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 } @@ -124,11 +374,24 @@ private func makeDirectionHandle() -> EntityID { 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: initialLightDirectionHandleOffset(), + color: handleColor, + descriptor: GizmoHandleDescriptor(mode: .lightRotate, axis: .none) ) } +private func initialLightDirectionHandleOffset() -> simd_float3 { + guard activeEntity != .invalid, + let localTransform = scene.get(component: LocalTransformComponent.self, for: activeEntity) + else { + return simd_float3(0.0, GizmoDimensions.directionHandleOffsetY, 0.0) + } + + let forward = forwardDirectionVector(from: localTransform.rotation) + 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)? { let up = simd_float3(0.0, 1.0, 0.0) let dirLength = simd_length(direction) @@ -195,11 +458,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 +556,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 +565,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 +575,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 +594,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 +603,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 +637,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 +645,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 +655,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 +674,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 +682,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 +703,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 +720,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 +737,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 +767,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 +789,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 +810,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/Tests/UntoldEditorTests/GizmoSystemTest.swift b/Tests/UntoldEditorTests/GizmoSystemTest.swift index 2e0c89c..a1af3c7 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,218 @@ 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_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) + } + } } From 59f1ae298417875644584165368d4312bde58931 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 12:00:33 -0700 Subject: [PATCH 06/12] [Patch] Modify quick preview --- Sources/UntoldEditor/Editor/EditorView.swift | 328 ++++++++++++++++++ .../Editor/QuickPreviewComponent.swift | 83 ++++- .../UntoldEditorTests/ToolbarViewTests.swift | 61 +++- 3 files changed, 466 insertions(+), 6 deletions(-) diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index 7898e22..e89853e 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -11,6 +11,12 @@ public struct Asset: Identifiable { var isFolder: Bool = false } +private struct QuickPreviewRuntimeExportRequest: Identifiable, Equatable { + let id = UUID() + let sourceURL: URL + let outputURL: URL +} + public struct EditorView: View { @State private var editor_entities: [EntityID] = getAllGameEntities() @StateObject private var selectionManager = SelectionManager() @@ -27,6 +33,13 @@ public struct EditorView: View { @State private var useSceneCameraDuringPlay = false @State private var showQuickPreviewWarning = false @State private var quickPreviewEntities: [(EntityID, String)] = [] + @State private var pendingQuickPreviewExport: QuickPreviewRuntimeExportRequest? + @State private var isExportingQuickPreviewAsset = false + @State private var quickPreviewConvertOrientation = false + @State private var quickPreviewSourceOrientation = "blender-native" + @State private var quickPreviewCompressGeometry = false + @State private var quickPreviewCompressTextures = false + @State private var quickPreviewAstcencBinPath = "" var renderer: UntoldRenderer? @@ -235,6 +248,9 @@ public struct EditorView: View { let entityWord = count == 1 ? "entity" : "entities" return Text("Your scene contains \(count) Quick Preview \(entityWord):\n\n\(entityNames)\n\nQuick Preview entities use absolute file paths and cannot be saved to scenes. To include these assets permanently, use the Import button in the Asset Browser to copy them into your project first.\n\nYou can delete the Quick Preview entities and save the rest of your scene, or cancel to keep working.") } + .sheet(item: $pendingQuickPreviewExport) { request in + quickPreviewRuntimeExportSheet(for: request) + } } private func editor_handleSave() { @@ -728,6 +744,11 @@ public struct EditorView: View { let fileExtension = fileURL.pathExtension.lowercased() let absolutePath = fileURL.path + if isUSDSourceAsset(fileURL) { + queueQuickPreviewRuntimeExport(sourceURL: fileURL) + return + } + if fileExtension == "json", !isTiledSceneManifest(fileURL) { Logger.log(message: "⚠️ Quick Preview JSON is not a tiled scene manifest: \(fileURL.lastPathComponent)") return @@ -816,6 +837,303 @@ public struct EditorView: View { print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") } + private func isUSDSourceAsset(_ url: URL) -> Bool { + ["usd", "usda", "usdc", "usdz"].contains(url.pathExtension.lowercased()) + } + + private func queueQuickPreviewRuntimeExport(sourceURL: URL) { + let cacheDirectory = QuickPreviewRuntimeExportCache.cacheDirectory(for: sourceURL) + let outputURL = QuickPreviewRuntimeExportCache.outputURL(for: sourceURL, in: cacheDirectory) + + QuickPreviewRuntimeExportCache.pruneStaleCaches(preserving: [cacheDirectory]) + + pendingQuickPreviewExport = QuickPreviewRuntimeExportRequest( + sourceURL: sourceURL, + outputURL: outputURL + ) + } + + private func quickPreviewRuntimeExportSheet(for request: QuickPreviewRuntimeExportRequest) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("Convert to Untold Preview Asset") + .font(.title2) + .bold() + + Text("This USD file needs to be converted to Untold Engine's .untold runtime format before it can be previewed.") + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 6) { + Text("Source") + .font(.caption) + .foregroundColor(.secondary) + Text(request.sourceURL.path) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + + Text("Output") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, 6) + Text(request.outputURL.path) + .font(.system(size: 12, design: .monospaced)) + .lineLimit(2) + } + + VStack(alignment: .leading, spacing: 10) { + Toggle("Convert orientation", isOn: $quickPreviewConvertOrientation) + + Picker("Source orientation", selection: $quickPreviewSourceOrientation) { + Text("Blender native").tag("blender-native") + Text("Engine oriented").tag("engine-oriented") + } + .disabled(!quickPreviewConvertOrientation) + + Toggle("Compress geometry (LZ4)", isOn: $quickPreviewCompressGeometry) + .help("Compresses vertex and index data with LZ4. Requires the Python lz4 package.") + if quickPreviewCompressGeometry { + Text("Requires: pip install lz4") + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 20) + } + + Toggle("Compress textures (ASTC)", isOn: $quickPreviewCompressTextures) + .help("Converts textures to GPU-native ASTC format. Requires astcenc and the Python Pillow package.") + if quickPreviewCompressTextures { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Link("Install astcenc ->", destination: URL(string: "https://github.com/ARM-software/astc-encoder/releases")!) + .font(.caption) + Text("-") + .font(.caption) + .foregroundColor(.secondary) + Text("Also requires: pip install Pillow") + .font(.caption) + .foregroundColor(.secondary) + } + VStack(alignment: .leading, spacing: 4) { + Text("astcenc path (optional)") + .font(.caption) + .foregroundColor(.secondary) + HStack { + TextField("/opt/homebrew/bin/astcenc", text: $quickPreviewAstcencBinPath) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + Button("Browse...") { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.title = "Select astcenc binary" + if panel.runModal() == .OK, let url = panel.url { + quickPreviewAstcencBinPath = url.path + } + } + } + } + } + .padding(.leading, 20) + } + } + + if isExportingQuickPreviewAsset { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Exporting...") + .foregroundColor(.secondary) + } + } + + HStack { + Spacer() + Button("Cancel") { + pendingQuickPreviewExport = nil + } + .disabled(isExportingQuickPreviewAsset) + + Button("Export and Load") { + exportAndLoadQuickPreviewRuntimeAsset(request) + } + .keyboardShortcut(.defaultAction) + .disabled(isExportingQuickPreviewAsset) + } + } + .padding(20) + .frame(width: 560) + } + + private func exportAndLoadQuickPreviewRuntimeAsset(_ request: QuickPreviewRuntimeExportRequest) { + guard !isExportingQuickPreviewAsset else { return } + guard let exporterScript = findExportUntoldScript() else { + Logger.log(message: "❌ export-untold script not found. Expected at .build/checkouts/UntoldEngine/scripts/export-untold") + pendingQuickPreviewExport = nil + return + } + + isExportingQuickPreviewAsset = true + let convertOrientation = quickPreviewConvertOrientation + let sourceOrientation = quickPreviewSourceOrientation + let compressGeometry = quickPreviewCompressGeometry + let compressTextures = quickPreviewCompressTextures + let astcencBin = quickPreviewAstcencBinPath.trimmingCharacters(in: .whitespacesAndNewlines) + + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + let tempDirectory = FileManager.default.temporaryDirectory + let outputLogURL = tempDirectory.appendingPathComponent("quick-preview-export-\(UUID().uuidString).out") + let errorLogURL = tempDirectory.appendingPathComponent("quick-preview-export-\(UUID().uuidString).err") + + do { + try FileManager.default.createDirectory(at: request.outputURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: request.outputURL.path) { + try FileManager.default.removeItem(at: request.outputURL) + } + FileManager.default.createFile(atPath: outputLogURL.path, contents: nil) + FileManager.default.createFile(atPath: errorLogURL.path, contents: nil) + let outputHandle = try FileHandle(forWritingTo: outputLogURL) + let errorHandle = try FileHandle(forWritingTo: errorLogURL) + defer { + try? outputHandle.close() + try? errorHandle.close() + try? FileManager.default.removeItem(at: outputLogURL) + try? FileManager.default.removeItem(at: errorLogURL) + } + + process.executableURL = exporterScript + var arguments = [ + "--input", request.sourceURL.path, + "--output", request.outputURL.path, + ] + if convertOrientation { + arguments.append("--ConvertOrientation") + arguments.append(contentsOf: ["--source-orientation", sourceOrientation]) + } + if compressGeometry { + arguments.append("--compress-geometry") + } + process.arguments = arguments + process.standardOutput = outputHandle + process.standardError = errorHandle + + try process.run() + process.waitUntilExit() + + let stdout = (try? String(contentsOf: outputLogURL, encoding: .utf8)) ?? "" + let stderr = (try? String(contentsOf: errorLogURL, encoding: .utf8)) ?? "" + let exportSucceeded = process.terminationStatus == 0 + + DispatchQueue.main.async { + if !stdout.isEmpty { Logger.log(message: stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !stderr.isEmpty { Logger.log(message: stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + } + + if exportSucceeded, compressTextures { + let texturesDir = request.outputURL.deletingLastPathComponent().appendingPathComponent("Textures") + if FileManager.default.fileExists(atPath: texturesDir.path), + let texbakeScript = findTexbakeScript() + { + let bakeResult = runTexbakeStep(script: texbakeScript, arguments: ["--dir", texturesDir.path], astcencBin: astcencBin) + let patchResult = runTexbakeStep(script: texbakeScript, arguments: ["--patch-refs", request.outputURL.path], astcencBin: astcencBin) + DispatchQueue.main.async { + if !bakeResult.stdout.isEmpty { Logger.log(message: bakeResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !bakeResult.stderr.isEmpty { Logger.log(message: bakeResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !patchResult.stdout.isEmpty { Logger.log(message: patchResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } + if !patchResult.stderr.isEmpty { Logger.log(message: patchResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } + if bakeResult.status != 0 || patchResult.status != 0 { + Logger.log(message: "⚠️ ASTC compression had errors — preview asset exported without compressed textures") + } + } + } else { + DispatchQueue.main.async { + Logger.log(message: "⚠️ ASTC skipped — texbake.py not found or no Textures folder present") + } + } + } + + DispatchQueue.main.async { + isExportingQuickPreviewAsset = false + pendingQuickPreviewExport = nil + if exportSucceeded { + editor_loadQuickPreviewAsset(from: request.outputURL, originalSourceURL: request.sourceURL) + } else { + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: request.outputURL.deletingLastPathComponent()) + Logger.log(message: "❌ Quick Preview export failed for \(request.sourceURL.lastPathComponent)") + } + } + } catch { + DispatchQueue.main.async { + isExportingQuickPreviewAsset = false + pendingQuickPreviewExport = nil + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: request.outputURL.deletingLastPathComponent()) + Logger.log(message: "❌ Quick Preview export failed: \(error)") + } + } + } + } + + private func editor_loadQuickPreviewAsset(from loadURL: URL, originalSourceURL: URL? = nil) { + let sourceURL = originalSourceURL ?? loadURL + let fileExtension = loadURL.pathExtension.lowercased() + let absolutePath = loadURL.path + let fileName = sourceURL.deletingPathExtension().lastPathComponent + + deleteExistingQuickPreviewEntities() + removeGizmo() + + let entityId = createEntity() + let uniqueName = "QuickPreview-\(fileName)-\(entityId)" + setEntityName(entityId: entityId, name: uniqueName) + + if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + quickPreviewComp.absoluteFilePath = sourceURL.path + quickPreviewComp.fileExtension = sourceURL.pathExtension.lowercased() + quickPreviewComp.originalFileName = fileName + if originalSourceURL != nil { + quickPreviewComp.runtimePreviewDirectoryPath = loadURL.deletingLastPathComponent().path + } + } + + if fileExtension == "untold" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = false + + setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in + if success { + print("✅ Quick Preview loaded: \(loadURL.lastPathComponent)") + } else { + print("⚠️ Failed to load Quick Preview, using fallback: \(loadURL.lastPathComponent)") + } + } + } else if fileExtension == "ply" { + clearSceneBatches() + GeometryStreamingSystem.shared.enabled = false + + setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) + print("✅ Quick Preview Gaussian loaded: \(loadURL.lastPathComponent)") + } + + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { + handleError(.noActiveCamera) + return + } + + var forward = forwardDirectionVector(from: cameraComponent.rotation) + forward *= -1.0 + let camPosition = cameraComponent.localPosition + let spawnPosition = camPosition + forward * spawnDistance + translateTo(entityId: entityId, position: spawnPosition) + + selectionManager.selectedEntity = entityId + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + + print("ℹ️ Quick Preview mode: File loaded with absolute path") + print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") + } + private func deleteExistingQuickPreviewEntities() { let previewEntityIds = getAllGameEntities() .filter { hasComponent(entityId: $0, componentType: QuickPreviewComponent.self) } @@ -825,6 +1143,11 @@ public struct EditorView: View { } for entityId in previewEntityIds { + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId), + quickPreviewComp.runtimePreviewDirectoryPath.isEmpty == false + { + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: URL(fileURLWithPath: quickPreviewComp.runtimePreviewDirectoryPath)) + } destroyEntity(entityId: entityId) } @@ -869,6 +1192,11 @@ public struct EditorView: View { private func deleteQuickPreviewEntitiesAndSave() { // Delete all Quick Preview entities for (entityId, entityName) in quickPreviewEntities { + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId), + quickPreviewComp.runtimePreviewDirectoryPath.isEmpty == false + { + QuickPreviewRuntimeExportCache.removeCacheDirectory(at: URL(fileURLWithPath: quickPreviewComp.runtimePreviewDirectoryPath)) + } destroyEntity(entityId: entityId) print("🗑️ Deleted Quick Preview entity: \(entityName)") } diff --git a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift index 851e0cd..5f91b5c 100644 --- a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift +++ b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift @@ -19,7 +19,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var menuTitle: String { switch self { case .untoldAsset: - return "Load Untold Asset (.untold)" + return "Load Untold Asset (.untold, USD)" case .tiledScene: return "Load Tiled Stream (.json)" case .gaussian: @@ -41,7 +41,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var filePickerTitle: String { switch self { case .untoldAsset: - return "Load Preview - Select Untold Asset" + return "Load Preview - Select Untold or USD Asset" case .tiledScene: return "Load Preview - Select Tiled Stream Manifest" case .gaussian: @@ -52,7 +52,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var filePickerMessage: String { switch self { case .untoldAsset: - return "Select an Untold runtime asset to preview without creating a project" + return "Select an Untold runtime asset or USD source asset to preview without creating a project" case .tiledScene: return "Select a tiled stream manifest to preview without creating a project" case .gaussian: @@ -63,7 +63,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var allowedContentTypes: [UTType] { switch self { case .untoldAsset: - return [UTType(filenameExtension: "untold") ?? .data] + return ["untold", "usd", "usda", "usdc", "usdz"].compactMap { UTType(filenameExtension: $0) } case .tiledScene: return [.json] case .gaussian: @@ -84,15 +84,90 @@ public class QuickPreviewComponent: Component { /// The original filename without extension public var originalFileName: String + /// Disposable temp cache directory used by converted quick-preview assets. + public var runtimePreviewDirectoryPath: String + public required init() { absoluteFilePath = "" fileExtension = "" originalFileName = "" + runtimePreviewDirectoryPath = "" } public init(absoluteFilePath: String, fileExtension: String, originalFileName: String) { self.absoluteFilePath = absoluteFilePath self.fileExtension = fileExtension self.originalFileName = originalFileName + runtimePreviewDirectoryPath = "" + } +} + +enum QuickPreviewRuntimeExportCache { + static let directoryName = "UntoldEditorQuickPreviewExports" + static let defaultStaleAge: TimeInterval = 24 * 60 * 60 + + static func rootDirectory(baseDirectory: URL = FileManager.default.temporaryDirectory) -> URL { + baseDirectory.appendingPathComponent(directoryName, isDirectory: true) + } + + static func cacheDirectory( + for sourceURL: URL, + exportID: UUID = UUID(), + rootDirectory: URL = rootDirectory() + ) -> URL { + let baseName = sanitizedFileName(sourceURL.deletingPathExtension().lastPathComponent) + return rootDirectory.appendingPathComponent("\(baseName)-\(exportID.uuidString)", isDirectory: true) + } + + static func outputURL(for sourceURL: URL, in cacheDirectory: URL) -> URL { + cacheDirectory + .appendingPathComponent(sanitizedFileName(sourceURL.deletingPathExtension().lastPathComponent)) + .appendingPathExtension("untold") + } + + static func pruneStaleCaches( + in rootDirectory: URL = rootDirectory(), + preserving preservedDirectories: Set = [], + olderThan staleAge: TimeInterval = defaultStaleAge, + now: Date = Date(), + fileManager: FileManager = .default + ) { + guard let contents = try? fileManager.contentsOfDirectory( + at: rootDirectory, + includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let preservedPaths = Set(preservedDirectories.map(\.standardizedFileURL.path)) + for url in contents where preservedPaths.contains(url.standardizedFileURL.path) == false { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isDirectoryKey]), + let modifiedAt = values.contentModificationDate + else { + continue + } + + if now.timeIntervalSince(modifiedAt) > staleAge { + try? fileManager.removeItem(at: url) + } + } + } + + static func removeCacheDirectory(at url: URL, fileManager: FileManager = .default) { + let rootPath = rootDirectory().standardizedFileURL.path + let targetURL = url.standardizedFileURL + guard targetURL.path.hasPrefix(rootPath) else { + return + } + + try? fileManager.removeItem(at: targetURL) + } + + private static func sanitizedFileName(_ rawName: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let scalars = rawName.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } + let sanitized = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_")) + return sanitized.isEmpty ? "QuickPreview" : sanitized } } diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index 2c0e7f1..f14ed54 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -272,8 +272,9 @@ import XCTest func test_quickPreviewModes_exposeExpectedPickerConfiguration() { XCTAssertEqual(QuickPreviewImportMode.allCases, [.untoldAsset, .tiledScene, .gaussian]) - XCTAssertEqual(QuickPreviewImportMode.untoldAsset.menuTitle, "Load Untold Asset (.untold)") - XCTAssertEqual(QuickPreviewImportMode.untoldAsset.allowedContentTypes.first?.preferredFilenameExtension, "untold") + XCTAssertEqual(QuickPreviewImportMode.untoldAsset.menuTitle, "Load Untold Asset (.untold, USD)") + let runtimePreviewExtensions = Set(QuickPreviewImportMode.untoldAsset.allowedContentTypes.compactMap(\.preferredFilenameExtension)) + XCTAssertTrue(runtimePreviewExtensions.isSuperset(of: ["untold", "usd", "usda", "usdc", "usdz"])) XCTAssertEqual(QuickPreviewImportMode.tiledScene.menuTitle, "Load Tiled Stream (.json)") XCTAssertEqual(QuickPreviewImportMode.tiledScene.allowedContentTypes, [.json]) @@ -281,5 +282,61 @@ import XCTest XCTAssertEqual(QuickPreviewImportMode.gaussian.menuTitle, "Load Gaussian (.ply)") XCTAssertEqual(QuickPreviewImportMode.gaussian.allowedContentTypes.first?.preferredFilenameExtension, "ply") } + + func test_quickPreviewRuntimeExportCache_buildsIsolatedUntoldOutputPath() throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent("QuickPreviewCacheTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let sourceURL = URL(fileURLWithPath: "/Assets/Test Model.usdz") + let exportID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000123")) + let cacheDirectory = QuickPreviewRuntimeExportCache.cacheDirectory( + for: sourceURL, + exportID: exportID, + rootDirectory: rootURL + ) + let outputURL = QuickPreviewRuntimeExportCache.outputURL(for: sourceURL, in: cacheDirectory) + + XCTAssertEqual(cacheDirectory.deletingLastPathComponent(), rootURL) + XCTAssertEqual(cacheDirectory.lastPathComponent, "Test-Model-\(exportID.uuidString)") + XCTAssertEqual(outputURL.lastPathComponent, "Test-Model.untold") + XCTAssertEqual(outputURL.deletingLastPathComponent(), cacheDirectory) + } + + func test_quickPreviewRuntimeExportCache_prunesOnlyStaleUnpreservedEntries() throws { + let fileManager = FileManager.default + let rootURL = fileManager.temporaryDirectory + .appendingPathComponent("QuickPreviewCacheTests-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: rootURL) } + + let staleURL = rootURL.appendingPathComponent("stale", isDirectory: true) + let freshURL = rootURL.appendingPathComponent("fresh", isDirectory: true) + let preservedURL = rootURL.appendingPathComponent("preserved", isDirectory: true) + let staleFileURL = rootURL.appendingPathComponent("old-preview.untold") + try fileManager.createDirectory(at: staleURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: freshURL, withIntermediateDirectories: true) + try fileManager.createDirectory(at: preservedURL, withIntermediateDirectories: true) + _ = fileManager.createFile(atPath: staleFileURL.path, contents: Data()) + + let now = Date(timeIntervalSince1970: 1_000_000) + let staleDate = now.addingTimeInterval(-QuickPreviewRuntimeExportCache.defaultStaleAge - 10) + let freshDate = now.addingTimeInterval(-10) + try fileManager.setAttributes([.modificationDate: staleDate], ofItemAtPath: staleURL.path) + try fileManager.setAttributes([.modificationDate: freshDate], ofItemAtPath: freshURL.path) + try fileManager.setAttributes([.modificationDate: staleDate], ofItemAtPath: preservedURL.path) + try fileManager.setAttributes([.modificationDate: staleDate], ofItemAtPath: staleFileURL.path) + + QuickPreviewRuntimeExportCache.pruneStaleCaches( + in: rootURL, + preserving: [preservedURL], + now: now, + fileManager: fileManager + ) + + XCTAssertFalse(fileManager.fileExists(atPath: staleURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: staleFileURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: freshURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: preservedURL.path)) + } } #endif From 77432da0503efe412cb1bc14b3542e7be44bf83e Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 13:57:36 -0700 Subject: [PATCH 07/12] [Patch] Fixed gizmo parent-child selection --- .../Editor/AssetNodeEditingPolicy.swift | 13 +- .../UntoldEditor/Editor/InspectorView.swift | 99 ++++++------ .../Editor/SelectionManager.swift | 11 +- .../Systems/EditorInputSystemAppKit.swift | 12 +- .../AssetNodeEditingPolicyTests.swift | 144 ++++++++++++++++++ 5 files changed, 207 insertions(+), 72 deletions(-) create mode 100644 Tests/UntoldEditorTests/AssetNodeEditingPolicyTests.swift 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/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index e87c940..03cba89 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( @@ -1031,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) @@ -1052,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/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index 4e82444..d7db068 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, diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index a728bd7..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() 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)) + } +} From a19d8971bd53900b539fe14f7d35475053c05443 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 18 May 2026 15:26:03 -0700 Subject: [PATCH 08/12] [Patch] Fixed the direction handler --- .../UntoldEditor/Systems/GizmoSystem.swift | 24 ++++++++--- Tests/UntoldEditorTests/GizmoSystemTest.swift | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/Sources/UntoldEditor/Systems/GizmoSystem.swift b/Sources/UntoldEditor/Systems/GizmoSystem.swift index 47868e0..cf034c1 100644 --- a/Sources/UntoldEditor/Systems/GizmoSystem.swift +++ b/Sources/UntoldEditor/Systems/GizmoSystem.swift @@ -23,6 +23,7 @@ private enum GizmoDimensions { 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 } @@ -370,24 +371,35 @@ 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: initialLightDirectionHandleOffset(), + 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, - let localTransform = scene.get(component: LocalTransformComponent.self, for: activeEntity) - else { + guard activeEntity != .invalid else { return simd_float3(0.0, GizmoDimensions.directionHandleOffsetY, 0.0) } - let forward = forwardDirectionVector(from: localTransform.rotation) + 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) } diff --git a/Tests/UntoldEditorTests/GizmoSystemTest.swift b/Tests/UntoldEditorTests/GizmoSystemTest.swift index a1af3c7..c202a5e 100644 --- a/Tests/UntoldEditorTests/GizmoSystemTest.swift +++ b/Tests/UntoldEditorTests/GizmoSystemTest.swift @@ -376,6 +376,47 @@ final class GizmoSystemTests: XCTestCase { } } + 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 From 5a592ef8e5f1a8b9f34951aa750bafaa75eae3d3 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 21 May 2026 12:50:09 -0700 Subject: [PATCH 09/12] [Patch] Remove debug view from editor --- .../UntoldEditor/Editor/EnvironmentView.swift | 124 ------------------ .../UntoldEditor/Editor/InspectorView.swift | 2 +- .../Editor/SelectionManager.swift | 6 +- 3 files changed, 3 insertions(+), 129 deletions(-) diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index 129ba11..8a22ed9 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -512,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 { @@ -569,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 03cba89..1c74d7f 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -925,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 } diff --git a/Sources/UntoldEditor/Editor/SelectionManager.swift b/Sources/UntoldEditor/Editor/SelectionManager.swift index d7db068..194d581 100644 --- a/Sources/UntoldEditor/Editor/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/SelectionManager.swift @@ -294,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( From 4e279db37e0938a73bf0f06c9d883b7125302cef Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 22 May 2026 07:18:08 -0700 Subject: [PATCH 10/12] [Patch] Removed export tools --- .../Editor/AssetBrowserView.swift | 290 +--------------- Sources/UntoldEditor/Editor/EditorView.swift | 328 ------------------ .../Editor/QuickPreviewComponent.swift | 83 +---- .../UntoldEditorTests/ToolbarViewTests.swift | 59 +--- 4 files changed, 8 insertions(+), 752 deletions(-) diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 505cf12..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) } @@ -375,11 +363,6 @@ struct AssetBrowserView: View { @State private var showImportMenu = false @State private var showRemoteStreamSheet = false @State private var remoteStreamURLString = "" - @State private var pendingRuntimeExport: RuntimeExportRequest? - @State private var runtimeExportQueue: [RuntimeExportRequest] = [] - @State private var isExportingRuntimeAsset = false - @State private var exportConvertOrientation = true - @State private var exportSourceOrientation = "blender-native" @State private var pendingTilesExport: TilesExportRequest? @State private var tilesExportQueue: [TilesExportRequest] = [] @State private var isExportingTilesAsset = false @@ -657,9 +640,6 @@ 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) } @@ -698,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: @@ -767,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": @@ -820,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)") } } @@ -839,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( diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index e89853e..7898e22 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -11,12 +11,6 @@ public struct Asset: Identifiable { var isFolder: Bool = false } -private struct QuickPreviewRuntimeExportRequest: Identifiable, Equatable { - let id = UUID() - let sourceURL: URL - let outputURL: URL -} - public struct EditorView: View { @State private var editor_entities: [EntityID] = getAllGameEntities() @StateObject private var selectionManager = SelectionManager() @@ -33,13 +27,6 @@ public struct EditorView: View { @State private var useSceneCameraDuringPlay = false @State private var showQuickPreviewWarning = false @State private var quickPreviewEntities: [(EntityID, String)] = [] - @State private var pendingQuickPreviewExport: QuickPreviewRuntimeExportRequest? - @State private var isExportingQuickPreviewAsset = false - @State private var quickPreviewConvertOrientation = false - @State private var quickPreviewSourceOrientation = "blender-native" - @State private var quickPreviewCompressGeometry = false - @State private var quickPreviewCompressTextures = false - @State private var quickPreviewAstcencBinPath = "" var renderer: UntoldRenderer? @@ -248,9 +235,6 @@ public struct EditorView: View { let entityWord = count == 1 ? "entity" : "entities" return Text("Your scene contains \(count) Quick Preview \(entityWord):\n\n\(entityNames)\n\nQuick Preview entities use absolute file paths and cannot be saved to scenes. To include these assets permanently, use the Import button in the Asset Browser to copy them into your project first.\n\nYou can delete the Quick Preview entities and save the rest of your scene, or cancel to keep working.") } - .sheet(item: $pendingQuickPreviewExport) { request in - quickPreviewRuntimeExportSheet(for: request) - } } private func editor_handleSave() { @@ -744,11 +728,6 @@ public struct EditorView: View { let fileExtension = fileURL.pathExtension.lowercased() let absolutePath = fileURL.path - if isUSDSourceAsset(fileURL) { - queueQuickPreviewRuntimeExport(sourceURL: fileURL) - return - } - if fileExtension == "json", !isTiledSceneManifest(fileURL) { Logger.log(message: "⚠️ Quick Preview JSON is not a tiled scene manifest: \(fileURL.lastPathComponent)") return @@ -837,303 +816,6 @@ public struct EditorView: View { print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") } - private func isUSDSourceAsset(_ url: URL) -> Bool { - ["usd", "usda", "usdc", "usdz"].contains(url.pathExtension.lowercased()) - } - - private func queueQuickPreviewRuntimeExport(sourceURL: URL) { - let cacheDirectory = QuickPreviewRuntimeExportCache.cacheDirectory(for: sourceURL) - let outputURL = QuickPreviewRuntimeExportCache.outputURL(for: sourceURL, in: cacheDirectory) - - QuickPreviewRuntimeExportCache.pruneStaleCaches(preserving: [cacheDirectory]) - - pendingQuickPreviewExport = QuickPreviewRuntimeExportRequest( - sourceURL: sourceURL, - outputURL: outputURL - ) - } - - private func quickPreviewRuntimeExportSheet(for request: QuickPreviewRuntimeExportRequest) -> some View { - VStack(alignment: .leading, spacing: 16) { - Text("Convert to Untold Preview Asset") - .font(.title2) - .bold() - - Text("This USD file needs to be converted to Untold Engine's .untold runtime format before it can be previewed.") - .fixedSize(horizontal: false, vertical: true) - - VStack(alignment: .leading, spacing: 6) { - Text("Source") - .font(.caption) - .foregroundColor(.secondary) - Text(request.sourceURL.path) - .font(.system(size: 12, design: .monospaced)) - .lineLimit(2) - - Text("Output") - .font(.caption) - .foregroundColor(.secondary) - .padding(.top, 6) - Text(request.outputURL.path) - .font(.system(size: 12, design: .monospaced)) - .lineLimit(2) - } - - VStack(alignment: .leading, spacing: 10) { - Toggle("Convert orientation", isOn: $quickPreviewConvertOrientation) - - Picker("Source orientation", selection: $quickPreviewSourceOrientation) { - Text("Blender native").tag("blender-native") - Text("Engine oriented").tag("engine-oriented") - } - .disabled(!quickPreviewConvertOrientation) - - Toggle("Compress geometry (LZ4)", isOn: $quickPreviewCompressGeometry) - .help("Compresses vertex and index data with LZ4. Requires the Python lz4 package.") - if quickPreviewCompressGeometry { - Text("Requires: pip install lz4") - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 20) - } - - Toggle("Compress textures (ASTC)", isOn: $quickPreviewCompressTextures) - .help("Converts textures to GPU-native ASTC format. Requires astcenc and the Python Pillow package.") - if quickPreviewCompressTextures { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 12) { - Link("Install astcenc ->", destination: URL(string: "https://github.com/ARM-software/astc-encoder/releases")!) - .font(.caption) - Text("-") - .font(.caption) - .foregroundColor(.secondary) - Text("Also requires: pip install Pillow") - .font(.caption) - .foregroundColor(.secondary) - } - VStack(alignment: .leading, spacing: 4) { - Text("astcenc path (optional)") - .font(.caption) - .foregroundColor(.secondary) - HStack { - TextField("/opt/homebrew/bin/astcenc", text: $quickPreviewAstcencBinPath) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) - Button("Browse...") { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.title = "Select astcenc binary" - if panel.runModal() == .OK, let url = panel.url { - quickPreviewAstcencBinPath = url.path - } - } - } - } - } - .padding(.leading, 20) - } - } - - if isExportingQuickPreviewAsset { - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text("Exporting...") - .foregroundColor(.secondary) - } - } - - HStack { - Spacer() - Button("Cancel") { - pendingQuickPreviewExport = nil - } - .disabled(isExportingQuickPreviewAsset) - - Button("Export and Load") { - exportAndLoadQuickPreviewRuntimeAsset(request) - } - .keyboardShortcut(.defaultAction) - .disabled(isExportingQuickPreviewAsset) - } - } - .padding(20) - .frame(width: 560) - } - - private func exportAndLoadQuickPreviewRuntimeAsset(_ request: QuickPreviewRuntimeExportRequest) { - guard !isExportingQuickPreviewAsset else { return } - guard let exporterScript = findExportUntoldScript() else { - Logger.log(message: "❌ export-untold script not found. Expected at .build/checkouts/UntoldEngine/scripts/export-untold") - pendingQuickPreviewExport = nil - return - } - - isExportingQuickPreviewAsset = true - let convertOrientation = quickPreviewConvertOrientation - let sourceOrientation = quickPreviewSourceOrientation - let compressGeometry = quickPreviewCompressGeometry - let compressTextures = quickPreviewCompressTextures - let astcencBin = quickPreviewAstcencBinPath.trimmingCharacters(in: .whitespacesAndNewlines) - - DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - let tempDirectory = FileManager.default.temporaryDirectory - let outputLogURL = tempDirectory.appendingPathComponent("quick-preview-export-\(UUID().uuidString).out") - let errorLogURL = tempDirectory.appendingPathComponent("quick-preview-export-\(UUID().uuidString).err") - - do { - try FileManager.default.createDirectory(at: request.outputURL.deletingLastPathComponent(), withIntermediateDirectories: true) - if FileManager.default.fileExists(atPath: request.outputURL.path) { - try FileManager.default.removeItem(at: request.outputURL) - } - FileManager.default.createFile(atPath: outputLogURL.path, contents: nil) - FileManager.default.createFile(atPath: errorLogURL.path, contents: nil) - let outputHandle = try FileHandle(forWritingTo: outputLogURL) - let errorHandle = try FileHandle(forWritingTo: errorLogURL) - defer { - try? outputHandle.close() - try? errorHandle.close() - try? FileManager.default.removeItem(at: outputLogURL) - try? FileManager.default.removeItem(at: errorLogURL) - } - - process.executableURL = exporterScript - var arguments = [ - "--input", request.sourceURL.path, - "--output", request.outputURL.path, - ] - if convertOrientation { - arguments.append("--ConvertOrientation") - arguments.append(contentsOf: ["--source-orientation", sourceOrientation]) - } - if compressGeometry { - arguments.append("--compress-geometry") - } - process.arguments = arguments - process.standardOutput = outputHandle - process.standardError = errorHandle - - try process.run() - process.waitUntilExit() - - let stdout = (try? String(contentsOf: outputLogURL, encoding: .utf8)) ?? "" - let stderr = (try? String(contentsOf: errorLogURL, encoding: .utf8)) ?? "" - let exportSucceeded = process.terminationStatus == 0 - - DispatchQueue.main.async { - if !stdout.isEmpty { Logger.log(message: stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } - if !stderr.isEmpty { Logger.log(message: stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } - } - - if exportSucceeded, compressTextures { - let texturesDir = request.outputURL.deletingLastPathComponent().appendingPathComponent("Textures") - if FileManager.default.fileExists(atPath: texturesDir.path), - let texbakeScript = findTexbakeScript() - { - let bakeResult = runTexbakeStep(script: texbakeScript, arguments: ["--dir", texturesDir.path], astcencBin: astcencBin) - let patchResult = runTexbakeStep(script: texbakeScript, arguments: ["--patch-refs", request.outputURL.path], astcencBin: astcencBin) - DispatchQueue.main.async { - if !bakeResult.stdout.isEmpty { Logger.log(message: bakeResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } - if !bakeResult.stderr.isEmpty { Logger.log(message: bakeResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } - if !patchResult.stdout.isEmpty { Logger.log(message: patchResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) } - if !patchResult.stderr.isEmpty { Logger.log(message: patchResult.stderr.trimmingCharacters(in: .whitespacesAndNewlines)) } - if bakeResult.status != 0 || patchResult.status != 0 { - Logger.log(message: "⚠️ ASTC compression had errors — preview asset exported without compressed textures") - } - } - } else { - DispatchQueue.main.async { - Logger.log(message: "⚠️ ASTC skipped — texbake.py not found or no Textures folder present") - } - } - } - - DispatchQueue.main.async { - isExportingQuickPreviewAsset = false - pendingQuickPreviewExport = nil - if exportSucceeded { - editor_loadQuickPreviewAsset(from: request.outputURL, originalSourceURL: request.sourceURL) - } else { - QuickPreviewRuntimeExportCache.removeCacheDirectory(at: request.outputURL.deletingLastPathComponent()) - Logger.log(message: "❌ Quick Preview export failed for \(request.sourceURL.lastPathComponent)") - } - } - } catch { - DispatchQueue.main.async { - isExportingQuickPreviewAsset = false - pendingQuickPreviewExport = nil - QuickPreviewRuntimeExportCache.removeCacheDirectory(at: request.outputURL.deletingLastPathComponent()) - Logger.log(message: "❌ Quick Preview export failed: \(error)") - } - } - } - } - - private func editor_loadQuickPreviewAsset(from loadURL: URL, originalSourceURL: URL? = nil) { - let sourceURL = originalSourceURL ?? loadURL - let fileExtension = loadURL.pathExtension.lowercased() - let absolutePath = loadURL.path - let fileName = sourceURL.deletingPathExtension().lastPathComponent - - deleteExistingQuickPreviewEntities() - removeGizmo() - - let entityId = createEntity() - let uniqueName = "QuickPreview-\(fileName)-\(entityId)" - setEntityName(entityId: entityId, name: uniqueName) - - if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { - quickPreviewComp.absoluteFilePath = sourceURL.path - quickPreviewComp.fileExtension = sourceURL.pathExtension.lowercased() - quickPreviewComp.originalFileName = fileName - if originalSourceURL != nil { - quickPreviewComp.runtimePreviewDirectoryPath = loadURL.deletingLastPathComponent().path - } - } - - if fileExtension == "untold" { - clearSceneBatches() - GeometryStreamingSystem.shared.enabled = false - - setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in - if success { - print("✅ Quick Preview loaded: \(loadURL.lastPathComponent)") - } else { - print("⚠️ Failed to load Quick Preview, using fallback: \(loadURL.lastPathComponent)") - } - } - } else if fileExtension == "ply" { - clearSceneBatches() - GeometryStreamingSystem.shared.enabled = false - - setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) - print("✅ Quick Preview Gaussian loaded: \(loadURL.lastPathComponent)") - } - - guard let camera = CameraSystem.shared.activeCamera, - let cameraComponent = scene.get(component: CameraComponent.self, for: camera) - else { - handleError(.noActiveCamera) - return - } - - var forward = forwardDirectionVector(from: cameraComponent.rotation) - forward *= -1.0 - let camPosition = cameraComponent.localPosition - let spawnPosition = camPosition + forward * spawnDistance - translateTo(entityId: entityId, position: spawnPosition) - - selectionManager.selectedEntity = entityId - editor_entities = getAllGameEntities() - sceneGraphModel.refreshHierarchy() - - print("ℹ️ Quick Preview mode: File loaded with absolute path") - print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") - } - private func deleteExistingQuickPreviewEntities() { let previewEntityIds = getAllGameEntities() .filter { hasComponent(entityId: $0, componentType: QuickPreviewComponent.self) } @@ -1143,11 +825,6 @@ public struct EditorView: View { } for entityId in previewEntityIds { - if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId), - quickPreviewComp.runtimePreviewDirectoryPath.isEmpty == false - { - QuickPreviewRuntimeExportCache.removeCacheDirectory(at: URL(fileURLWithPath: quickPreviewComp.runtimePreviewDirectoryPath)) - } destroyEntity(entityId: entityId) } @@ -1192,11 +869,6 @@ public struct EditorView: View { private func deleteQuickPreviewEntitiesAndSave() { // Delete all Quick Preview entities for (entityId, entityName) in quickPreviewEntities { - if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId), - quickPreviewComp.runtimePreviewDirectoryPath.isEmpty == false - { - QuickPreviewRuntimeExportCache.removeCacheDirectory(at: URL(fileURLWithPath: quickPreviewComp.runtimePreviewDirectoryPath)) - } destroyEntity(entityId: entityId) print("🗑️ Deleted Quick Preview entity: \(entityName)") } diff --git a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift index 5f91b5c..851e0cd 100644 --- a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift +++ b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift @@ -19,7 +19,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var menuTitle: String { switch self { case .untoldAsset: - return "Load Untold Asset (.untold, USD)" + return "Load Untold Asset (.untold)" case .tiledScene: return "Load Tiled Stream (.json)" case .gaussian: @@ -41,7 +41,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var filePickerTitle: String { switch self { case .untoldAsset: - return "Load Preview - Select Untold or USD Asset" + return "Load Preview - Select Untold Asset" case .tiledScene: return "Load Preview - Select Tiled Stream Manifest" case .gaussian: @@ -52,7 +52,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var filePickerMessage: String { switch self { case .untoldAsset: - return "Select an Untold runtime asset or USD source asset to preview without creating a project" + 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: @@ -63,7 +63,7 @@ enum QuickPreviewImportMode: String, CaseIterable { var allowedContentTypes: [UTType] { switch self { case .untoldAsset: - return ["untold", "usd", "usda", "usdc", "usdz"].compactMap { UTType(filenameExtension: $0) } + return [UTType(filenameExtension: "untold") ?? .data] case .tiledScene: return [.json] case .gaussian: @@ -84,90 +84,15 @@ public class QuickPreviewComponent: Component { /// The original filename without extension public var originalFileName: String - /// Disposable temp cache directory used by converted quick-preview assets. - public var runtimePreviewDirectoryPath: String - public required init() { absoluteFilePath = "" fileExtension = "" originalFileName = "" - runtimePreviewDirectoryPath = "" } public init(absoluteFilePath: String, fileExtension: String, originalFileName: String) { self.absoluteFilePath = absoluteFilePath self.fileExtension = fileExtension self.originalFileName = originalFileName - runtimePreviewDirectoryPath = "" - } -} - -enum QuickPreviewRuntimeExportCache { - static let directoryName = "UntoldEditorQuickPreviewExports" - static let defaultStaleAge: TimeInterval = 24 * 60 * 60 - - static func rootDirectory(baseDirectory: URL = FileManager.default.temporaryDirectory) -> URL { - baseDirectory.appendingPathComponent(directoryName, isDirectory: true) - } - - static func cacheDirectory( - for sourceURL: URL, - exportID: UUID = UUID(), - rootDirectory: URL = rootDirectory() - ) -> URL { - let baseName = sanitizedFileName(sourceURL.deletingPathExtension().lastPathComponent) - return rootDirectory.appendingPathComponent("\(baseName)-\(exportID.uuidString)", isDirectory: true) - } - - static func outputURL(for sourceURL: URL, in cacheDirectory: URL) -> URL { - cacheDirectory - .appendingPathComponent(sanitizedFileName(sourceURL.deletingPathExtension().lastPathComponent)) - .appendingPathExtension("untold") - } - - static func pruneStaleCaches( - in rootDirectory: URL = rootDirectory(), - preserving preservedDirectories: Set = [], - olderThan staleAge: TimeInterval = defaultStaleAge, - now: Date = Date(), - fileManager: FileManager = .default - ) { - guard let contents = try? fileManager.contentsOfDirectory( - at: rootDirectory, - includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey], - options: [.skipsHiddenFiles] - ) else { - return - } - - let preservedPaths = Set(preservedDirectories.map(\.standardizedFileURL.path)) - for url in contents where preservedPaths.contains(url.standardizedFileURL.path) == false { - guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isDirectoryKey]), - let modifiedAt = values.contentModificationDate - else { - continue - } - - if now.timeIntervalSince(modifiedAt) > staleAge { - try? fileManager.removeItem(at: url) - } - } - } - - static func removeCacheDirectory(at url: URL, fileManager: FileManager = .default) { - let rootPath = rootDirectory().standardizedFileURL.path - let targetURL = url.standardizedFileURL - guard targetURL.path.hasPrefix(rootPath) else { - return - } - - try? fileManager.removeItem(at: targetURL) - } - - private static func sanitizedFileName(_ rawName: String) -> String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) - let scalars = rawName.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } - let sanitized = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_")) - return sanitized.isEmpty ? "QuickPreview" : sanitized } } diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index f14ed54..2f0b9af 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -272,9 +272,9 @@ import XCTest func test_quickPreviewModes_exposeExpectedPickerConfiguration() { XCTAssertEqual(QuickPreviewImportMode.allCases, [.untoldAsset, .tiledScene, .gaussian]) - XCTAssertEqual(QuickPreviewImportMode.untoldAsset.menuTitle, "Load Untold Asset (.untold, USD)") + XCTAssertEqual(QuickPreviewImportMode.untoldAsset.menuTitle, "Load Untold Asset (.untold)") let runtimePreviewExtensions = Set(QuickPreviewImportMode.untoldAsset.allowedContentTypes.compactMap(\.preferredFilenameExtension)) - XCTAssertTrue(runtimePreviewExtensions.isSuperset(of: ["untold", "usd", "usda", "usdc", "usdz"])) + XCTAssertEqual(runtimePreviewExtensions, ["untold"]) XCTAssertEqual(QuickPreviewImportMode.tiledScene.menuTitle, "Load Tiled Stream (.json)") XCTAssertEqual(QuickPreviewImportMode.tiledScene.allowedContentTypes, [.json]) @@ -283,60 +283,5 @@ import XCTest XCTAssertEqual(QuickPreviewImportMode.gaussian.allowedContentTypes.first?.preferredFilenameExtension, "ply") } - func test_quickPreviewRuntimeExportCache_buildsIsolatedUntoldOutputPath() throws { - let rootURL = FileManager.default.temporaryDirectory - .appendingPathComponent("QuickPreviewCacheTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: rootURL) } - - let sourceURL = URL(fileURLWithPath: "/Assets/Test Model.usdz") - let exportID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000123")) - let cacheDirectory = QuickPreviewRuntimeExportCache.cacheDirectory( - for: sourceURL, - exportID: exportID, - rootDirectory: rootURL - ) - let outputURL = QuickPreviewRuntimeExportCache.outputURL(for: sourceURL, in: cacheDirectory) - - XCTAssertEqual(cacheDirectory.deletingLastPathComponent(), rootURL) - XCTAssertEqual(cacheDirectory.lastPathComponent, "Test-Model-\(exportID.uuidString)") - XCTAssertEqual(outputURL.lastPathComponent, "Test-Model.untold") - XCTAssertEqual(outputURL.deletingLastPathComponent(), cacheDirectory) - } - - func test_quickPreviewRuntimeExportCache_prunesOnlyStaleUnpreservedEntries() throws { - let fileManager = FileManager.default - let rootURL = fileManager.temporaryDirectory - .appendingPathComponent("QuickPreviewCacheTests-\(UUID().uuidString)", isDirectory: true) - defer { try? fileManager.removeItem(at: rootURL) } - - let staleURL = rootURL.appendingPathComponent("stale", isDirectory: true) - let freshURL = rootURL.appendingPathComponent("fresh", isDirectory: true) - let preservedURL = rootURL.appendingPathComponent("preserved", isDirectory: true) - let staleFileURL = rootURL.appendingPathComponent("old-preview.untold") - try fileManager.createDirectory(at: staleURL, withIntermediateDirectories: true) - try fileManager.createDirectory(at: freshURL, withIntermediateDirectories: true) - try fileManager.createDirectory(at: preservedURL, withIntermediateDirectories: true) - _ = fileManager.createFile(atPath: staleFileURL.path, contents: Data()) - - let now = Date(timeIntervalSince1970: 1_000_000) - let staleDate = now.addingTimeInterval(-QuickPreviewRuntimeExportCache.defaultStaleAge - 10) - let freshDate = now.addingTimeInterval(-10) - try fileManager.setAttributes([.modificationDate: staleDate], ofItemAtPath: staleURL.path) - try fileManager.setAttributes([.modificationDate: freshDate], ofItemAtPath: freshURL.path) - try fileManager.setAttributes([.modificationDate: staleDate], ofItemAtPath: preservedURL.path) - try fileManager.setAttributes([.modificationDate: staleDate], ofItemAtPath: staleFileURL.path) - - QuickPreviewRuntimeExportCache.pruneStaleCaches( - in: rootURL, - preserving: [preservedURL], - now: now, - fileManager: fileManager - ) - - XCTAssertFalse(fileManager.fileExists(atPath: staleURL.path)) - XCTAssertFalse(fileManager.fileExists(atPath: staleFileURL.path)) - XCTAssertTrue(fileManager.fileExists(atPath: freshURL.path)) - XCTAssertTrue(fileManager.fileExists(atPath: preservedURL.path)) - } } #endif From 056060ff923a17bec1651ea76f7d45f323d3b10d Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 22 May 2026 07:29:05 -0700 Subject: [PATCH 11/12] Release 0.12.14 --- CHANGELOG.md | 16 ++++++++++++++++ Package.swift | 2 +- Sources/UntoldEditor/Editor/ToolbarView.swift | 2 +- Sources/UntoldEditor/main.swift | 4 ++-- 4 files changed, 20 insertions(+), 4 deletions(-) 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/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index db4f95e..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 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()) From 28e5f38bec4e79f94dbd7a26e17c4617ddc324cf Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 22 May 2026 07:29:38 -0700 Subject: [PATCH 12/12] [Chores] formmated files --- Tests/UntoldEditorTests/ToolbarViewTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index 2f0b9af..ba4d2b7 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -282,6 +282,5 @@ import XCTest XCTAssertEqual(QuickPreviewImportMode.gaussian.menuTitle, "Load Gaussian (.ply)") XCTAssertEqual(QuickPreviewImportMode.gaussian.allowedContentTypes.first?.preferredFilenameExtension, "ply") } - } #endif