From 9122f788913c04192a165d740cad7b41efd7f2e8 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 21 Jul 2026 06:36:19 -0700 Subject: [PATCH 1/7] [Patch] fix area light mesh culling --- .../UntoldEditor/Renderer/EditorRenderPasses.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index 7b35f45..f0810a8 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -604,6 +604,7 @@ 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 @@ -621,6 +622,7 @@ extension RenderPasses { } 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 +633,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 +657,10 @@ extension RenderPasses { } } + if isAreaLight { + renderEncoder.setCullMode(.back) + } + } else { var modelMatrix = worldTransform.space renderEncoder.setVertexBytes( From 390e1f45f85a7eb09f18473bc1a6fc407b1e53fa Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 22 Jul 2026 10:18:36 -0700 Subject: [PATCH 2/7] [Patch] Added shadow cast to point lights --- .../UntoldEditor/Editor/InspectorView.swift | 53 +++++++++++++++++++ .../InspectorViewTests.swift | 53 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index cadf8cf..7ffdf7f 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,7 @@ struct PointLightEditorView: View { let intensity: Float = getLightIntensity(entityId: entityId) let falloff: Float = getLightFalloff(entityId: entityId) let radius: Float = getLightRadius(entityId: entityId) + let castsShadow: Bool = getPointLightCastsShadow(entityId: entityId) TextInputVectorView(label: "Color", value: Binding( get: { color }, @@ -1380,6 +1396,18 @@ struct PointLightEditorView: View { )) .frame(maxWidth: .infinity, alignment: .leading) } + + Toggle(isOn: Binding( + get: { castsShadow }, + set: { enabled in + setLight(entityId: entityId, .point(.castsShadow(enabled))) + refreshView() + } + )) { + Text("Cast Shadows") + } + .toggleStyle(.checkbox) + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -1397,7 +1425,9 @@ struct SpotLightEditorView: View { let color: simd_float3 = getLightColor(entityId: entityId) let falloff: Float = getLightFalloff(entityId: entityId) let intensity: Float = getLightIntensity(entityId: entityId) + let radius: Float = getLightRadius(entityId: entityId) let coneAngle: Float = getLightConeAngle(entityId: entityId) + let castsShadow: Bool = getSpotLightCastsShadow(entityId: entityId) TextInputVectorView(label: "Color", value: Binding( get: { color }, set: { newColor in @@ -1424,6 +1454,17 @@ struct SpotLightEditorView: View { )) .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 @@ -1433,6 +1474,18 @@ struct SpotLightEditorView: View { )) .frame(maxWidth: .infinity, alignment: .leading) } + + Toggle(isOn: Binding( + get: { castsShadow }, + set: { enabled in + setLight(entityId: entityId, .spot(.castsShadow(enabled))) + refreshView() + } + )) { + Text("Cast Shadows") + } + .toggleStyle(.checkbox) + .frame(maxWidth: .infinity, alignment: .leading) } } } 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") From e71c1981919ad4cf9c281ccf7390194f2d671d1d Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 22 Jul 2026 10:19:04 -0700 Subject: [PATCH 3/7] [Bugfix] Fixed warning messages --- Sources/UntoldEditor/Editor/EditorView.swift | 9 ++++++--- Sources/UntoldEditor/Systems/GizmoSystem.swift | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) 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/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 } From b43981650257291ec8abdbff351b1932c2d64042 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Thu, 23 Jul 2026 22:57:24 -0700 Subject: [PATCH 4/7] [Patch] Align light inspector controls and debug meshes with Blender lighting --- .../UntoldEditor/Editor/InspectorView.swift | 122 +++++++++++++++--- .../Renderer/EditorRenderPasses.swift | 10 +- 2 files changed, 108 insertions(+), 24 deletions(-) diff --git a/Sources/UntoldEditor/Editor/InspectorView.swift b/Sources/UntoldEditor/Editor/InspectorView.swift index 7ffdf7f..c68415a 100644 --- a/Sources/UntoldEditor/Editor/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/InspectorView.swift @@ -1357,6 +1357,7 @@ 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( @@ -1369,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) @@ -1378,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() } )) @@ -1397,6 +1398,15 @@ 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 @@ -1404,7 +1414,7 @@ struct PointLightEditorView: View { refreshView() } )) { - Text("Cast Shadows") + Text("Shadow") } .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .leading) @@ -1426,7 +1436,8 @@ struct SpotLightEditorView: View { let falloff: Float = getLightFalloff(entityId: entityId) let intensity: Float = getLightIntensity(entityId: entityId) let radius: Float = getLightRadius(entityId: entityId) - let coneAngle: Float = getLightConeAngle(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 }, @@ -1437,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) @@ -1445,10 +1456,11 @@ 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() } )) @@ -1468,13 +1480,22 @@ struct SpotLightEditorView: View { 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 @@ -1482,7 +1503,7 @@ struct SpotLightEditorView: View { refreshView() } )) { - Text("Cast Shadows") + Text("Shadow") } .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .leading) @@ -1502,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 @@ -1512,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/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index f0810a8..beb1906 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -609,16 +609,18 @@ extension RenderPasses { 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) From 7ac94151946bbdb9ec6c13b78c5be447e41ce2c8 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Fri, 24 Jul 2026 21:27:22 -0700 Subject: [PATCH 5/7] [Patch] Allow .exr import alongside .hdr in Asset Browser's HDR category --- .../Editor/AssetBrowserView.swift | 12 +++--- .../AssetBrowserViewTests.swift | 39 +++++++++++++++++++ .../EnvironmentViewTests.swift | 21 ++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) 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/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 From d519be9527145021643533598a1c0944c430adc4 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Sat, 25 Jul 2026 14:20:21 -0700 Subject: [PATCH 6/7] [Patch] Enable the color lut in the editor --- .../UntoldEditor/Editor/EnvironmentView.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 } } From ed58fb8238f228d0b567c7efb9ca8aecf0325a78 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Wed, 12 Aug 2026 05:52:03 -0700 Subject: [PATCH 7/7] [Release] Preparing release 0.16.0 --- CHANGELOG.md | 7 +++++++ Package.swift | 2 +- Sources/UntoldEditor/Editor/StarterStreamModels.swift | 2 +- Sources/UntoldEditor/Editor/ToolbarView.swift | 2 +- Sources/UntoldEditor/main.swift | 4 ++-- 5 files changed, 12 insertions(+), 5 deletions(-) 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/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/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())