Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 60 additions & 23 deletions Sources/UntoldEngine/Scenes/SceneSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand Down Expand Up @@ -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 {
Expand All @@ -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)")
}
}
}
Expand Down
13 changes: 12 additions & 1 deletion Sources/UntoldEngine/Systems/RegistrationSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
35 changes: 35 additions & 0 deletions Tests/UntoldEngineRenderTests/SceneSerializerTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/"))
}
}
Loading