diff --git a/Package.swift b/Package.swift
index 82ebf2d..8038f5e 100644
--- a/Package.swift
+++ b/Package.swift
@@ -13,7 +13,7 @@ let package = Package(
// Use a branch during active development:
// .package(url: "https://github.com/untoldengine/UntoldEngine.git", branch: "develop"),
// Or pin to a release:
- .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.6.2"),
+ .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.7.0"),
],
targets: [
.executableTarget(
diff --git a/Sources/UntoldEditor/Build/BuildSettingsView.swift b/Sources/UntoldEditor/Build/CreateProjectView.swift
similarity index 77%
rename from Sources/UntoldEditor/Build/BuildSettingsView.swift
rename to Sources/UntoldEditor/Build/CreateProjectView.swift
index 475e642..2d0dc3f 100644
--- a/Sources/UntoldEditor/Build/BuildSettingsView.swift
+++ b/Sources/UntoldEditor/Build/CreateProjectView.swift
@@ -1,5 +1,5 @@
//
-// BuildSettingsView.swift
+// CreateProjectView.swift
// UntoldEditor
//
// Copyright (C) Untold Engine Studios
@@ -10,7 +10,7 @@
import SwiftUI
import UntoldEngine
-struct BuildSettingsView: View {
+struct CreateProjectView: View {
@State private var projectName: String = "MyGame"
@State private var bundleIdentifier: String = "com.yourcompany.mygame"
@State private var selectedTarget: Int = 0
@@ -25,11 +25,11 @@ struct BuildSettingsView: View {
@State private var showBuildResult: Bool = false
@State private var buildResultMessage: String = ""
@State private var buildSucceeded: Bool = false
- @State private var showBasePathAlert: Bool = false
+ @State private var resultProjectPath: URL?
@Environment(\.dismiss) private var dismiss
- private let targets = ["macOS", "iOS", "visionOS"]
+ private let targets = ["macOS", "iOS", "iOS AR", "visionOS"]
private let macOSVersions = ["13.0", "14.0", "15.0"]
private let optimizationLevels = ["None", "Speed", "Size"]
@@ -37,7 +37,7 @@ struct BuildSettingsView: View {
VStack(spacing: 0) {
// Header
HStack {
- Text("Build Settings")
+ Text("Create New Project")
.font(.title2)
.fontWeight(.semibold)
Spacer()
@@ -119,11 +119,10 @@ struct BuildSettingsView: View {
.background(Color(NSColor.controlBackgroundColor))
}
- // Build Button
+ // Create Button
HStack {
Spacer()
- Button("Build") {
- guard ensureAssetBasePath() else { return }
+ Button("Create") {
startBuild()
}
.buttonStyle(.borderedProminent)
@@ -133,26 +132,27 @@ struct BuildSettingsView: View {
.background(Color(NSColor.controlBackgroundColor))
}
.frame(width: 600, height: 550)
- .alert("Build Complete", isPresented: $showBuildResult) {
+ .alert("Project Created", isPresented: $showBuildResult) {
if buildSucceeded {
Button("Open in Finder") {
openBuildOutput()
+ dismiss()
}
Button("Open in Xcode") {
openInXcode()
+ dismiss()
+ }
+ Button("OK", role: .cancel) {
+ dismiss()
}
- Button("OK", role: .cancel) {}
} else {
- Button("OK", role: .cancel) {}
+ Button("OK", role: .cancel) {
+ dismiss()
+ }
}
} message: {
Text(buildResultMessage)
}
- .alert("Set Asset Folder First", isPresented: $showBasePathAlert) {
- Button("OK", role: .cancel) {}
- } message: {
- Text("Please set the Asset Folder in the Asset Browser before building.")
- }
.onAppear {
loadDefaultSettings()
}
@@ -171,7 +171,7 @@ struct BuildSettingsView: View {
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
- panel.message = "Choose build output directory"
+ panel.message = "Choose project output directory"
if panel.runModal() == .OK {
outputPath = panel.url?.path ?? outputPath
@@ -180,7 +180,7 @@ struct BuildSettingsView: View {
private func startBuild() {
isBuilding = true
- buildProgress = "Preparing build..."
+ buildProgress = "Creating project..."
Task {
do {
@@ -195,20 +195,33 @@ struct BuildSettingsView: View {
await MainActor.run {
isBuilding = false
buildSucceeded = true
+ resultProjectPath = result.xcodeProjectPath.deletingLastPathComponent()
buildResultMessage = """
- Build completed successfully in \(String(format: "%.2f", result.buildTime))s
+ Project created successfully in \(String(format: "%.2f", result.buildTime))s
- Project: \(result.xcodeProjectPath.path)
+ Location: \(result.xcodeProjectPath.path)
Assets: \(result.bundledAssets.count) files
"""
showBuildResult = true
+
+ // Set assetBasePath to the newly created GameData folder
+ // This allows the editor to immediately save scenes to the correct location
+ let projectDir = result.xcodeProjectPath.deletingLastPathComponent()
+ let gameDataPath = projectDir
+ .appendingPathComponent("Sources")
+ .appendingPathComponent(settings.projectName)
+ .appendingPathComponent("GameData")
+
+ assetBasePath = gameDataPath
+ EditorAssetBasePath.shared.basePath = gameDataPath
+ Logger.log(message: "📁 Asset base path set to: \(gameDataPath.path)")
}
} catch {
await MainActor.run {
isBuilding = false
buildSucceeded = false
- buildResultMessage = "Build failed: \(error.localizedDescription)"
+ buildResultMessage = "Project creation failed: \(error.localizedDescription)"
showBuildResult = true
}
}
@@ -217,6 +230,8 @@ struct BuildSettingsView: View {
private func createBuildSettings() -> BuildSettings {
let target: BuildTarget
+ let isIOSAR = (selectedTarget == 2) // iOS AR
+
switch selectedTarget {
case 0: // macOS
let version: MacOSVersion
@@ -229,8 +244,10 @@ struct BuildSettingsView: View {
target = .macOS(deployment: version)
case 1: // iOS
target = .iOS(deployment: .v17)
- case 2: // visionOS
- target = .visionOS(deployment: .v1)
+ case 2: // iOS AR
+ target = .iOS(deployment: .v17)
+ case 3: // visionOS
+ target = .visionOS(deployment: .v26)
default:
target = .macOS(deployment: .v15)
}
@@ -251,13 +268,18 @@ struct BuildSettingsView: View {
scenes: [], // Will be populated by BuildSystem
includeDebugInfo: includeDebugInfo,
optimizationLevel: optimization,
- teamID: teamID.isEmpty ? nil : teamID
+ teamID: teamID.isEmpty ? nil : teamID,
+ isIOSAR: isIOSAR
)
}
private func openBuildOutput() {
- let buildPath = URL(fileURLWithPath: outputPath).appendingPathComponent(projectName)
- NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: buildPath.path)
+ if let projectPath = resultProjectPath {
+ NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: projectPath.path)
+ } else {
+ let buildPath = URL(fileURLWithPath: outputPath).appendingPathComponent(projectName)
+ NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: buildPath.path)
+ }
}
private func openInXcode() {
@@ -267,16 +289,8 @@ struct BuildSettingsView: View {
NSWorkspace.shared.open(xcodeProjectPath)
}
-
- private func ensureAssetBasePath() -> Bool {
- guard EditorAssetBasePath.shared.basePath != nil else {
- showBasePathAlert = true
- return false
- }
- return true
- }
}
#Preview {
- BuildSettingsView()
+ CreateProjectView()
}
diff --git a/Sources/UntoldEditor/Config/EditorFeatureFlags.swift b/Sources/UntoldEditor/Config/EditorFeatureFlags.swift
new file mode 100644
index 0000000..8f7d43d
--- /dev/null
+++ b/Sources/UntoldEditor/Config/EditorFeatureFlags.swift
@@ -0,0 +1,36 @@
+//
+// EditorFeatureFlags.swift
+// UntoldEditor
+//
+// Copyright (C) Untold Engine Studios
+// Licensed under the GNU LGPL v3.0 or later.
+// See the LICENSE file or for details.
+//
+
+import Foundation
+
+/// Feature flags for controlling experimental or deprecated features in the editor
+enum EditorFeatureFlags {
+ // MARK: - Build System Features
+
+ /// Enable the Build button in the toolbar
+ /// When disabled, users should use the `untoldengine-create` CLI tool instead
+ static let enableBuildButton: Bool = true
+
+ // MARK: - Script Management Features
+
+ /// Enable script creation and management buttons in the toolbar
+ /// When disabled, users manage Swift code directly in their game project
+ static let enableScriptButtons: Bool = true
+
+ /// Enable the Script Component in the Inspector
+ /// When disabled, users write game logic in Swift within their game project
+ static let enableScriptComponent: Bool = true
+
+ // MARK: - Future Use Cases
+
+ // When these might be re-enabled:
+ // - Build button: If we add specialized build configurations not available via CLI
+ // - Script buttons: If we implement visual scripting / Blueprint-like system
+ // - Script component: If we add hot-reloading or modding support via USC scripts
+}
diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift
index a20cfe7..6da3289 100644
--- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift
+++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift
@@ -114,44 +114,15 @@ struct AssetBrowserView: View {
.frame(maxWidth: 240)
Spacer()
-
- // Set Base Path Button
- Button(action: selectResourceDirectory) {
- HStack(spacing: 6) {
- Image(systemName: "externaldrive.fill.badge.plus")
- .foregroundColor(.white)
- Text("Asset Folder")
- .fontWeight(.semibold)
- }
- .padding(.vertical, 6)
- .padding(.horizontal, 12)
- .background(Color.editorSurface)
- .foregroundColor(.white)
- .cornerRadius(8)
- .shadow(color: Color.black.opacity(0.2), radius: 4, x: 0, y: 2)
- }
- .buttonStyle(PlainButtonStyle())
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.editorPanelBackground.opacity(0.9))
.cornerRadius(8)
- // MARK: - Path Indicator
+ // MARK: - Target Entity Indicator
HStack(spacing: 12) {
- if let resourceDir = editorBaseAssetPath.basePath {
- Text("Current Path: \(resourceDir.lastPathComponent)")
- .font(.caption)
- .foregroundColor(Color.editorAccent)
- } else {
- Text("No Path Selected")
- .font(.caption)
- .foregroundColor(.red)
- }
-
- Spacer()
-
Text("Target Entity:")
.font(.caption)
.foregroundColor(.secondary)
@@ -159,6 +130,8 @@ struct AssetBrowserView: View {
.font(.caption)
.foregroundColor(.white)
.lineLimit(1)
+
+ Spacer()
}
.padding(.horizontal, 10)
.padding(.bottom, 5)
@@ -312,10 +285,10 @@ struct AssetBrowserView: View {
} message: {
Text("Loading a new scene will replace the current scene. Any unsaved changes will be lost.")
}
- .alert("Set Asset Folder First", isPresented: $showBasePathAlert) {
+ .alert("No Project Loaded", isPresented: $showBasePathAlert) {
Button("OK", role: .cancel) {}
} message: {
- Text("Please set the Asset Folder in the Asset Browser before importing assets.")
+ Text("Please create a new project or open an existing project before importing assets.")
}
.alert("Delete Asset?", isPresented: $showDeleteConfirmation) {
Button("Cancel", role: .cancel) {
@@ -347,32 +320,6 @@ struct AssetBrowserView: View {
}
}
- // MARK: - Select Resource Directory
-
- private func selectResourceDirectory() {
- let panel = NSOpenPanel()
- panel.canChooseFiles = false
- panel.canChooseDirectories = true
- panel.allowsMultipleSelection = false
- panel.canCreateDirectories = true
-
- if panel.runModal() == .OK, let selectedURL = panel.urls.first {
- // Create the required folder structure if it doesn't exist
- let fm = FileManager.default
- let requiredFolders = ["Models", "Animations", "HDR", "Gaussians", "Materials", "Scenes", "Scripts"]
-
- for folder in requiredFolders {
- let folderURL = selectedURL.appendingPathComponent(folder, isDirectory: true)
- if !fm.fileExists(atPath: folderURL.path) {
- try? fm.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
- }
- }
-
- assetBasePath = selectedURL
- EditorAssetBasePath.shared.basePath = assetBasePath
- }
- }
-
private func importAsset() {
guard editorBaseAssetPath.basePath != nil else {
showBasePathAlert = true
@@ -474,7 +421,7 @@ struct AssetBrowserView: View {
}
loadAssets()
- showStatus("Imported \(openPanel.urls.count) item(s)")
+ showStatus("Queued import of \(openPanel.urls.count) item(s) (see Console)")
}
}
@@ -706,10 +653,10 @@ struct AssetBrowserView: View {
selectedAssetName = nil
}
loadAssets()
- showStatus("Deleted \(asset.name)")
+ showStatus("Queued delete: \(asset.name) (see Console)")
} catch {
print("❌ Failed to delete asset \(asset.name): \(error)")
- showStatus("Failed to delete \(asset.name)", isError: true)
+ showStatus("Delete failed for \(asset.name) (see Console)", isError: true)
}
}
@@ -733,16 +680,21 @@ struct AssetBrowserView: View {
let uniqueName = generateEntityName()
setEntityName(entityId: entityId, name: uniqueName)
- // Add mesh to entity
- setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension)
-
- // Refresh the scene hierarchy to show the new entity
- sceneGraphModel.refreshHierarchy()
+ // Add mesh to entity asynchronously
+ setEntityMeshAsync(entityId: entityId, filename: filename, withExtension: withExtension) { success in
+ if success {
+ print("✅ Model imported: \(uniqueName)")
+ } else {
+ print("⚠️ Failed to load model, using fallback: \(uniqueName)")
+ }
+ // Refresh scene hierarchy after loading completes
+ sceneGraphModel.refreshHierarchy()
+ }
// Select the newly created entity in the editor
selectionManager.selectedEntity = entityId
- showStatus("Added model \(uniqueName)")
+ showStatus("Importing model: \(uniqueName)...")
}
// Handle Gaussian files (ply)
else if asset.category == AssetCategory.gaussians.rawValue,
@@ -764,7 +716,7 @@ struct AssetBrowserView: View {
// Select the newly created entity in the editor
selectionManager.selectedEntity = entityId
- showStatus("Added Gaussian \(uniqueName)")
+ showStatus("Queued Gaussian import: \(uniqueName) (see Console)")
}
// Handle Animation files (usdz in Animations category)
else if asset.category == AssetCategory.animations.rawValue,
@@ -794,7 +746,7 @@ struct AssetBrowserView: View {
// Refresh view
selectionManager.objectWillChange.send()
- showStatus("Animation linked to \(targetEntityName)", isError: false)
+ showStatus("Queued animation link to \(targetEntityName) (see Console)")
}
// Handle Script files (uscript)
else if asset.category == AssetCategory.scripts.rawValue,
@@ -838,7 +790,7 @@ struct AssetBrowserView: View {
// Refresh view
selectionManager.objectWillChange.send()
- showStatus("Script linked to \(targetEntityName)", isError: false)
+ showStatus("Queued script link to \(targetEntityName) (see Console)")
} catch {
print("❌ Failed to load script: \(error.localizedDescription)")
}
@@ -856,13 +808,27 @@ struct AssetBrowserView: View {
else if asset.category == AssetCategory.hdr.rawValue,
withExtension.lowercased() == "hdr"
{
+ // Verify HDR file exists before attempting to load
+ guard FileManager.default.fileExists(atPath: asset.path.path) else {
+ Logger.log(message: "⚠️ HDR file not found: \(asset.path.path)")
+ showStatus("HDR file not found", isError: true)
+ return
+ }
+
// Load HDR as environment IBL
let filename = asset.path.lastPathComponent
let directoryURL = asset.path.deletingLastPathComponent()
generateHDR(filename, from: directoryURL)
- print("✅ HDR environment loaded: \(filename)")
- showStatus("HDR loaded: \(filename)")
+ // Only enable IBL if HDR was successfully loaded
+ if iblSuccessful {
+ applyIBL = true
+ print("✅ HDR environment loaded and IBL enabled: \(filename)")
+ showStatus("HDR loaded and IBL enabled: \(filename)")
+ } else {
+ print("⚠️ Failed to load HDR: \(filename)")
+ showStatus("Failed to load HDR: \(filename)", isError: true)
+ }
}
}
diff --git a/Sources/UntoldEditor/Editor/EditorController.swift b/Sources/UntoldEditor/Editor/EditorController.swift
index f3f3c2b..6a5a5ee 100644
--- a/Sources/UntoldEditor/Editor/EditorController.swift
+++ b/Sources/UntoldEditor/Editor/EditorController.swift
@@ -41,22 +41,26 @@ public class EditorAssetBasePath: ObservableObject {
}
}
+ // Extract project name from the GameData path
+ // e.g., /path/to/MyGame/Sources/MyGame/GameData -> "MyGame"
+ public var projectName: String? {
+ guard let basePath else { return nil }
+
+ // GameData is at: ProjectRoot/Sources/ProjectName/GameData
+ // So we go up 3 levels to get ProjectRoot
+ let projectRoot = basePath.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ return projectRoot.lastPathComponent
+ }
+
init() {
- // Load persisted path from UserDefaults on startup
- if let savedPath = UserDefaults.standard.string(forKey: userDefaultsKey) {
- let url = URL(fileURLWithPath: savedPath)
- // Verify the directory still exists
- if FileManager.default.fileExists(atPath: url.path) {
- basePath = url
- assetBasePath = url
- } else {
- // Path no longer exists, clear it
- UserDefaults.standard.removeObject(forKey: userDefaultsKey)
- basePath = nil
- }
- } else {
- basePath = assetBasePath
- }
+ // With the new workflow, editor should start with no asset path
+ // The path will be set when user creates a new project via "New" button
+ // or manually sets it via "Asset Folder" button
+ basePath = nil
+ assetBasePath = nil
+
+ // Clear any previously persisted path since we're changing the workflow
+ UserDefaults.standard.removeObject(forKey: userDefaultsKey)
}
}
diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift
index 44306d9..c05eb4d 100644
--- a/Sources/UntoldEditor/Editor/EditorView.swift
+++ b/Sources/UntoldEditor/Editor/EditorView.swift
@@ -23,6 +23,7 @@ public struct EditorView: View {
@State private var pendingTargetURL: URL?
@State private var isSaveAs = false
@State private var showSaveBasePathAlert = false
+ @State private var useSceneCameraDuringPlay = false
var renderer: UntoldRenderer?
@@ -44,99 +45,119 @@ public struct EditorView: View {
}
public var body: some View {
- VStack {
- ToolbarView(
- selectionManager: selectionManager,
- onSave: editor_handleSave,
- onSaveAs: editor_handleSaveAs,
- onClear: editor_clearScene,
- onPlayToggled: { isPlaying in
- editor_handlePlayToggle(isPlaying)
- },
- dirLightCreate: editor_createDirLight,
- pointLightCreate: editor_createPointLight,
- spotLightCreate: editor_createSpotLight,
- areaLightCreate: editor_createAreaLight,
- onCreateCube: editor_createCube,
- onCreateSphere: editor_createSphere,
- onCreatePlane: editor_createPlane,
- onCreateCylinder: editor_createCylinder,
- onCreateCone: editor_createCone
- )
- Divider()
- HStack {
- VStack {
- SceneHierarchyView(
- selectionManager: selectionManager,
- sceneGraphModel: sceneGraphModel,
- entityList: editor_entities,
- onAddEntity_Editor: editor_addNewEntity,
- onRemoveEntity_Editor: editor_removeEntity,
- onAddCube: editor_createCube,
- onAddSphere: editor_createSphere,
- onAddPlane: editor_createPlane,
- onAddDirLight: editor_createDirLight,
- onAddPointLight: editor_createPointLight,
- onAddSpotLight: editor_createSpotLight,
- onAddAreaLight: editor_createAreaLight
- )
- }
-
- VStack(spacing: 0) {
- EditorSceneView(renderer: renderer!)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- TransformManipulationToolbar(controller: editorController!)
- .frame(height: 40)
- TabView {
- AssetBrowserView(
- assets: $assets,
- selectedAsset: $selectedAsset,
+ ZStack {
+ VStack {
+ ToolbarView(
+ selectionManager: selectionManager,
+ onSave: editor_handleSave,
+ onSaveAs: editor_handleSaveAs,
+ onClear: editor_clearScene,
+ onPlayToggled: { isPlaying in
+ editor_handlePlayToggle(isPlaying)
+ },
+ useSceneCameraDuringPlay: $useSceneCameraDuringPlay,
+ dirLightCreate: editor_createDirLight,
+ pointLightCreate: editor_createPointLight,
+ spotLightCreate: editor_createSpotLight,
+ areaLightCreate: editor_createAreaLight,
+ onCreateCube: editor_createCube,
+ onCreateSphere: editor_createSphere,
+ onCreatePlane: editor_createPlane,
+ onCreateCylinder: editor_createCylinder,
+ onCreateCone: editor_createCone
+ )
+ Divider()
+ HStack {
+ VStack {
+ SceneHierarchyView(
selectionManager: selectionManager,
sceneGraphModel: sceneGraphModel,
- editor_addEntityWithAsset: editor_addEntityWithAsset
+ entityList: editor_entities,
+ onAddEntity_Editor: editor_addNewEntity,
+ onRemoveEntity_Editor: editor_removeEntity,
+ onAddCube: editor_createCube,
+ onAddSphere: editor_createSphere,
+ onAddPlane: editor_createPlane,
+ onAddDirLight: editor_createDirLight,
+ onAddPointLight: editor_createPointLight,
+ onAddSpotLight: editor_createSpotLight,
+ onAddAreaLight: editor_createAreaLight
)
- .tabItem { Label("Assets", systemImage: "shippingbox") }
-
- LogConsoleView()
- .tabItem { Label("Console", systemImage: "terminal") }
}
- .frame(height: 200)
- .clipped()
- }
- TabView {
- EnvironmentView(selectedAsset: $selectedAsset)
- .tabItem {
- Label("Environment", systemImage: "sun.max")
+ VStack(spacing: 0) {
+ EditorSceneView(renderer: renderer!)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ TransformManipulationToolbar(controller: editorController!)
+ .frame(height: 40)
+ TabView {
+ AssetBrowserView(
+ assets: $assets,
+ selectedAsset: $selectedAsset,
+ selectionManager: selectionManager,
+ sceneGraphModel: sceneGraphModel,
+ editor_addEntityWithAsset: editor_addEntityWithAsset
+ )
+ .tabItem { Label("Assets", systemImage: "shippingbox") }
+
+ LogConsoleView()
+ .tabItem { Label("Console", systemImage: "terminal") }
}
+ .frame(height: 200)
+ .clipped()
+ }
+
+ TabView {
+ EnvironmentView(selectedAsset: $selectedAsset)
+ .tabItem {
+ Label("Environment", systemImage: "sun.max")
+ }
+
+ PostProcessingEditorView()
+ .tabItem {
+ Label("Effects", systemImage: "cube")
+ }
- PostProcessingEditorView()
+ InspectorView(
+ selectionManager: selectionManager,
+ sceneGraphModel: sceneGraphModel,
+ onAddName_Editor: editor_addName,
+ selectedAsset: $selectedAsset
+ )
.tabItem {
- Label("Effects", systemImage: "cube")
+ Label("Inspector", systemImage: "cube")
}
-
- InspectorView(
- selectionManager: selectionManager,
- sceneGraphModel: sceneGraphModel,
- onAddName_Editor: editor_addName,
- selectedAsset: $selectedAsset
- )
- .tabItem {
- Label("Inspector", systemImage: "cube")
}
+ .frame(minWidth: 200, maxWidth: 250)
}
- .frame(minWidth: 200, maxWidth: 250)
}
+ .background(
+ LinearGradient(
+ colors: [Color.editorBackground, Color.editorPanelBackground.opacity(0.95)],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .ignoresSafeArea())
+
+ // Loading indicator overlay
+ LoadingIndicatorView()
+ .allowsHitTesting(false)
}
- .background(
- LinearGradient(
- colors: [Color.editorBackground, Color.editorPanelBackground.opacity(0.95)],
- startPoint: .top,
- endPoint: .bottom
- )
- .ignoresSafeArea())
.onAppear {
sceneGraphModel.refreshHierarchy()
+
+ // Listen for asset instance loading completion
+ NotificationCenter.default.addObserver(
+ forName: .assetInstanceDidLoad,
+ object: nil,
+ queue: .main
+ ) { _ in
+ // Refresh hierarchy when async asset instances finish loading
+ sceneGraphModel.refreshHierarchy()
+ }
+ }
+ .onChange(of: useSceneCameraDuringPlay) { _, _ in
+ updateActiveCameraForPlayMode()
}
.sheet(isPresented: $showSaveNamePrompt) {
VStack(spacing: 12) {
@@ -171,10 +192,10 @@ public struct EditorView: View {
} message: {
Text("A scene with that name already exists. Overwrite it?")
}
- .alert("Set Asset Folder First", isPresented: $showSaveBasePathAlert) {
+ .alert("No Project Loaded", isPresented: $showSaveBasePathAlert) {
Button("OK", role: .cancel) {}
} message: {
- Text("Please set the Asset Folder in the Asset Browser before saving scenes.")
+ Text("Please create a new project or open an existing project before saving scenes.")
}
}
@@ -395,8 +416,7 @@ public struct EditorView: View {
private func editor_handlePlayToggle(_ isPlaying: Bool) {
self.isPlaying = isPlaying
gameMode = !gameMode
- // For now, during "play" mode, the camera will keep being the scene camera
- CameraSystem.shared.activeCamera = gameMode ? findGameCamera() : findSceneCamera()
+ updateActiveCameraForPlayMode()
AnimationSystem.shared.isEnabled = isPlaying
// Start/stop USC System
@@ -407,6 +427,14 @@ public struct EditorView: View {
}
}
+ private func updateActiveCameraForPlayMode() {
+ if gameMode {
+ CameraSystem.shared.activeCamera = useSceneCameraDuringPlay ? findSceneCamera() : findGameCamera()
+ } else {
+ CameraSystem.shared.activeCamera = findSceneCamera()
+ }
+ }
+
private func editor_createDirLight() {
let entityId = createEntity()
@@ -464,7 +492,18 @@ public struct EditorView: View {
let filename = selectedAsset?.path.deletingPathExtension().lastPathComponent
let withExtension = selectedAsset?.path.pathExtension
- setEntityMesh(entityId: selectionManager.selectedEntity!, filename: filename!, withExtension: withExtension!)
+
+ guard let entityId = selectionManager.selectedEntity,
+ let fname = filename,
+ let ext = withExtension else { return }
+
+ setEntityMeshAsync(entityId: entityId, filename: fname, withExtension: ext) { success in
+ if success {
+ print("✅ Asset loaded: \(fname).\(ext)")
+ } else {
+ print("⚠️ Failed to load asset, using fallback: \(fname).\(ext)")
+ }
+ }
guard let camera = CameraSystem.shared.activeCamera, let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else {
handleError(.noActiveCamera)
diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift
index fd7cfad..28f78c4 100644
--- a/Sources/UntoldEditor/Editor/EnvironmentView.swift
+++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift
@@ -17,7 +17,23 @@ func addIBL(asset: Asset?) {
if let asset, selectedCategory.rawValue == asset.category {
let filename = asset.path.lastPathComponent
let directoryURL = asset.path.deletingLastPathComponent()
+
+ // Verify HDR file exists before attempting to load
+ let hdrPath = directoryURL.appendingPathComponent(filename)
+ guard FileManager.default.fileExists(atPath: hdrPath.path) else {
+ Logger.log(message: "⚠️ HDR file not found: \(hdrPath.path)")
+ return
+ }
+
generateHDR(filename, from: directoryURL)
+
+ // Only enable IBL if HDR was successfully loaded
+ if iblSuccessful {
+ applyIBL = true
+ Logger.log(message: "✅ IBL enabled with HDR: \(filename)")
+ } else {
+ Logger.log(message: "⚠️ Failed to enable IBL - HDR loading failed")
+ }
}
}
diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift
index 7f85a42..2c20b14 100644
--- a/Sources/UntoldEditor/Editor/InspectorView.swift
+++ b/Sources/UntoldEditor/Editor/InspectorView.swift
@@ -39,7 +39,13 @@ private func onAddMesh_Editor(entityId: EntityID, url: URL) {
let filename = url.deletingPathExtension().lastPathComponent
let withExtension = url.pathExtension
- setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension)
+ setEntityMeshAsync(entityId: entityId, filename: filename, withExtension: withExtension) { success in
+ if success {
+ print("✅ Mesh loaded: \(filename).\(withExtension)")
+ } else {
+ print("⚠️ Failed to load mesh, using fallback: \(filename).\(withExtension)")
+ }
+ }
}
private func onAddAnimation_Editor(entityId: EntityID, url: URL) {
@@ -153,17 +159,19 @@ var availableComponents_Editor: [ComponentOption_Editor] = [
}
)
}),
- ComponentOption_Editor(id: getComponentId(for: ScriptComponent.self), name: "Script Component", type: ScriptComponent.self, view: { selectedId, asset, refreshView in
- AnyView(
- Group {
- if let entityId = selectedId {
- ScriptComponentInspector(entityId: entityId, asset: asset, refreshView: refreshView)
- }
- }
- )
- }),
]
+// Script Component - controlled by feature flag
+var scriptComponent_Editor: ComponentOption_Editor = .init(id: getComponentId(for: ScriptComponent.self), name: "Script Component", type: ScriptComponent.self, view: { selectedId, asset, refreshView in
+ AnyView(
+ Group {
+ if let entityId = selectedId {
+ ScriptComponentInspector(entityId: entityId, asset: asset, refreshView: refreshView)
+ }
+ }
+ )
+})
+
func mergeEntityComponents(
selectedEntity: EntityID?,
editor_availableComponents: [ComponentOption_Editor]
@@ -174,7 +182,13 @@ func mergeEntityComponents(
let existingComponentIDs: [Int] = getAllEntityComponentsIds(entityId: entityId)
- let matchingComponents = editor_availableComponents.filter { existingComponentIDs.contains($0.id) }
+ // Include script component if feature flag is enabled
+ var allComponents = editor_availableComponents
+ if EditorFeatureFlags.enableScriptComponent {
+ allComponents.append(scriptComponent_Editor)
+ }
+
+ let matchingComponents = allComponents.filter { existingComponentIDs.contains($0.id) }
for match in matchingComponents {
let key = ObjectIdentifier(match.type)
@@ -304,7 +318,7 @@ struct InspectorView: View {
.font(.headline)
.padding()
- List(availableComponents_Editor, id: \.id) { component in
+ List(availableComponentsWithFlags(), id: \.id) { component in
Button(action: {
addComponentToEntity_Editor(componentType: component.type)
showComponentSelection = false
@@ -402,6 +416,14 @@ struct InspectorView: View {
selectionManager.objectWillChange.send()
sceneGraphModel.refreshHierarchy()
}
+
+ private func availableComponentsWithFlags() -> [ComponentOption_Editor] {
+ var components = availableComponents_Editor
+ if EditorFeatureFlags.enableScriptComponent {
+ components.append(scriptComponent_Editor)
+ }
+ return components
+ }
}
/*
diff --git a/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift b/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift
new file mode 100644
index 0000000..bee2276
--- /dev/null
+++ b/Sources/UntoldEditor/Editor/LoadingIndicatorView.swift
@@ -0,0 +1,141 @@
+//
+// LoadingIndicatorView.swift
+// UntoldEditor
+//
+// Copyright (C) Untold Engine Studios
+// Licensed under the GNU LGPL v3.0 or later.
+// See the LICENSE file or for details.
+//
+
+import SwiftUI
+import UntoldEngine
+
+/// SwiftUI view that displays asset loading progress
+public struct LoadingIndicatorView: View {
+ @State private var isLoading = false
+ @State private var loadingSummary = "Loading..."
+ @State private var currentProgress: Float = 0.0
+ @State private var totalCount = 0
+
+ // Timer to poll loading state
+ private let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
+
+ public init() {}
+
+ public var body: some View {
+ Group {
+ if isLoading {
+ VStack(spacing: 0) {
+ Spacer()
+
+ HStack {
+ Spacer()
+
+ VStack(spacing: 12) {
+ // Progress spinner
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ .scaleEffect(0.8)
+
+ // Loading text
+ Text(loadingSummary)
+ .font(.system(size: 12))
+ .foregroundColor(.white)
+
+ // Progress bar if we have total count
+ if totalCount > 0 {
+ ProgressView(value: currentProgress, total: 1.0)
+ .progressViewStyle(LinearProgressViewStyle())
+ .frame(width: 200)
+
+ Text("\(Int(currentProgress * 100))%")
+ .font(.system(size: 10))
+ .foregroundColor(.white.opacity(0.8))
+ }
+ }
+ .padding(16)
+ .background(Color.black.opacity(0.8))
+ .cornerRadius(8)
+ .shadow(radius: 10)
+
+ Spacer()
+ }
+
+ Spacer()
+ .frame(height: 80) // Position slightly above bottom
+ }
+ .transition(.opacity)
+ .animation(.easeInOut(duration: 0.2), value: isLoading)
+ }
+ }
+ .onReceive(timer) { _ in
+ updateLoadingState()
+ }
+ }
+
+ private func updateLoadingState() {
+ Task {
+ let loading = await AssetLoadingState.shared.isLoadingAny()
+ let summary = await AssetLoadingState.shared.loadingSummary()
+ let (current, total) = await AssetLoadingState.shared.totalProgress()
+
+ await MainActor.run {
+ isLoading = loading
+ loadingSummary = summary
+ totalCount = total
+
+ if total > 0 {
+ currentProgress = Float(current) / Float(total)
+ } else {
+ currentProgress = 0.0
+ }
+ }
+ }
+ }
+}
+
+/// Minimal loading indicator for small operations
+public struct MinimalLoadingIndicator: View {
+ @State private var isLoading = false
+ private let timer = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect()
+
+ public init() {}
+
+ public var body: some View {
+ Group {
+ if isLoading {
+ HStack(spacing: 6) {
+ ProgressView()
+ .progressViewStyle(CircularProgressViewStyle())
+ .scaleEffect(0.5)
+
+ Text("Loading...")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+ .padding(6)
+ .background(Color.black.opacity(0.6))
+ .cornerRadius(4)
+ }
+ }
+ .onReceive(timer) { _ in
+ updateState()
+ }
+ }
+
+ private func updateState() {
+ Task {
+ let loading = await AssetLoadingState.shared.isLoadingAny()
+ await MainActor.run {
+ isLoading = loading
+ }
+ }
+ }
+}
+
+#Preview {
+ ZStack {
+ Color.gray.ignoresSafeArea()
+ LoadingIndicatorView()
+ }
+}
diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift
index 6c5850d..1a29bb1 100644
--- a/Sources/UntoldEditor/Editor/ToolbarView.swift
+++ b/Sources/UntoldEditor/Editor/ToolbarView.swift
@@ -9,14 +9,17 @@
#if canImport(AppKit)
import AppKit
import SwiftUI
+ import UntoldEngine
struct ToolbarView: View {
@ObservedObject var selectionManager: SelectionManager
+ @ObservedObject var editorBasePath = EditorAssetBasePath.shared
var onSave: () -> Void
var onSaveAs: () -> Void
var onClear: () -> Void
var onPlayToggled: (Bool) -> Void
+ @Binding var useSceneCameraDuringPlay: Bool
var dirLightCreate: () -> Void
var pointLightCreate: () -> Void
var spotLightCreate: () -> Void
@@ -28,22 +31,27 @@
var onCreateCone: () -> Void
@State private var isPlaying = false
- @State private var showBuildSettings = false
+ @State private var showCreateProject = false
@State private var showingNewScriptDialog = false
@State private var newScriptName = ""
@State private var showBasePathAlert = false
+ @State private var showInvalidProjectAlert = false
+ @State private var invalidProjectMessage = ""
var body: some View {
HStack {
- leftSection
+ if EditorFeatureFlags.enableBuildButton {
+ leftSection
+ }
+ Spacer()
+ centeredButtons
Spacer()
- rightSection
+ if EditorFeatureFlags.enableScriptButtons {
+ rightSection
+ }
}
- .overlay(
- centeredButtons
- )
.padding(.horizontal, 20)
.padding(.vertical, 6)
.background(
@@ -56,8 +64,8 @@
)
.cornerRadius(8)
.shadow(color: Color.black.opacity(0.08), radius: 4, x: 0, y: 2)
- .sheet(isPresented: $showBuildSettings) {
- BuildSettingsView()
+ .sheet(isPresented: $showCreateProject) {
+ CreateProjectView()
}
.sheet(isPresented: $showingNewScriptDialog) {
NewScriptDialog(
@@ -71,19 +79,38 @@
}
)
}
- .alert("Set Asset Folder First", isPresented: $showBasePathAlert) {
+ .alert("No Project Loaded", isPresented: $showBasePathAlert) {
Button("OK", role: .cancel) {}
} message: {
- Text("Please set the Asset Folder in the Asset Browser before creating or editing scripts.")
+ Text("Please create a new project or open an existing project before working with scripts.")
+ }
+ .alert("Invalid Project", isPresented: $showInvalidProjectAlert) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text(invalidProjectMessage)
}
}
- var rightSection: some View {
+ var leftSection: some View {
HStack(spacing: 12) {
- Button(action: { showBuildSettings = true }) {
+ Button(action: { showCreateProject = true }) {
HStack(spacing: 6) {
Image(systemName: "hammer.fill")
- Text("Build")
+ Text("New")
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 12)
+ .background(Color.editorSurface)
+ .foregroundColor(.white)
+ .cornerRadius(6)
+ }
+ .buttonStyle(.plain)
+ .focusable(false)
+
+ Button(action: openExistingProject) {
+ HStack(spacing: 6) {
+ Image(systemName: "folder.fill")
+ Text("Open")
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -119,13 +146,21 @@
.buttonStyle(.plain)
.focusable(false)
+ Toggle(isOn: $useSceneCameraDuringPlay) {
+ Text("Scene Cam")
+ .font(.system(size: 11, weight: .semibold))
+ }
+ .toggleStyle(.switch)
+ .scaleEffect(0.85)
+ .frame(height: 20)
+
Menu {
- Button("Save", systemImage: "square.and.arrow.down.on.square", action: onSave)
- Button("Save As…", systemImage: "square.and.arrow.down", action: onSaveAs)
+ Button("Save Scene", systemImage: "square.and.arrow.down.on.square", action: onSave)
+ Button("Save Scene As…", systemImage: "square.and.arrow.down", action: onSaveAs)
} label: {
HStack(spacing: 6) {
Image(systemName: "square.and.arrow.down.on.square")
- Text("Save")
+ Text("Save Scene")
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
@@ -138,66 +173,46 @@
}
}
- var leftSection: some View {
+ var rightSection: some View {
HStack(spacing: 8) {
-// Button(action: onCreateCylinder) {
-// Image(systemName: "cylinder.fill")
-// .font(.system(size: 14))
-// .foregroundColor(.white)
-// }
-// .padding(6)
-// .background(Color.blue.opacity(0.8))
-// .cornerRadius(6)
-// .buttonStyle(.plain)
-// .help("Add Cylinder")
-//
-// Button(action: onCreateCone) {
-// Image(systemName: "cone.fill")
-// .font(.system(size: 14))
-// .foregroundColor(.white)
-// }
-// .padding(6)
-// .background(Color.blue.opacity(0.8))
-// .cornerRadius(6)
-// .buttonStyle(.plain)
-// .help("Add Cone")
-
Divider().frame(height: 24)
- Button(action: {
- guard ensureAssetBasePath() else { return }
- showingNewScriptDialog = true
- }) {
- HStack(spacing: 6) {
- Image(systemName: "plus.circle.fill")
- Text("New Script")
+ // Consolidated Script menu (matches Save menu pattern)
+ Menu {
+ Button("New Script", systemImage: "plus.circle.fill") {
+ guard ensureAssetBasePath() else { return }
+ showingNewScriptDialog = true
}
- .font(.system(size: 12, weight: .semibold))
- .foregroundColor(.white)
- .padding(.vertical, 6)
- .padding(.horizontal, 10)
- .background(Color.editorSurface)
- .cornerRadius(8)
- }
- .buttonStyle(.plain)
- .focusable(false)
- .help("Create a new script in the Scripts project")
-
- Button(action: openInXcode) {
+ Button("Script in Xcode", systemImage: "hammer.fill", action: openInXcode)
+ } label: {
HStack(spacing: 6) {
- Image(systemName: "hammer.fill")
- Text("Open in Xcode")
+ Image(systemName: "doc.text.fill")
+ Text("Script")
+ // Experimental indicator
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 10))
+ .foregroundColor(.orange)
}
- .font(.system(size: 12, weight: .semibold))
- .foregroundColor(.white)
.padding(.vertical, 6)
.padding(.horizontal, 10)
- .background(Color.editorSurface)
+ .background(Color.editorAccent)
+ .foregroundColor(.white)
.cornerRadius(8)
}
- .buttonStyle(.plain)
+ .menuStyle(.borderlessButton)
.focusable(false)
- .help("Open Scripts project in Xcode")
+ .help("USC Scripts (Experimental) - API subject to change")
+
+ // Show project name if loaded
+ if let projectName = editorBasePath.projectName {
+ Text(projectName)
+ .font(.system(size: 14, weight: .semibold, design: .monospaced))
+ .foregroundColor(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Color.editorAccent.opacity(0.3))
+ .cornerRadius(6)
+ }
}
}
@@ -261,6 +276,66 @@
print("✅ Opening Scripts project in Xcode")
}
+ private func openExistingProject() {
+ let panel = NSOpenPanel()
+ panel.canChooseFiles = false
+ panel.canChooseDirectories = true
+ panel.allowsMultipleSelection = false
+ panel.canCreateDirectories = false
+ panel.message = "Select the UntoldEngine project folder (the folder containing the .xcodeproj file)"
+ panel.prompt = "Open Project"
+
+ guard panel.runModal() == .OK, let projectURL = panel.url else {
+ return
+ }
+
+ // Validate project structure
+ let fm = FileManager.default
+ let projectName = projectURL.lastPathComponent
+
+ // Check for .xcodeproj
+ let xcodeProjectPath = projectURL.appendingPathComponent("\(projectName).xcodeproj")
+ guard fm.fileExists(atPath: xcodeProjectPath.path) else {
+ invalidProjectMessage = "This doesn't appear to be a valid UntoldEngine project.\n\nExpected to find: \(projectName).xcodeproj"
+ showInvalidProjectAlert = true
+ return
+ }
+
+ // Build the GameData path
+ let gameDataPath = projectURL
+ .appendingPathComponent("Sources")
+ .appendingPathComponent(projectName)
+ .appendingPathComponent("GameData")
+
+ // Check if GameData exists, create if not
+ if !fm.fileExists(atPath: gameDataPath.path) {
+ do {
+ try fm.createDirectory(at: gameDataPath, withIntermediateDirectories: true)
+ print("📁 Created missing GameData folder structure")
+ } catch {
+ invalidProjectMessage = "Failed to create GameData folder structure:\n\n\(error.localizedDescription)"
+ showInvalidProjectAlert = true
+ return
+ }
+ }
+
+ // Create standard asset subfolders if they don't exist
+ let assetFolders = ["Models", "Animations", "Scenes", "Scripts", "Gaussians", "Materials", "HDR", "Shaders"]
+ for folder in assetFolders {
+ let folderURL = gameDataPath.appendingPathComponent(folder, isDirectory: true)
+ if !fm.fileExists(atPath: folderURL.path) {
+ try? fm.createDirectory(at: folderURL, withIntermediateDirectories: true)
+ }
+ }
+
+ // Set the asset base path
+ assetBasePath = gameDataPath
+ EditorAssetBasePath.shared.basePath = gameDataPath
+
+ print("✅ Opened project: \(projectName)")
+ print("📁 Asset base path set to: \(gameDataPath.path)")
+ }
+
private func ensureAssetBasePath() -> Bool {
guard EditorAssetBasePath.shared.basePath != nil else {
showBasePathAlert = true
@@ -270,29 +345,6 @@
}
}
- // MARK: - Toolbar Button Component
-
- struct ToolbarButton: View {
- let iconName: String
- let action: () -> Void
- let tooltip: String
-
- var body: some View {
- Button(action: action) {
- Image(systemName: iconName)
- .font(.system(size: 14, weight: .bold))
- .foregroundColor(.white)
- .padding(6)
- .background(Color.editorSurface)
- .cornerRadius(6)
- .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1)
- }
- .buttonStyle(PlainButtonStyle())
- .focusable(false)
- .help(tooltip)
- }
- }
-
// MARK: - New Script Dialog
struct NewScriptDialog: View {
@@ -331,4 +383,27 @@
}
}
+ // MARK: - Toolbar Button Component
+
+ struct ToolbarButton: View {
+ let iconName: String
+ let action: () -> Void
+ let tooltip: String
+
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: iconName)
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(.white)
+ .padding(6)
+ .background(Color.editorSurface)
+ .cornerRadius(6)
+ .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .focusable(false)
+ .help(tooltip)
+ }
+ }
+
#endif
diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift
index e4cc46f..0048cde 100644
--- a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift
+++ b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift
@@ -12,8 +12,8 @@ import UntoldEngine
extension UntoldRenderer {
func handleSceneInput() {
- // Game mode blocks editor + camera input entirely
- if gameMode { return }
+ // Block editor + camera input during play unless the scene camera is active
+ if gameMode, CameraSystem.shared.activeCamera != findSceneCamera() { return }
// Always allow camera WASDQE input, regardless of editor state
let input = (
diff --git a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift
index bd0705a..0011ee1 100644
--- a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift
+++ b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift
@@ -353,4 +353,120 @@ final class AssetBrowserViewTests: XCTestCase {
XCTAssertTrue(scriptNames.contains("enemy_ai.uscript"), "Script file 2 should be loaded")
}
}
+
+ func test_hdrLoadingHandlesNonExistentFile() throws {
+ try withTempDirectory { base in
+ // Create HDR directory but no HDR file
+ let hdr = base.appendingPathComponent("HDR", isDirectory: true)
+ try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true)
+
+ // Create a non-existent HDR asset
+ let nonExistentHDRPath = hdr.appendingPathComponent("nonexistent.hdr")
+ let hdrAsset = Asset(name: "nonexistent.hdr", category: "HDR", path: nonExistentHDRPath, isFolder: false)
+
+ // Set the base path
+ assetBasePath = base
+ EditorAssetBasePath.shared.basePath = base
+
+ var assetsState: [String: [Asset]] = ["HDR": []]
+ var selected: Asset? = hdrAsset
+
+ _ = makeView(
+ assets: .init(get: { assetsState }, set: { assetsState = $0 }),
+ selectedAsset: .init(get: { selected }, set: { selected = $0 })
+ )
+
+ // Verify the HDR file does not exist
+ XCTAssertFalse(FileManager.default.fileExists(atPath: nonExistentHDRPath.path), "HDR file should not exist")
+
+ // The view's handle_add_model_double_click checks FileManager.default.fileExists before loading
+ // and displays an error status if the file doesn't exist
+ // Since we can't directly call the private method, we verify the logic:
+ // 1. File doesn't exist
+ // 2. Early return prevents generateHDR call
+ // This test validates that the guard condition works correctly
+ }
+ }
+
+ func test_hdrLoadingSuccessEnablesIBL() throws {
+ try withTempDirectory { base in
+ // Create HDR directory with a valid HDR file
+ let hdr = base.appendingPathComponent("HDR", isDirectory: true)
+ try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true)
+
+ // Create a mock HDR file (just an empty file for testing file existence)
+ let hdrFilePath = hdr.appendingPathComponent("test_environment.hdr")
+ FileManager.default.createFile(atPath: hdrFilePath.path, contents: Data())
+
+ let hdrAsset = Asset(name: "test_environment.hdr", category: "HDR", path: hdrFilePath, isFolder: false)
+
+ // Set the base path
+ assetBasePath = base
+ EditorAssetBasePath.shared.basePath = base
+
+ var assetsState: [String: [Asset]] = ["HDR": [hdrAsset]]
+ var selected: Asset? = hdrAsset
+
+ _ = makeView(
+ assets: .init(get: { assetsState }, set: { assetsState = $0 }),
+ selectedAsset: .init(get: { selected }, set: { selected = $0 })
+ )
+
+ // Verify the HDR file exists
+ XCTAssertTrue(FileManager.default.fileExists(atPath: hdrFilePath.path), "HDR file should exist")
+
+ // Simulate successful HDR loading
+ // In the actual view, when iblSuccessful is true after generateHDR:
+ // 1. applyIBL is set to true
+ // 2. Success status is displayed
+ iblSuccessful = true
+ applyIBL = true
+
+ XCTAssertTrue(applyIBL, "IBL should be enabled after successful HDR load")
+ XCTAssertTrue(iblSuccessful, "iblSuccessful should be true after successful load")
+
+ // Reset state
+ iblSuccessful = false
+ applyIBL = false
+ }
+ }
+
+ func test_hdrLoadingFailureDisplaysError() throws {
+ try withTempDirectory { base in
+ // Create HDR directory with a file
+ let hdr = base.appendingPathComponent("HDR", isDirectory: true)
+ try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true)
+
+ let hdrFilePath = hdr.appendingPathComponent("corrupted.hdr")
+ FileManager.default.createFile(atPath: hdrFilePath.path, contents: Data())
+
+ let hdrAsset = Asset(name: "corrupted.hdr", category: "HDR", path: hdrFilePath, isFolder: false)
+
+ assetBasePath = base
+ EditorAssetBasePath.shared.basePath = base
+
+ var assetsState: [String: [Asset]] = ["HDR": [hdrAsset]]
+ var selected: Asset? = hdrAsset
+
+ _ = makeView(
+ assets: .init(get: { assetsState }, set: { assetsState = $0 }),
+ selectedAsset: .init(get: { selected }, set: { selected = $0 })
+ )
+
+ // Verify the HDR file exists
+ XCTAssertTrue(FileManager.default.fileExists(atPath: hdrFilePath.path), "HDR file should exist")
+
+ // Simulate failed HDR loading (e.g., corrupted file)
+ // In the view, when iblSuccessful remains false after generateHDR:
+ // 1. applyIBL is NOT set to true
+ // 2. Error status is displayed
+ iblSuccessful = false
+ let initialApplyIBL = applyIBL
+
+ // Verify IBL was not enabled on failure
+ XCTAssertFalse(iblSuccessful, "iblSuccessful should be false after failed load")
+ // applyIBL should not have changed from initial state if HDR load failed
+ XCTAssertEqual(applyIBL, initialApplyIBL, "applyIBL should not change when HDR load fails")
+ }
+ }
}
diff --git a/Tests/UntoldEditorTests/EnvironmentViewTests.swift b/Tests/UntoldEditorTests/EnvironmentViewTests.swift
new file mode 100644
index 0000000..248985c
--- /dev/null
+++ b/Tests/UntoldEditorTests/EnvironmentViewTests.swift
@@ -0,0 +1,212 @@
+//
+// EnvironmentViewTests.swift
+// UntoldEditor
+//
+// Copyright (C) Untold Engine Studios
+// Licensed under the GNU LGPL v3.0 or later.
+// See the LICENSE file or for details.
+//
+
+// These unit tests were jump-started with AI assistance — then refined by humans. If you spot an issue, please submit an issue.
+
+import Foundation
+import SwiftUI
+@testable import UntoldEditor
+@testable import UntoldEngine
+import XCTest
+
+final class EnvironmentViewTests: XCTestCase {
+ // Helper to create a temp directory tree for a test and clean it up afterwards.
+ private func withTempDirectory(_ body: (URL) throws -> Void) throws {
+ let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ .appendingPathComponent("EnvironmentViewTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+ try body(base)
+ }
+
+ override func setUp() {
+ super.setUp()
+ // Reset global state before each test
+ iblSuccessful = false
+ applyIBL = false
+ }
+
+ override func tearDown() {
+ // Clean up global state after each test
+ iblSuccessful = false
+ applyIBL = false
+ super.tearDown()
+ }
+
+ func test_addIBLHandlesNonExistentFile() throws {
+ try withTempDirectory { base in
+ // Create HDR directory but no HDR file
+ let hdr = base.appendingPathComponent("HDR", isDirectory: true)
+ try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true)
+
+ // Create a non-existent HDR asset
+ let nonExistentHDRPath = hdr.appendingPathComponent("nonexistent.hdr")
+ let hdrAsset = Asset(name: "nonexistent.hdr", category: "HDR", path: nonExistentHDRPath, isFolder: false)
+
+ // Verify the HDR file does not exist
+ XCTAssertFalse(FileManager.default.fileExists(atPath: nonExistentHDRPath.path), "HDR file should not exist")
+
+ // Call addIBL with non-existent asset
+ addIBL(asset: hdrAsset)
+
+ // The function checks FileManager.default.fileExists and returns early if false
+ // Verify that IBL was not enabled and no attempt was made to load
+ XCTAssertFalse(iblSuccessful, "iblSuccessful should remain false for non-existent file")
+ XCTAssertFalse(applyIBL, "applyIBL should remain false for non-existent file")
+ }
+ }
+
+ func test_addIBLConditionalLogicForSuccess() throws {
+ try withTempDirectory { base in
+ // This test validates the conditional logic in addIBL for successful HDR loading
+ // Since we can't actually load HDR in tests (requires GPU/Metal context),
+ // we test the logic by manually setting iblSuccessful before the check
+
+ let hdr = base.appendingPathComponent("HDR", isDirectory: true)
+ try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true)
+
+ let hdrFilePath = hdr.appendingPathComponent("test_environment.hdr")
+ FileManager.default.createFile(atPath: hdrFilePath.path, contents: Data())
+
+ let hdrAsset = Asset(name: "test_environment.hdr", category: "HDR", path: hdrFilePath, isFolder: false)
+
+ // Verify the HDR file exists
+ XCTAssertTrue(FileManager.default.fileExists(atPath: hdrFilePath.path), "HDR file should exist")
+
+ // Pre-set iblSuccessful to simulate what would happen after successful generateHDR
+ iblSuccessful = true
+
+ // Verify the conditional logic: if iblSuccessful is true, applyIBL should be set
+ if iblSuccessful {
+ applyIBL = true
+ }
+
+ XCTAssertTrue(applyIBL, "IBL should be enabled when iblSuccessful is true")
+ XCTAssertTrue(iblSuccessful, "iblSuccessful should be true in success scenario")
+ }
+ }
+
+ func test_addIBLFailureLogsWarning() throws {
+ try withTempDirectory { base in
+ // Create HDR directory with a file
+ let hdr = base.appendingPathComponent("HDR", isDirectory: true)
+ try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true)
+
+ let hdrFilePath = hdr.appendingPathComponent("corrupted.hdr")
+ FileManager.default.createFile(atPath: hdrFilePath.path, contents: Data())
+
+ let hdrAsset = Asset(name: "corrupted.hdr", category: "HDR", path: hdrFilePath, isFolder: false)
+
+ // Verify the HDR file exists
+ XCTAssertTrue(FileManager.default.fileExists(atPath: hdrFilePath.path), "HDR file should exist")
+
+ // Simulate failed HDR loading (iblSuccessful remains false)
+ iblSuccessful = false
+
+ // Call addIBL with existing but "corrupted" asset
+ addIBL(asset: hdrAsset)
+
+ // When iblSuccessful is false, addIBL should NOT enable applyIBL
+ // and should log a warning message
+ XCTAssertFalse(iblSuccessful, "iblSuccessful should be false after failed load")
+ XCTAssertFalse(applyIBL, "applyIBL should remain false when HDR load fails")
+ }
+ }
+
+ func test_addIBLWithNilAsset() {
+ // Test that addIBL handles nil asset gracefully
+ addIBL(asset: nil)
+
+ // Should not crash and should not modify global state
+ XCTAssertFalse(iblSuccessful, "iblSuccessful should remain false for nil asset")
+ XCTAssertFalse(applyIBL, "applyIBL should remain false for nil asset")
+ }
+
+ func test_addIBLWithNonHDRCategory() throws {
+ try withTempDirectory { base in
+ // Create a Models asset (wrong category)
+ let models = base.appendingPathComponent("Models", isDirectory: true)
+ try FileManager.default.createDirectory(at: models, withIntermediateDirectories: true)
+
+ let modelFile = models.appendingPathComponent("test.usdz")
+ FileManager.default.createFile(atPath: modelFile.path, contents: Data())
+
+ let modelAsset = Asset(name: "test.usdz", category: "Models", path: modelFile, isFolder: false)
+
+ // Call addIBL with wrong category
+ addIBL(asset: modelAsset)
+
+ // The function checks that category matches "HDR" and returns early if not
+ XCTAssertFalse(iblSuccessful, "iblSuccessful should remain false for non-HDR asset")
+ XCTAssertFalse(applyIBL, "applyIBL should remain false for non-HDR asset")
+ }
+ }
+
+ func test_environmentViewTogglesApplyIBL() {
+ var selectedAsset: Asset? = nil
+
+ // Create EnvironmentView
+ let view = EnvironmentView(selectedAsset: .init(
+ get: { selectedAsset },
+ set: { selectedAsset = $0 }
+ ))
+
+ // Verify view is created
+ XCTAssertNotNil(view)
+
+ // The view binds to global applyIBL state
+ // Test that toggling the view's state would update the global
+ applyIBL = true
+ XCTAssertTrue(applyIBL, "applyIBL should be true when toggled on")
+
+ applyIBL = false
+ XCTAssertFalse(applyIBL, "applyIBL should be false when toggled off")
+ }
+
+ func test_environmentViewTogglesRenderEnvironment() {
+ var selectedAsset: Asset? = nil
+
+ // Create EnvironmentView
+ let view = EnvironmentView(selectedAsset: .init(
+ get: { selectedAsset },
+ set: { selectedAsset = $0 }
+ ))
+
+ // Verify view is created
+ XCTAssertNotNil(view)
+
+ // The view binds to global renderEnvironment state
+ renderEnvironment = true
+ XCTAssertTrue(renderEnvironment, "renderEnvironment should be true when toggled on")
+
+ renderEnvironment = false
+ XCTAssertFalse(renderEnvironment, "renderEnvironment should be false when toggled off")
+ }
+
+ func test_environmentViewAmbientIntensity() {
+ var selectedAsset: Asset? = nil
+
+ // Create EnvironmentView
+ let view = EnvironmentView(selectedAsset: .init(
+ get: { selectedAsset },
+ set: { selectedAsset = $0 }
+ ))
+
+ // Verify view is created
+ XCTAssertNotNil(view)
+
+ // The view binds to global ambientIntensity state
+ let testIntensity: Float = 2.5
+ ambientIntensity = testIntensity
+ XCTAssertEqual(ambientIntensity, testIntensity, "ambientIntensity should update correctly")
+
+ ambientIntensity = 1.0 // Reset to default
+ XCTAssertEqual(ambientIntensity, 1.0, "ambientIntensity should reset to default")
+ }
+}
diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift
index 5f91d0d..02c0d66 100644
--- a/Tests/UntoldEditorTests/ToolbarViewTests.swift
+++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift
@@ -39,6 +39,7 @@ import XCTest
onSaveAs: { onSaveAsCalled.pointee = true },
onClear: { onClearCalled.pointee = true },
onPlayToggled: { value in onPlayToggledValues.pointee.append(value) },
+ useSceneCameraDuringPlay: .constant(false),
dirLightCreate: { onDirLightCalled.pointee = true },
pointLightCreate: { onPointLightCalled.pointee = true },
spotLightCreate: { onSpotLightCalled.pointee = true },
@@ -130,6 +131,7 @@ import XCTest
onSaveAs: {},
onClear: {},
onPlayToggled: { playValues.append($0) },
+ useSceneCameraDuringPlay: .constant(false),
dirLightCreate: {},
pointLightCreate: {},
spotLightCreate: {},
@@ -164,6 +166,7 @@ import XCTest
onSaveAs: {},
onClear: {},
onPlayToggled: { _ in },
+ useSceneCameraDuringPlay: .constant(false),
dirLightCreate: {},
pointLightCreate: {},
spotLightCreate: {},
@@ -199,6 +202,7 @@ import XCTest
onSaveAs: {},
onClear: {},
onPlayToggled: { _ in },
+ useSceneCameraDuringPlay: .constant(false),
dirLightCreate: {},
pointLightCreate: {},
spotLightCreate: {},
@@ -231,6 +235,7 @@ import XCTest
onSaveAs: {},
onClear: {},
onPlayToggled: { _ in },
+ useSceneCameraDuringPlay: .constant(false),
dirLightCreate: {},
pointLightCreate: {},
spotLightCreate: {},