From 796f5fbf80b897b48ea3b93f3a7bb801112773bd Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 21 Jan 2026 22:55:21 -0700 Subject: [PATCH 1/2] [Bugfix] Fixed asset fallback creation --- .../UntoldEngine/Systems/RegistrationSystem.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/UntoldEngine/Systems/RegistrationSystem.swift b/Sources/UntoldEngine/Systems/RegistrationSystem.swift index 721b2cc0..a446d2e0 100644 --- a/Sources/UntoldEngine/Systems/RegistrationSystem.swift +++ b/Sources/UntoldEngine/Systems/RegistrationSystem.swift @@ -395,8 +395,19 @@ private func loadFallbackMesh(entityId: EntityID, filename: String) async { Logger.logWarning(message: "Failed to load mesh '\(filename)'. Using fallback cube.") let fallbackMeshes = BasicPrimitives.createCube() let dummyURL = URL(fileURLWithPath: "/fallback/\(filename)") + let fallbackName = "Fallback_\(filename)" + + if hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) == false { + registerTransformComponent(entityId: entityId) + } + + if hasComponent(entityId: entityId, componentType: ScenegraphComponent.self) == false { + registerSceneGraphComponent(entityId: entityId) + } + associateMeshesToEntity(entityId: entityId, meshes: fallbackMeshes) - registerRenderComponent(entityId: entityId, meshes: fallbackMeshes, url: dummyURL, assetName: "Fallback_\(filename)") + registerRenderComponent(entityId: entityId, meshes: fallbackMeshes, url: dummyURL, assetName: fallbackName) + setEntityName(entityId: entityId, name: fallbackName) } } From 9040ad97629df2c9425739e35c4d0e94f19c2fb7 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 21 Jan 2026 23:00:06 -0700 Subject: [PATCH 2/2] [Patch] Fixed the serialization of primitives --- .../UntoldEngine/Scenes/SceneSerializer.swift | 83 ++++++++++++++----- .../SceneSerializerTest.swift | 35 ++++++++ 2 files changed, 95 insertions(+), 23 deletions(-) diff --git a/Sources/UntoldEngine/Scenes/SceneSerializer.swift b/Sources/UntoldEngine/Scenes/SceneSerializer.swift index 668535a4..2b3c1cec 100644 --- a/Sources/UntoldEngine/Scenes/SceneSerializer.swift +++ b/Sources/UntoldEngine/Scenes/SceneSerializer.swift @@ -172,6 +172,30 @@ struct EntityData: Codable { var assetInstance: AssetInstanceData? = nil } +private func isProceduralAssetURL(_ url: URL) -> Bool { + let path = url.path + return path.hasPrefix("/primitive/") || path.hasPrefix("/fallback/") +} + +private func createProceduralMeshes(assetName: String) -> [Mesh] { + let typeName = assetName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if typeName.contains("sphere") { + return BasicPrimitives.createSphere() + } + if typeName.contains("plane") { + return BasicPrimitives.createPlane() + } + if typeName.contains("cylinder") { + return BasicPrimitives.createCylinder() + } + if typeName.contains("cone") { + return BasicPrimitives.createCone() + } + // Default to cube + return BasicPrimitives.createCube() +} + public func serializeScene() -> SceneData { var sceneData = SceneData() var entityIdToUUID: [EntityID: UUID] = [:] @@ -686,10 +710,17 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM // Legacy rendering component workflow (backward compatibility) let filename = sceneDataEntity.assetURL.deletingPathExtension().lastPathComponent let withExtension = sceneDataEntity.assetURL.pathExtension + let isProcedural = isProceduralAssetURL(sceneDataEntity.assetURL) switch meshLoadingMode { case .sync: - setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) - applyLocalTransform() + if isProcedural { + let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName) + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: sceneDataEntity.assetName) + applyLocalTransform() + } else { + setEntityMesh(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) + applyLocalTransform() + } // Setup animations (skeleton is now available) if sceneDataEntity.hasAnimationComponent == true { @@ -704,29 +735,35 @@ public func deserializeScene(sceneData: SceneData, meshLoadingMode: MeshLoadingM } } case .asyncDefault: - let fallbackLabel = withExtension.isEmpty ? filename : "\(filename).\(withExtension)" - let meshLabel = sceneDataEntity.name.isEmpty ? fallbackLabel : sceneDataEntity.name - setEntityMeshAsync(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) { success in - Task { - await MainActor.run { - applyLocalTransform() - if success { - Logger.log(message: "✅ Mesh loaded for \(meshLabel)") - - // Setup animations (skeleton is now available) - if sceneDataEntity.hasAnimationComponent == true { - for animations in sceneDataEntity.animations { - let animationFilename = animations.deletingPathExtension().lastPathComponent - let animationFilenameExt = animations.pathExtension - setEntityAnimations(entityId: entityId, filename: animationFilename, withExtension: animationFilenameExt, name: animationFilename) - changeAnimation(entityId: entityId, name: animationFilename) - } - if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { - animationComponent.animationsFilenames = sceneDataEntity.animations + if isProcedural { + let meshes = createProceduralMeshes(assetName: sceneDataEntity.assetName) + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: sceneDataEntity.assetName) + applyLocalTransform() + } else { + let fallbackLabel = withExtension.isEmpty ? filename : "\(filename).\(withExtension)" + let meshLabel = sceneDataEntity.name.isEmpty ? fallbackLabel : sceneDataEntity.name + setEntityMeshAsync(entityId: entityId, filename: filename, withExtension: withExtension, assetName: sceneDataEntity.assetName) { success in + Task { + await MainActor.run { + applyLocalTransform() + if success { + Logger.log(message: "✅ Mesh loaded for \(meshLabel)") + + // Setup animations (skeleton is now available) + if sceneDataEntity.hasAnimationComponent == true { + for animations in sceneDataEntity.animations { + let animationFilename = animations.deletingPathExtension().lastPathComponent + let animationFilenameExt = animations.pathExtension + setEntityAnimations(entityId: entityId, filename: animationFilename, withExtension: animationFilenameExt, name: animationFilename) + changeAnimation(entityId: entityId, name: animationFilename) + } + if let animationComponent = scene.get(component: AnimationComponent.self, for: entityId) { + animationComponent.animationsFilenames = sceneDataEntity.animations + } } + } else { + Logger.logWarning(message: "❌ Mesh failed for \(meshLabel)") } - } else { - Logger.logWarning(message: "❌ Mesh failed for \(meshLabel)") } } } diff --git a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift index c0580edc..d391aaad 100644 --- a/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift +++ b/Tests/UntoldEngineRenderTests/SceneSerializerTest.swift @@ -1148,4 +1148,39 @@ final class SceneSerializerTests: BaseRenderSetup { // applyIBL should remain false (its default value) XCTAssertFalse(applyIBL, "applyIBL should be false by default when no environment data is provided") } + + func testProceduralMeshSerializationDeserialization() { + destroyAllEntities() + scene.finalizePendingDestroys() + entityNameMap.removeAll() + reverseEntityNameMap.removeAll() + + let entityId = createEntity() + setEntityName(entityId: entityId, name: "ProcCube") + let meshes = BasicPrimitives.createCube() + setEntityMeshDirect(entityId: entityId, meshes: meshes, assetName: "Cube") + + let sceneData = serializeScene() + + destroyAllEntities() + scene.finalizePendingDestroys() + entityNameMap.removeAll() + reverseEntityNameMap.removeAll() + + deserializeScene(sceneData: sceneData, meshLoadingMode: .sync) + + guard let loadedId = findEntity(name: "ProcCube") else { + XCTFail("Expected to find procedural entity after deserialization") + return + } + + guard let renderComponent = scene.get(component: RenderComponent.self, for: loadedId) else { + XCTFail("Expected RenderComponent on deserialized procedural entity") + return + } + + XCTAssertFalse(renderComponent.mesh.isEmpty, "Procedural mesh should be recreated on deserialize") + XCTAssertEqual(renderComponent.assetName, "Cube") + XCTAssertTrue(renderComponent.assetURL.path.hasPrefix("/primitive/")) + } }