diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7e08d..c6133f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Changelog +## v0.16.0 - 2026-08-12 +### 🐞 Fixes +- [Patch] fix area light mesh culling (9122f78…) +- [Patch] Added shadow cast to point lights (390e1f4…) +- [Patch] Align light inspector controls and debug meshes with Blender lighting (b439816…) +- [Patch] Allow .exr import alongside .hdr in Asset Browser's HDR category (7ac9415…) +- [Patch] Enable the color lut in the editor (d519be9…) ## v0.14.2 - 2026-07-16 ## v0.14.0 - 2026-07-09 ### 🐞 Fixes diff --git a/Package.swift b/Package.swift index 82e55c1..e2987a9 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.14.2"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.16.0"), ], targets: [ .executableTarget( diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 0638412..b5f0ea7 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -729,7 +729,7 @@ struct AssetBrowserView: View { case .materials: openPanel.allowedContentTypes = [.png, .jpeg, .tiff] case .hdr: - openPanel.allowedContentTypes = [UTType(filenameExtension: "hdr")!] + openPanel.allowedContentTypes = [UTType(filenameExtension: "hdr")!, UTType(filenameExtension: "exr")!] } openPanel.canChooseDirectories = (category == .materials || category == .streamModels) @@ -1456,8 +1456,8 @@ struct AssetBrowserView: View { path: item, isFolder: true)) } else if category == .hdr { - // For HDR, also allow .hdr files directly in the HDR folder - if item.pathExtension.lowercased() == "hdr" { + // For HDR, also allow .hdr and .exr files directly in the HDR folder + if ["hdr", "exr"].contains(item.pathExtension.lowercased()) { categoryAssets.append(Asset(name: item.lastPathComponent, category: category.rawValue, path: item, @@ -1563,7 +1563,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", "remotestream"] + let allowedExtensions: Set = [runtimeAssetExtension, "utex", "png", "jpg", "jpeg", "hdr", "exr", "tif", "tiff", "ply", "json", "uscript", "remotestream"] guard allowedExtensions.contains(item.pathExtension.lowercased()) else { return nil } return Asset(name: item.lastPathComponent, @@ -2020,9 +2020,9 @@ struct AssetBrowserView: View { showSceneLoadConfirmation = true editorController?.currentSceneURL = asset.path } - // Handle HDR files (hdr) + // Handle HDR files (hdr, exr) else if asset.category == AssetCategory.hdr.rawValue, - withExtension.lowercased() == "hdr" + ["hdr", "exr"].contains(withExtension.lowercased()) { // Verify HDR file exists before attempting to load guard FileManager.default.fileExists(atPath: asset.path.path) else { diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index eb10169..4968044 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -1189,7 +1189,8 @@ public struct EditorView: View { let entityId = createEntity() setEntityName(entityId: entityId, name: "Demo-\(title)-\(entityId)") - if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + registerComponent(entityId: entityId, componentType: QuickPreviewComponent.self) + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId) { quickPreviewComp.absoluteFilePath = sourceURL.isFileURL ? sourceURL.path : sourceURL.absoluteString quickPreviewComp.fileExtension = fileExtension quickPreviewComp.originalFileName = title @@ -1480,7 +1481,8 @@ public struct EditorView: View { setEntityName(entityId: entityId, name: uniqueName) // Mark this entity as a Quick Preview entity (cannot be saved) - if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + registerComponent(entityId: entityId, componentType: QuickPreviewComponent.self) + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId) { quickPreviewComp.absoluteFilePath = absolutePath quickPreviewComp.fileExtension = fileExtension quickPreviewComp.originalFileName = fileName @@ -1869,7 +1871,8 @@ public struct EditorView: View { let uniqueName = "QuickPreview-\(fileName)-\(entityId)" setEntityName(entityId: entityId, name: uniqueName) - if let quickPreviewComp = scene.assign(to: entityId, component: QuickPreviewComponent.self) { + registerComponent(entityId: entityId, componentType: QuickPreviewComponent.self) + if let quickPreviewComp = scene.get(component: QuickPreviewComponent.self, for: entityId) { quickPreviewComp.absoluteFilePath = sourceURL.path quickPreviewComp.fileExtension = sourceURL.pathExtension.lowercased() quickPreviewComp.originalFileName = fileName diff --git a/Sources/UntoldEditor/Editor/EnvironmentView.swift b/Sources/UntoldEditor/Editor/EnvironmentView.swift index da492c8..246260a 100644 --- a/Sources/UntoldEditor/Editor/EnvironmentView.swift +++ b/Sources/UntoldEditor/Editor/EnvironmentView.swift @@ -41,6 +41,7 @@ func addIBL(asset: Asset?) { struct EnvironmentView: View { @State private var enableApplyIBL: Bool = false @State private var enableRenderEnvironment: Bool = false + @State private var enableColorLUT: Bool = false @State private var intensity: Float = 1.0 @Binding var selectedAsset: Asset? var body: some View { @@ -108,6 +109,27 @@ struct EnvironmentView: View { Divider() + // MARK: - Color LUT Toggle (Compact) + + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $enableColorLUT) { + Label("Apply Color LUT", systemImage: enableColorLUT ? "checkmark.circle.fill" : "circle") + .font(.system(size: 12)) + } + .toggleStyle(SwitchToggleStyle()) + .scaleEffect(0.85) + .onChange(of: enableColorLUT) { _, newValue in + ColorLUTParams.shared.enabled = newValue + enableColorLUT = ColorLUTParams.shared.enabled + } + + Text("Compares the baked Blender color-grading LUT against the default tonemap. Only takes effect if the loaded asset has a baked LUT.") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Divider() + // MARK: - Ambient Intensity Slider (Compact) VStack(alignment: .leading, spacing: 4) { @@ -132,6 +154,7 @@ struct EnvironmentView: View { .onAppear { enableApplyIBL = applyIBL enableRenderEnvironment = renderEnvironment + enableColorLUT = ColorLUTParams.shared.enabled intensity = ambientIntensity } } diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index cadf8cf..c68415a 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -651,6 +651,21 @@ struct RenderingEditorView: View { .background(Color.secondary.opacity(0.05)) .cornerRadius(8) + if hasComponent(entityId: entityId, componentType: RenderComponent.self) { + Toggle(isOn: Binding( + get: { getEntityCastsShadow(entityId: entityId) }, + set: { enabled in + setEntityCastsShadow(entityId: entityId, enabled) + refreshView() + } + )) { + Text("Cast Shadows") + } + .toggleStyle(.checkbox) + .disabled(inspectionOnly) + .opacity(inspectionOnly ? 0.7 : 1.0) + } + if hasComponent(entityId: entityId, componentType: RenderComponent.self), hasComponent(entityId: entityId, componentType: LightComponent.self) == false { @@ -1342,6 +1357,8 @@ struct PointLightEditorView: View { let intensity: Float = getLightIntensity(entityId: entityId) let falloff: Float = getLightFalloff(entityId: entityId) let radius: Float = getLightRadius(entityId: entityId) + let range: Float = scene.get(component: PointLightComponent.self, for: entityId)?.range ?? 0.0 + let castsShadow: Bool = getPointLightCastsShadow(entityId: entityId) TextInputVectorView(label: "Color", value: Binding( get: { color }, @@ -1353,7 +1370,7 @@ struct PointLightEditorView: View { .frame(maxWidth: .infinity, alignment: .leading) HStack { - TextInputNumberView(label: "Brighness", value: Binding( + TextInputNumberView(label: "Power (W)", value: Binding( get: { intensity }, set: { newIntensity in updateLightIntensity(entityId: entityId, intensity: newIntensity) @@ -1362,10 +1379,10 @@ struct PointLightEditorView: View { )) .frame(maxWidth: .infinity, alignment: .leading) - TextInputNumberView(label: "Falloff", value: Binding( - get: { falloff }, - set: { newFalloff in - updateLightFalloff(entityId: entityId, falloff: newFalloff) + TextInputNumberView(label: "Range", value: Binding( + get: { range }, + set: { newRange in + setLight(entityId: entityId, .point(.range(newRange))) refreshView() } )) @@ -1380,6 +1397,27 @@ struct PointLightEditorView: View { )) .frame(maxWidth: .infinity, alignment: .leading) } + + TextInputNumberView(label: "Legacy Falloff", value: Binding( + get: { falloff }, + set: { newFalloff in + updateLightFalloff(entityId: entityId, falloff: newFalloff) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle(isOn: Binding( + get: { castsShadow }, + set: { enabled in + setLight(entityId: entityId, .point(.castsShadow(enabled))) + refreshView() + } + )) { + Text("Shadow") + } + .toggleStyle(.checkbox) + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -1397,7 +1435,10 @@ struct SpotLightEditorView: View { let color: simd_float3 = getLightColor(entityId: entityId) let falloff: Float = getLightFalloff(entityId: entityId) let intensity: Float = getLightIntensity(entityId: entityId) - let coneAngle: Float = getLightConeAngle(entityId: entityId) + let radius: Float = getLightRadius(entityId: entityId) + let range: Float = scene.get(component: SpotLightComponent.self, for: entityId)?.range ?? 0.0 + let coneAngle: Float = getLightConeAngle(entityId: entityId) * 2.0 + let castsShadow: Bool = getSpotLightCastsShadow(entityId: entityId) TextInputVectorView(label: "Color", value: Binding( get: { color }, set: { newColor in @@ -1407,7 +1448,7 @@ struct SpotLightEditorView: View { )) .frame(maxWidth: .infinity, alignment: .leading) HStack { - TextInputNumberView(label: "Brightness", value: Binding( + TextInputNumberView(label: "Power (W)", value: Binding( get: { intensity }, set: { newIntensity in updateLightIntensity(entityId: entityId, intensity: newIntensity) @@ -1415,24 +1456,57 @@ struct SpotLightEditorView: View { } )) .frame(maxWidth: .infinity, alignment: .leading) - TextInputNumberView(label: "Falloff", value: Binding( - get: { falloff }, - set: { newFalloff in - updateLightFalloff(entityId: entityId, falloff: newFalloff) + + TextInputNumberView(label: "Range", value: Binding( + get: { range }, + set: { newRange in + setLight(entityId: entityId, .spot(.range(newRange))) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + + TextInputNumberView(label: "Radius", value: Binding( + get: { radius }, + set: { newRadius in + updateLightRadius(entityId: entityId, radius: newRadius) refreshView() } )) .frame(maxWidth: .infinity, alignment: .leading) + } + HStack { TextInputNumberView(label: "Cone Angle", value: Binding( get: { coneAngle }, set: { newConeAngle in - updateLightConeAngle(entityId: entityId, coneAngle: newConeAngle) + updateLightConeAngle(entityId: entityId, coneAngle: newConeAngle * 0.5) refreshView() } )) .frame(maxWidth: .infinity, alignment: .leading) } + + TextInputNumberView(label: "Legacy Falloff", value: Binding( + get: { falloff }, + set: { newFalloff in + updateLightFalloff(entityId: entityId, falloff: newFalloff) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle(isOn: Binding( + get: { castsShadow }, + set: { enabled in + setLight(entityId: entityId, .spot(.castsShadow(enabled))) + refreshView() + } + )) { + Text("Shadow") + } + .toggleStyle(.checkbox) + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -1449,7 +1523,11 @@ struct AreaLightEditorView: View { VStack { let color: simd_float3 = getLightColor(entityId: entityId) let intensity: Float = getLightIntensity(entityId: entityId) - // add area lights properties here + let scale: simd_float3 = getScale(entityId: entityId) + let areaLightComponent = scene.get(component: AreaLightComponent.self, for: entityId) + let range: Float = areaLightComponent?.range ?? 0.0 + let twoSided: Bool = areaLightComponent?.twoSided ?? false + TextInputVectorView(label: "Color", value: Binding( get: { color }, set: { newColor in @@ -1459,13 +1537,70 @@ struct AreaLightEditorView: View { )) .frame(maxWidth: .infinity, alignment: .leading) - TextInputNumberView(label: "Brightness", value: Binding( - get: { intensity }, - set: { newIntensity in - updateLightIntensity(entityId: entityId, intensity: newIntensity) + HStack { + TextInputNumberView(label: "Power (W)", value: Binding( + get: { intensity }, + set: { newIntensity in + updateLightIntensity(entityId: entityId, intensity: newIntensity) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + + TextInputNumberView(label: "Range", value: Binding( + get: { range }, + set: { newRange in + setLight(entityId: entityId, .area(.range(newRange))) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack { + TextInputNumberView(label: "Size X", value: Binding( + get: { scale.x }, + set: { newSizeX in + let before = EditorTransformSnapshot(entityId: entityId) + let nextSizeX = max(newSizeX, 0.001) + scaleTo(entityId: entityId, scale: simd_float3(nextSizeX, scale.y, scale.z)) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + + TextInputNumberView(label: "Size Y", value: Binding( + get: { scale.y }, + set: { newSizeY in + let before = EditorTransformSnapshot(entityId: entityId) + let nextSizeY = max(newSizeY, 0.001) + scaleTo(entityId: entityId, scale: simd_float3(scale.x, nextSizeY, scale.z)) + EditorUndoManager.shared.registerTransformChange( + entityId: entityId, + before: before, + after: EditorTransformSnapshot(entityId: entityId) + ) + refreshView() + } + )) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Toggle(isOn: Binding( + get: { twoSided }, + set: { enabled in + setLight(entityId: entityId, .area(.twoSided(enabled))) refreshView() } - )) + )) { + Text("Two Sided") + } + .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Sources/UntoldEditor/Editor/StarterStreamModels.swift b/Sources/UntoldEditor/Editor/StarterStreamModels.swift index 9d262ea..d0a0628 100644 --- a/Sources/UntoldEditor/Editor/StarterStreamModels.swift +++ b/Sources/UntoldEditor/Editor/StarterStreamModels.swift @@ -25,7 +25,7 @@ let starterStreamModels: [StreamModelCatalogItem] = [ .init( id: "dungeon", title: "Game Dungeon", - manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/dungeon3/dungeon3.json")! + manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Dungeon/dungeon.json")! ), .init( id: "city", diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 6b2892f..fbb99c2 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.14.2" + private let editorVersionLabel = "v0.16.0" var onSave: () -> Void var onSaveAs: () -> Void diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index 7b35f45..beb1906 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -604,23 +604,27 @@ extension RenderPasses { if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { var lightMesh: [Mesh] = [] var debugModelMatrix = worldTransform.space + var isAreaLight = false if let pointLightComponent = scene.get(component: PointLightComponent.self, for: activeEntity) { scale = simd_float3(repeating: pointLightComponent.radius) lightMesh = pointLightDebugMesh } else if let spotLightComponent = scene.get(component: SpotLightComponent.self, for: activeEntity) { - let theta = degreesToRadians(degrees: spotLightComponent.coneAngle) - let radius = tan(theta) * spotLightComponent.radius + let halfAngle = degreesToRadians(degrees: spotLightComponent.coneAngle) + let coneLength = max(spotLightComponent.range, 1.0) + let coneRadius = tan(halfAngle) * coneLength - scale = simd_float3(radius, radius, spotLightComponent.radius / 2.0) + scale = simd_float3(coneRadius * 2.0, coneLength, coneRadius * 2.0) lightMesh = spotLightDebugMesh let spotDebugRotation = matrix4x4Rotation( radians: degreesToRadians(degrees: 90.0), axis: simd_float3(1.0, 0.0, 0.0) ) - debugModelMatrix = simd_mul(worldTransform.space, spotDebugRotation) + let spotDebugPivotOffset = matrix4x4Translation(0.0, -coneLength / 2.0, 0.0) + debugModelMatrix = simd_mul(simd_mul(worldTransform.space, spotDebugRotation), spotDebugPivotOffset) } else if scene.get(component: AreaLightComponent.self, for: activeEntity) != nil { lightMesh = areaLightDebugMesh debugModelMatrix = areaLightDebugModelMatrix(worldTransform: worldTransform.space) + isAreaLight = true } else if let dirLightComponent = scene.get(component: DirectionalLightComponent.self, for: activeEntity) { lightMesh = dirLightDebugMesh } @@ -631,6 +635,13 @@ extension RenderPasses { renderEncoder.setVertexBytes(&scale, length: MemoryLayout.stride, index: 4) renderEncoder.setTriangleFillMode(.lines) + + // The area light gizmo is a single-sided plane; disable culling so its + // wireframe stays visible from either side as the camera orbits. + if isAreaLight { + renderEncoder.setCullMode(.none) + } + for mesh in lightMesh { renderEncoder.setVertexBuffer( mesh.metalKitMesh.vertexBuffers[Int(modelPassVerticesIndex.rawValue)].buffer, @@ -648,6 +659,10 @@ extension RenderPasses { } } + if isAreaLight { + renderEncoder.setCullMode(.back) + } + } else { var modelMatrix = worldTransform.space renderEncoder.setVertexBytes( diff --git a/Sources/UntoldEditor/Systems/GizmoSystem.swift b/Sources/UntoldEditor/Systems/GizmoSystem.swift index 25ace76..148eae3 100644 --- a/Sources/UntoldEditor/Systems/GizmoSystem.swift +++ b/Sources/UntoldEditor/Systems/GizmoSystem.swift @@ -357,7 +357,8 @@ 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) { + registerComponent(entityId: handle, componentType: GizmoHandleComponent.self) + if let handleComponent = scene.get(component: GizmoHandleComponent.self, for: handle) { handleComponent.mode = descriptor.mode handleComponent.axis = descriptor.axis } diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 325f72c..3ad807f 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.14.2") + Logger.log(message: "Launching Untold Engine Editor v0.16.0") // Step 1. Create and configure the window window = NSWindow( @@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { defer: false ) - window.title = "Untold Engine Editor v0.14.2" + window.title = "Untold Engine Editor v0.16.0" window.center() let hostingView = NSHostingView(rootView: EditorView()) diff --git a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift index 2027a8b..d0127df 100644 --- a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift +++ b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift @@ -180,6 +180,45 @@ final class AssetBrowserViewTests: XCTestCase { } } + func test_loadAssetsFromDisk_includesEXRFilesInHDRCategory() throws { + try withTempDirectory { base in + let hdr = base.appendingPathComponent("HDR", isDirectory: true) + try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true) + + let exrFile = hdr.appendingPathComponent("garden.exr") + FileManager.default.createFile(atPath: exrFile.path, contents: Data()) + let hdrFile = hdr.appendingPathComponent("studio.hdr") + FileManager.default.createFile(atPath: hdrFile.path, contents: Data()) + let txtFile = hdr.appendingPathComponent("readme.txt") + FileManager.default.createFile(atPath: txtFile.path, contents: Data()) + + assetBasePath = base + EditorAssetBasePath.shared.basePath = base + + var assetsState: [String: [Asset]] = [:] + var selected: Asset? = nil + + _ = makeView( + assets: .init(get: { assetsState }, set: { assetsState = $0 }), + selectedAsset: .init(get: { selected }, set: { selected = $0 }) + ) + + // Replicate the filesystem walk the view does (mirrors AssetBrowserView's + // HDR-category filter, which now accepts both "hdr" and "exr"). + var categoryAssets: [Asset] = [] + if let contents = try? FileManager.default.contentsOfDirectory(at: hdr, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) { + for item in contents where ["hdr", "exr"].contains(item.pathExtension.lowercased()) { + categoryAssets.append(Asset(name: item.lastPathComponent, category: AssetCategory.hdr.rawValue, path: item, isFolder: false)) + } + } + + let names = Set(categoryAssets.map(\.name)) + XCTAssertTrue(names.contains("garden.exr"), "EXR files should be listed in the HDR category") + XCTAssertTrue(names.contains("studio.hdr")) + XCTAssertFalse(names.contains("readme.txt")) + } + } + func test_selectingAssetUpdatesBinding() throws { var assetsState: [String: [Asset]] = [ "Models": [ diff --git a/Tests/UntoldEditorTests/EnvironmentViewTests.swift b/Tests/UntoldEditorTests/EnvironmentViewTests.swift index 5e17da1..342d5f5 100644 --- a/Tests/UntoldEditorTests/EnvironmentViewTests.swift +++ b/Tests/UntoldEditorTests/EnvironmentViewTests.swift @@ -62,6 +62,27 @@ final class EnvironmentViewTests: XCTestCase { } } + func test_addIBLHandlesNonExistentEXRFile() throws { + try withTempDirectory { base in + // Same as test_addIBLHandlesNonExistentFile, but for the .exr extension + // that the HDR category now also accepts (addIBL itself is extension- + // agnostic, but this locks in that .exr assets flow through the same + // guard clauses as .hdr instead of being silently ignored). + let hdr = base.appendingPathComponent("HDR", isDirectory: true) + try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true) + + let nonExistentEXRPath = hdr.appendingPathComponent("nonexistent.exr") + let exrAsset = Asset(name: "nonexistent.exr", category: "HDR", path: nonExistentEXRPath, isFolder: false) + + XCTAssertFalse(FileManager.default.fileExists(atPath: nonExistentEXRPath.path), "EXR file should not exist") + + addIBL(asset: exrAsset) + + XCTAssertFalse(iblSuccessful, "iblSuccessful should remain false for non-existent EXR file") + XCTAssertFalse(applyIBL, "applyIBL should remain false for non-existent EXR file") + } + } + func test_addIBLConditionalLogicForSuccess() throws { try withTempDirectory { base in // This test validates the conditional logic in addIBL for successful HDR loading diff --git a/Tests/UntoldEditorTests/InspectorViewTests.swift b/Tests/UntoldEditorTests/InspectorViewTests.swift index 3199073..a185e69 100644 --- a/Tests/UntoldEditorTests/InspectorViewTests.swift +++ b/Tests/UntoldEditorTests/InspectorViewTests.swift @@ -121,6 +121,21 @@ final class InspectorViewTests: XCTestCase { XCTAssertEqual(names, expected, "Components should be sorted as expected.") } + func test_renderComponentEditor_togglesShadowCasting_viaBinding() { + // Arrange + let e = createEntityWithName("Receiver Plane") + addTransform(to: e) + addRender(to: e) + + XCTAssertTrue(getEntityCastsShadow(entityId: e)) + + // Act: mimic the binding setter used by RenderComponentEditorView. + setEntityCastsShadow(entityId: e, false) + + // Assert + XCTAssertFalse(getEntityCastsShadow(entityId: e)) + } + func test_addComponentSheet_addsDirectionalLight_andCreatesLightComponent() { // Arrange let e = createEntityWithName("Light Holder") @@ -180,6 +195,44 @@ final class InspectorViewTests: XCTestCase { XCTAssertEqual(getLightIntensity(entityId: e), initialIntensity + 2.0, accuracy: 0.0001) } + func test_pointLightEditor_togglesShadowCasting_viaBinding() { + // Arrange + let e = createEntityWithName("Point") + addTransform(to: e) + createPointLight(entityId: e) + selectionManager.selectedEntity = e + sut = makeSUT() + + // Precondition + XCTAssertFalse(getPointLightCastsShadow(entityId: e)) + + // Act: mimic the binding setter used by PointLightEditorView + setLight(entityId: e, .point(.castsShadow(true))) + sut.selectionManager.objectWillChange.send() + + // Assert + XCTAssertTrue(getPointLightCastsShadow(entityId: e)) + } + + func test_spotLightEditor_togglesShadowCasting_viaBinding() { + // Arrange + let e = createEntityWithName("Spot") + addTransform(to: e) + createSpotLight(entityId: e) + selectionManager.selectedEntity = e + sut = makeSUT() + + // Precondition + XCTAssertFalse(getSpotLightCastsShadow(entityId: e)) + + // Act: mimic the binding setter used by SpotLightEditorView + setLight(entityId: e, .spot(.castsShadow(true))) + sut.selectionManager.objectWillChange.send() + + // Assert + XCTAssertTrue(getSpotLightCastsShadow(entityId: e)) + } + func test_removeComponent_isDisabledForDirectionalLightInSceneCompositionMode() { // Arrange let e = createEntityWithName("Light Entity")