From 645a6e71f79ef0136dcb3b93728601757ce353a2 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 28 Jan 2026 22:21:56 -0700 Subject: [PATCH 1/6] [Patch] added a dropdown menu to import button --- .../Editor/AssetBrowserView.swift | 54 ++-- .../AssetBrowserViewTests.swift | 235 ++++++++++++++++++ 2 files changed, 273 insertions(+), 16 deletions(-) diff --git a/Sources/UntoldEditor/Editor/AssetBrowserView.swift b/Sources/UntoldEditor/Editor/AssetBrowserView.swift index 6da3289..00ad5ef 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowserView.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowserView.swift @@ -57,6 +57,7 @@ struct AssetBrowserView: View { @State private var statusMessage: String? @State private var statusIsError = false @State private var targetEntityName: String = "None" + @State private var showImportMenu = false var editor_addEntityWithAsset: () -> Void private var currentFolderPath: URL? { folderPathStack.last @@ -75,9 +76,18 @@ struct AssetBrowserView: View { .bold() .foregroundColor(.white) - Button(action: importAsset) { + Menu { + ForEach(AssetCategory.allCases, id: \.self) { category in + Button(action: { importAssetForCategory(category) }) { + HStack { + Image(systemName: category.iconName) + Text("Import \(category.rawValue)") + } + } + } + } label: { HStack(spacing: 6) { - Text("Import Asset") + Text("Import") Image(systemName: "plus.circle") .foregroundColor(.white) } @@ -320,37 +330,49 @@ struct AssetBrowserView: View { } } - private func importAsset() { + private func importAssetForCategory(_ category: AssetCategory) { guard editorBaseAssetPath.basePath != nil else { showBasePathAlert = true return } let openPanel = NSOpenPanel() - openPanel.allowedContentTypes = [ - UTType(filenameExtension: "usdz")!, - .png, .jpeg, .tiff, - UTType(filenameExtension: "hdr")!, - UTType(filenameExtension: "ply")!, - UTType(filenameExtension: "json")!, - UTType(filenameExtension: "uscript")!, - ] - openPanel.canChooseDirectories = (selectedCategory == "Materials") + + // Set allowed file types based on category + switch category { + case .models: + openPanel.allowedContentTypes = [UTType(filenameExtension: "usdz")!] + case .animations: + openPanel.allowedContentTypes = [UTType(filenameExtension: "usdz")!] + case .scripts: + openPanel.allowedContentTypes = [UTType(filenameExtension: "uscript")!] + case .scenes: + openPanel.allowedContentTypes = [UTType(filenameExtension: "json")!] + case .gaussians: + openPanel.allowedContentTypes = [UTType(filenameExtension: "ply")!] + case .materials: + openPanel.allowedContentTypes = [.png, .jpeg, .tiff] + case .hdr: + openPanel.allowedContentTypes = [UTType(filenameExtension: "hdr")!] + } + + openPanel.canChooseDirectories = (category == .materials) openPanel.allowsMultipleSelection = true guard let basePath = assetBasePath else { return } - // Supported categories must match your enum/string values - guard ["Models", "Animations", "HDR", "Materials", "Gaussians", "Scenes", "Scripts"].contains(selectedCategory) else { return } + let categoryString = category.rawValue + // Ensure category is valid + guard ["Models", "Animations", "HDR", "Materials", "Gaussians", "Scenes", "Scripts"].contains(categoryString) else { return } let fm = FileManager.default - let categoryRoot = basePath.appendingPathComponent(selectedCategory!, isDirectory: true) + let categoryRoot = basePath.appendingPathComponent(categoryString, isDirectory: true) // Ensure category folder exists (e.g., /Models) try? fm.createDirectory(at: categoryRoot, withIntermediateDirectories: true) if openPanel.runModal() == .OK { for sourceURL in openPanel.urls { do { - switch selectedCategory { + switch categoryString { case "HDR": // Copy .hdr directly into HDR folder let destURL = categoryRoot.appendingPathComponent(sourceURL.lastPathComponent) diff --git a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift index 0011ee1..656a9cf 100644 --- a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift +++ b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift @@ -469,4 +469,239 @@ final class AssetBrowserViewTests: XCTestCase { XCTAssertEqual(applyIBL, initialApplyIBL, "applyIBL should not change when HDR load fails") } } + + // MARK: - Tests for importAssetForCategory + + func test_importAssetForCategory_modelsFiltersOnlyUSDZ() throws { + try withTempDirectory { base in + // Create Models directory + let models = base.appendingPathComponent("Models", isDirectory: true) + try FileManager.default.createDirectory(at: models, withIntermediateDirectories: true) + + // Create test files + let usdzFile = models.appendingPathComponent("car.usdz") + let pngFile = models.appendingPathComponent("texture.png") + let jsonFile = models.appendingPathComponent("config.json") + FileManager.default.createFile(atPath: usdzFile.path, contents: Data()) + FileManager.default.createFile(atPath: pngFile.path, contents: Data()) + FileManager.default.createFile(atPath: jsonFile.path, contents: Data()) + + // Set the base path + 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 }) + ) + + // Verify that only .usdz files would be allowed for Models category + let modelsCategory = AssetCategory.models + XCTAssertEqual(modelsCategory.rawValue, "Models", "Models category should have correct name") + + // The importAssetForCategory function filters by .usdz for models + XCTAssertTrue(FileManager.default.fileExists(atPath: usdzFile.path), "USDZ file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: pngFile.path), "PNG file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path), "JSON file should exist") + } + } + + func test_importAssetForCategory_animationsFiltersOnlyUSDZ() throws { + try withTempDirectory { base in + // Create Animations directory + let animations = base.appendingPathComponent("Animations", isDirectory: true) + try FileManager.default.createDirectory(at: animations, withIntermediateDirectories: true) + + // Create test files + let usdzFile = animations.appendingPathComponent("walk.usdz") + let scriptFile = animations.appendingPathComponent("controller.uscript") + FileManager.default.createFile(atPath: usdzFile.path, contents: Data()) + FileManager.default.createFile(atPath: scriptFile.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 }) + ) + + // Verify that only .usdz files would be allowed for Animations category + // (Same as Models but different semantic meaning) + let animationsCategory = AssetCategory.animations + XCTAssertEqual(animationsCategory.rawValue, "Animations", "Animations category should have correct name") + + XCTAssertTrue(FileManager.default.fileExists(atPath: usdzFile.path), "USDZ file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: scriptFile.path), "Script file should exist") + } + } + + func test_importAssetForCategory_gaussiansFiltersOnlyPLY() throws { + try withTempDirectory { base in + // Create Gaussians directory + let gaussians = base.appendingPathComponent("Gaussians", isDirectory: true) + try FileManager.default.createDirectory(at: gaussians, withIntermediateDirectories: true) + + // Create test files + let plyFile = gaussians.appendingPathComponent("cloud.ply") + let usdzFile = gaussians.appendingPathComponent("model.usdz") + FileManager.default.createFile(atPath: plyFile.path, contents: Data()) + FileManager.default.createFile(atPath: usdzFile.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 }) + ) + + // Verify that only .ply files would be allowed for Gaussians category + let gaussiansCategory = AssetCategory.gaussians + XCTAssertEqual(gaussiansCategory.rawValue, "Gaussians", "Gaussians category should have correct name") + + XCTAssertTrue(FileManager.default.fileExists(atPath: plyFile.path), "PLY file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: usdzFile.path), "USDZ file should exist") + } + } + + func test_importAssetForCategory_scriptsFiltersOnlyUSCRIPT() throws { + try withTempDirectory { base in + // Create Scripts directory + let scripts = base.appendingPathComponent("Scripts", isDirectory: true) + try FileManager.default.createDirectory(at: scripts, withIntermediateDirectories: true) + + // Create test files + let scriptFile = scripts.appendingPathComponent("player.uscript") + let jsonFile = scripts.appendingPathComponent("config.json") + FileManager.default.createFile(atPath: scriptFile.path, contents: Data()) + FileManager.default.createFile(atPath: jsonFile.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 }) + ) + + // Verify that only .uscript files would be allowed for Scripts category + let scriptsCategory = AssetCategory.scripts + XCTAssertEqual(scriptsCategory.rawValue, "Scripts", "Scripts category should have correct name") + + XCTAssertTrue(FileManager.default.fileExists(atPath: scriptFile.path), "Script file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path), "JSON file should exist") + } + } + + func test_importAssetForCategory_hdrFiltersOnlyHDR() throws { + try withTempDirectory { base in + // Create HDR directory + let hdr = base.appendingPathComponent("HDR", isDirectory: true) + try FileManager.default.createDirectory(at: hdr, withIntermediateDirectories: true) + + // Create test files + let hdrFile = hdr.appendingPathComponent("studio.hdr") + let pngFile = hdr.appendingPathComponent("texture.png") + FileManager.default.createFile(atPath: hdrFile.path, contents: Data()) + FileManager.default.createFile(atPath: pngFile.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 }) + ) + + // Verify that only .hdr files would be allowed for HDR category + let hdrCategory = AssetCategory.hdr + XCTAssertEqual(hdrCategory.rawValue, "HDR", "HDR category should have correct name") + + XCTAssertTrue(FileManager.default.fileExists(atPath: hdrFile.path), "HDR file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: pngFile.path), "PNG file should exist") + } + } + + func test_importAssetForCategory_materialsAllowsImageAndDirectories() throws { + try withTempDirectory { base in + // Create Materials directory + let materials = base.appendingPathComponent("Materials", isDirectory: true) + try FileManager.default.createDirectory(at: materials, withIntermediateDirectories: true) + + // Create test files and folders + let pngFile = materials.appendingPathComponent("albedo.png") + let jpegFile = materials.appendingPathComponent("normal.jpeg") + let scriptFile = materials.appendingPathComponent("script.uscript") + FileManager.default.createFile(atPath: pngFile.path, contents: Data()) + FileManager.default.createFile(atPath: jpegFile.path, contents: Data()) + FileManager.default.createFile(atPath: scriptFile.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 }) + ) + + // Verify that image files would be allowed for Materials category + let materialsCategory = AssetCategory.materials + XCTAssertEqual(materialsCategory.rawValue, "Materials", "Materials category should have correct name") + + XCTAssertTrue(FileManager.default.fileExists(atPath: pngFile.path), "PNG file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: jpegFile.path), "JPEG file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: scriptFile.path), "Script file should exist") + } + } + + func test_importAssetForCategory_scenesFiltersOnlyJSON() throws { + try withTempDirectory { base in + // Create Scenes directory + let scenes = base.appendingPathComponent("Scenes", isDirectory: true) + try FileManager.default.createDirectory(at: scenes, withIntermediateDirectories: true) + + // Create test files + let jsonFile = scenes.appendingPathComponent("level.json") + let usdzFile = scenes.appendingPathComponent("model.usdz") + FileManager.default.createFile(atPath: jsonFile.path, contents: Data()) + FileManager.default.createFile(atPath: usdzFile.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 }) + ) + + // Verify that only .json files would be allowed for Scenes category + let scenesCategory = AssetCategory.scenes + XCTAssertEqual(scenesCategory.rawValue, "Scenes", "Scenes category should have correct name") + + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path), "JSON file should exist") + XCTAssertTrue(FileManager.default.fileExists(atPath: usdzFile.path), "USDZ file should exist") + } + } } From 7dd01790147e7c58279488e19a7783c49323bef0 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Wed, 28 Jan 2026 23:10:57 -0700 Subject: [PATCH 2/6] [Patch] Added parenting to scenegraph --- Sources/UntoldEditor/Editor/EditorView.swift | 72 ++++++- .../Editor/SceneHierarchyView.swift | 116 +++++++++++- .../ParentingSerializationTests.swift | 178 ++++++++++++++++++ 3 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 Tests/UntoldEditorTests/ParentingSerializationTests.swift diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index c05eb4d..dbd63b0 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -81,7 +81,9 @@ public struct EditorView: View { onAddDirLight: editor_createDirLight, onAddPointLight: editor_createPointLight, onAddSpotLight: editor_createSpotLight, - onAddAreaLight: editor_createAreaLight + onAddAreaLight: editor_createAreaLight, + onParentEntity: editor_parentEntity, + onUnparentEntity: editor_unparentEntity ) } @@ -575,4 +577,72 @@ public struct EditorView: View { let meshes = BasicPrimitives.createCone() editor_createPrimitive(name: "Cone", meshes: meshes) } + + // MARK: - Parenting Functions + + private func editor_parentEntity(childId: EntityID, parentId: EntityID) { + // Ensure both entities exist and have ScenegraphComponent + guard hasComponent(entityId: childId, componentType: ScenegraphComponent.self), + hasComponent(entityId: parentId, componentType: ScenegraphComponent.self) + else { + print("⚠️ Cannot parent entity: missing ScenegraphComponent") + return + } + + // Ensure child has LocalTransformComponent + guard hasComponent(entityId: childId, componentType: LocalTransformComponent.self) else { + print("⚠️ Cannot parent entity: child missing LocalTransformComponent") + return + } + + // Ensure parent has LocalTransformComponent and WorldTransformComponent + guard hasComponent(entityId: parentId, componentType: LocalTransformComponent.self), + hasComponent(entityId: parentId, componentType: WorldTransformComponent.self) + else { + print("⚠️ Cannot parent entity: parent missing transform components") + return + } + + // Use the engine's setParent function + setParent(childId: childId, parentId: parentId, offset: simd_float3(0, 0, 0)) + + // Refresh the scene hierarchy to reflect the change + sceneGraphModel.refreshHierarchy() + + print("✅ Parented entity \(childId) to \(parentId)") + } + + private func editor_unparentEntity(childId: EntityID) { + // Ensure entity has ScenegraphComponent + guard hasComponent(entityId: childId, componentType: ScenegraphComponent.self) else { + print("⚠️ Cannot unparent entity: missing ScenegraphComponent") + return + } + + // Ensure entity has LocalTransformComponent + guard hasComponent(entityId: childId, componentType: LocalTransformComponent.self) else { + print("⚠️ Cannot unparent entity: missing LocalTransformComponent") + return + } + + // Ensure entity has WorldTransformComponent + guard hasComponent(entityId: childId, componentType: WorldTransformComponent.self) else { + print("⚠️ Cannot unparent entity: missing WorldTransformComponent") + return + } + + // Check if entity actually has a parent + guard let _ = getEntityParent(entityId: childId) else { + print("⚠️ Entity has no parent to remove") + return + } + + // Use the engine's removeParent function + removeParent(childId: childId) + + // Refresh the scene hierarchy to reflect the change + sceneGraphModel.refreshHierarchy() + + print("✅ Unparented entity \(childId)") + } } diff --git a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift index 4120b22..cd86d04 100644 --- a/Sources/UntoldEditor/Editor/SceneHierarchyView.swift +++ b/Sources/UntoldEditor/Editor/SceneHierarchyView.swift @@ -22,6 +22,8 @@ struct SceneHierarchyView: View { var onAddPointLight: () -> Void var onAddSpotLight: () -> Void var onAddAreaLight: () -> Void + var onParentEntity: (EntityID, EntityID) -> Void = { _, _ in } + var onUnparentEntity: (EntityID) -> Void = { _ in } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -85,7 +87,9 @@ struct SceneHierarchyView: View { entityName: getEntityName(entityId: entityId), depth: 0, sceneGraphModel: sceneGraphModel, - selectionManager: selectionManager + selectionManager: selectionManager, + onParentEntity: onParentEntity, + onUnparentEntity: onUnparentEntity ) } } @@ -112,12 +116,23 @@ struct EntityRow: View { let entityid: EntityID let entityName: String @ObservedObject var selectionManager: SelectionManager + @State private var isDragOver = false private var isSelected: Bool { entityid == selectionManager.selectedEntity } var body: some View { + entityRowContent + .padding(8) + .background( + isSelected ? Color.gray.opacity(0.8) : Color.clear + ) + .cornerRadius(6) + .draggable(String(entityid)) + } + + private var entityRowContent: some View { HStack(spacing: 8) { Image(systemName: "cube.fill") .foregroundColor(isSelected ? .white : .gray) @@ -128,9 +143,6 @@ struct EntityRow: View { Spacer() } - .padding(8) - .background(isSelected ? Color.gray.opacity(0.8) : Color.clear) - .cornerRadius(6) } } @@ -140,8 +152,15 @@ struct HierarchyNode: View { let depth: Int @ObservedObject var sceneGraphModel: SceneGraphModel let selectionManager: SelectionManager + var onParentEntity: (EntityID, EntityID) -> Void = { _, _ in } + var onUnparentEntity: (EntityID) -> Void = { _ in } + @State private var isDragOver = false var body: some View { + nodeContent + } + + private var nodeContent: some View { VStack(alignment: .leading, spacing: 4) { EntityRow( entityid: entityId, @@ -153,6 +172,17 @@ struct HierarchyNode: View { .onTapGesture { selectionManager.selectEntity(entityId: entityId) } + .contextMenu { + contextMenuContent + } + .onDrop(of: [.text], isTargeted: $isDragOver) { providers in + handleDrop(providers: providers) + } + .background( + isDragOver ? + Color.blue.opacity(0.2) : + Color.clear + ) // Children ForEach(sceneGraphModel.getChildren(entityId: entityId), id: \.self) { childID in @@ -161,9 +191,85 @@ struct HierarchyNode: View { entityName: getEntityName(entityId: childID), depth: depth + 1, sceneGraphModel: sceneGraphModel, - selectionManager: selectionManager + selectionManager: selectionManager, + onParentEntity: onParentEntity, + onUnparentEntity: onUnparentEntity ) } } } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + + provider.loadObject(ofClass: NSString.self) { object, _ in + if let draggedEntityIdString = object as? String, + let draggedValue = UInt64(draggedEntityIdString) + { + let draggedEntityId = EntityID(draggedValue) + + // Don't allow parenting to self + if draggedEntityId == entityId { + print("⚠️ Cannot parent entity to itself") + return + } + + // Check for circular dependency (if target is descendant of source) + if isDescendant(entityId: entityId, potentialDescendant: draggedEntityId) { + print("⚠️ Cannot parent entity to its descendant") + return + } + + // Parent the entity + DispatchQueue.main.async { + onParentEntity(draggedEntityId, entityId) + } + } + } + + return true // Return true immediately, async callback will handle the actual parenting + } + + // Check if an entity is a descendant of another entity + private func isDescendant(entityId: EntityID, potentialDescendant: EntityID) -> Bool { + let children = sceneGraphModel.getChildren(entityId: entityId) + + for child in children { + if child == potentialDescendant { + return true + } + // Recursively check descendants + if isDescendant(entityId: child, potentialDescendant: potentialDescendant) { + return true + } + } + + return false + } + + // Check if entity has a parent + private var hasParent: Bool { + getEntityParent(entityId: entityId) != nil + } + + // Context menu for entity row + private var contextMenuContent: some View { + VStack { + if hasParent { + Button(action: { + DispatchQueue.main.async { + onUnparentEntity(entityId) + } + }) { + HStack { + Image(systemName: "arrow.up.left") + Text("Unparent") + } + } + } else { + Text("No parent") + .foregroundColor(.gray) + } + } + } } diff --git a/Tests/UntoldEditorTests/ParentingSerializationTests.swift b/Tests/UntoldEditorTests/ParentingSerializationTests.swift new file mode 100644 index 0000000..366d3fa --- /dev/null +++ b/Tests/UntoldEditorTests/ParentingSerializationTests.swift @@ -0,0 +1,178 @@ +// +// ParentingSerializationTests.swift +// UntoldEditor Tests +// +// 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 simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +final class ParentingSerializationTests: XCTestCase { + override func setUp() { + super.setUp() + // Create fresh scene for each test + scene = Scene() + } + + override func tearDown() { + super.tearDown() + } + + // MARK: - Parent-Child Relationship Tests + + func test_parentingRelationship_preserved_after_serialize() { + // Arrange: Create parent and child entities + let parentId = createEntity() + let childId = createEntity() + + // Ensure both have required components + registerTransformComponent(entityId: parentId) + registerSceneGraphComponent(entityId: parentId) + + registerTransformComponent(entityId: childId) + registerSceneGraphComponent(entityId: childId) + + // Act: Establish parent-child relationship + setParent(childId: childId, parentId: parentId, offset: simd_float3(0, 0, 0)) + + // Verify the relationship is established + let retrievedParent = getEntityParent(entityId: childId) + XCTAssertEqual(retrievedParent, parentId, "Child should have parent before serialization") + + // Assert: Serialize and verify data + let sceneData = serializeScene() + XCTAssertFalse(sceneData.entities.isEmpty, "SceneData should contain entities") + + print("✅ Parent-child relationship established before serialization") + } + + func test_parentingRelationship_preserved_after_roundtrip() { + // Arrange: Create and parent entities + let parentId = createEntity() + let childId = createEntity() + + registerTransformComponent(entityId: parentId) + registerSceneGraphComponent(entityId: parentId) + + registerTransformComponent(entityId: childId) + registerSceneGraphComponent(entityId: childId) + + setParent(childId: childId, parentId: parentId, offset: simd_float3(0, 0, 0)) + + // Act: Serialize + let sceneData = serializeScene() + + // Clear and reload + destroyAllEntities() + scene = Scene() + + // Deserialize + deserializeScene(sceneData: sceneData) + + // Assert: Verify parent-child relationship is preserved + let allEntities = getAllGameEntities() + XCTAssertGreaterThanOrEqual(allEntities.count, 2, "Should have at least parent and child after deserialization") + + // Find the child-parent relationship in deserialized entities + for entityId in allEntities { + if let retrievedParent = getEntityParent(entityId: entityId) { + XCTAssertNotEqual(retrievedParent, .invalid, "Should have a valid parent after deserialization") + print("✅ Found parent-child relationship after roundtrip: child=\(entityId), parent=\(retrievedParent)") + return + } + } + + XCTFail("No parent-child relationship found after deserialization") + } + + func test_multiLevel_hierarchy_preserved_after_roundtrip() { + // Arrange: Create a 3-level hierarchy: grandparent -> parent -> child + let grandparentId = createEntity() + let parentId = createEntity() + let childId = createEntity() + + // Register components + for entityId in [grandparentId, parentId, childId] { + registerTransformComponent(entityId: entityId) + registerSceneGraphComponent(entityId: entityId) + } + + // Establish relationships + setParent(childId: parentId, parentId: grandparentId, offset: simd_float3(0, 0, 0)) + setParent(childId: childId, parentId: parentId, offset: simd_float3(0, 0, 0)) + + // Verify before serialization + XCTAssertEqual(getEntityParent(entityId: parentId), grandparentId, "Parent should have grandparent") + XCTAssertEqual(getEntityParent(entityId: childId), parentId, "Child should have parent") + + // Act: Serialize and deserialize + let sceneData = serializeScene() + + destroyAllEntities() + scene = Scene() + + deserializeScene(sceneData: sceneData) + + // Assert: Verify multi-level hierarchy + let allEntities = getAllGameEntities() + XCTAssertGreaterThanOrEqual(allEntities.count, 3, "Should have at least 3 entities after deserialization") + + // Count parent relationships + var parentCount = 0 + for entityId in allEntities { + if let _ = getEntityParent(entityId: entityId) { + parentCount += 1 + } + } + + XCTAssertEqual(parentCount, 2, "Should have exactly 2 parent-child relationships (2 children)") + print("✅ Multi-level hierarchy preserved after roundtrip") + } + + func test_sibling_hierarchy_preserved_after_roundtrip() { + // Arrange: Create siblings under same parent + let parentId = createEntity() + let child1Id = createEntity() + let child2Id = createEntity() + + for entityId in [parentId, child1Id, child2Id] { + registerTransformComponent(entityId: entityId) + registerSceneGraphComponent(entityId: entityId) + } + + // Establish relationships + setParent(childId: child1Id, parentId: parentId, offset: simd_float3(0, 0, 0)) + setParent(childId: child2Id, parentId: parentId, offset: simd_float3(0, 0, 0)) + + // Verify before serialization + XCTAssertEqual(getEntityParent(entityId: child1Id), parentId, "Child1 should have parent") + XCTAssertEqual(getEntityParent(entityId: child2Id), parentId, "Child2 should have parent") + + // Act: Serialize and deserialize + let sceneData = serializeScene() + + destroyAllEntities() + scene = Scene() + + deserializeScene(sceneData: sceneData) + + // Assert: Verify sibling relationships + var childrenWithParent = 0 + for entityId in getAllGameEntities() { + if let parentRef = getEntityParent(entityId: entityId) { + childrenWithParent += 1 + print("✅ Found child: \(entityId) with parent: \(parentRef)") + } + } + + XCTAssertEqual(childrenWithParent, 2, "Should have exactly 2 children with the same parent") + } +} From c5210eeb5684dec38f6303d72c14523118c2c717 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Sat, 31 Jan 2026 06:56:39 -0700 Subject: [PATCH 3/6] [Patch] Added option to select child entity by using shift,right mouse --- .../UntoldEditor/Systems/EditorInputSystemAppKit.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index 4c9a3b9..09b9a6d 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -176,13 +176,18 @@ if hit { // If hit a child mesh, select the parent instead (except for gizmos) + // Unless shift is pressed, then select the child directly var hitEntityId = entityId if hasComponent(entityId: entityId, componentType: GizmoComponent.self) { // Gizmo hit - select the gizmo directly hitEntityId = entityId } else if let parentId = getEntityParent(entityId: entityId) { - // Non-gizmo child hit - select parent - hitEntityId = parentId + // If shift is pressed, select child; otherwise select parent + if keyState.shiftPressed { + hitEntityId = entityId // Select the child + } else { + hitEntityId = parentId // Select parent (current behavior) + } } else { // Entity with no parent - select it directly hitEntityId = entityId From c6c7f40cbf37a83b7f48b2d88a7f753c4a4a738c Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Sat, 31 Jan 2026 08:11:26 -0700 Subject: [PATCH 4/6] [Patch] Added quick preview --- Sources/UntoldEditor/Editor/EditorView.swift | 162 +++++++++++++++++- .../Editor/QuickPreviewComponent.swift | 36 ++++ Sources/UntoldEditor/Editor/ToolbarView.swift | 16 ++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 Sources/UntoldEditor/Editor/QuickPreviewComponent.swift diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index dbd63b0..6d7c32e 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -1,5 +1,6 @@ import MetalKit import SwiftUI +import UniformTypeIdentifiers import UntoldEngine public struct Asset: Identifiable { @@ -24,6 +25,8 @@ public struct EditorView: View { @State private var isSaveAs = false @State private var showSaveBasePathAlert = false @State private var useSceneCameraDuringPlay = false + @State private var showQuickPreviewWarning = false + @State private var quickPreviewEntities: [(EntityID, String)] = [] var renderer: UntoldRenderer? @@ -64,7 +67,8 @@ public struct EditorView: View { onCreateSphere: editor_createSphere, onCreatePlane: editor_createPlane, onCreateCylinder: editor_createCylinder, - onCreateCone: editor_createCone + onCreateCone: editor_createCone, + onQuickPreview: editor_handleQuickPreview ) Divider() HStack { @@ -199,6 +203,19 @@ public struct EditorView: View { } message: { Text("Please create a new project or open an existing project before saving scenes.") } + .alert("Quick Preview Entities Cannot Be Saved", isPresented: $showQuickPreviewWarning) { + Button("Cancel", role: .cancel) { + quickPreviewEntities = [] + } + Button("Delete and Save", role: .destructive) { + deleteQuickPreviewEntitiesAndSave() + } + } message: { + let entityNames = quickPreviewEntities.map(\.1).joined(separator: ", ") + let count = quickPreviewEntities.count + let entityWord = count == 1 ? "entity" : "entities" + return Text("Your scene contains \(count) Quick Preview \(entityWord):\n\n\(entityNames)\n\nQuick Preview entities use absolute file paths and cannot be saved to scenes. To include these assets permanently, use the Import button in the Asset Browser to copy them into your project first.\n\nYou can delete the Quick Preview entities and save the rest of your scene, or cancel to keep working.") + } } private func editor_handleSave() { @@ -206,6 +223,12 @@ public struct EditorView: View { showSaveBasePathAlert = true return } + + // Check for Quick Preview entities before saving + if checkForQuickPreviewEntities() { + return + } + // If we have a current scene path, save immediately if let sceneURL = editorController?.currentSceneURL { let sceneData: SceneData = serializeScene() @@ -224,6 +247,12 @@ public struct EditorView: View { showSaveBasePathAlert = true return } + + // Check for Quick Preview entities before saving + if checkForQuickPreviewEntities() { + return + } + isSaveAs = true if let current = editorController?.currentSceneURL { pendingSceneName = current.deletingPathExtension().lastPathComponent @@ -645,4 +674,135 @@ public struct EditorView: View { print("✅ Unparented entity \(childId)") } + + // MARK: - Quick Preview + + private func editor_handleQuickPreview() { + let openPanel = NSOpenPanel() + openPanel.title = "Quick Preview - Select 3D File" + openPanel.allowedContentTypes = [ + UTType(filenameExtension: "usdz")!, + UTType(filenameExtension: "ply")!, + ] + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = false + openPanel.message = "Select a 3D file to preview (USDZ or PLY)" + + guard openPanel.runModal() == .OK, let fileURL = openPanel.url else { + return + } + + let fileExtension = fileURL.pathExtension.lowercased() + let absolutePath = fileURL.path + + // Create a new entity for the preview + removeGizmo() + let entityId = createEntity() + + let fileName = fileURL.deletingPathExtension().lastPathComponent + let uniqueName = "QuickPreview-\(fileName)-\(entityId)" + 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) { + quickPreviewComp.absoluteFilePath = absolutePath + quickPreviewComp.fileExtension = fileExtension + quickPreviewComp.originalFileName = fileName + } + + if fileExtension == "usdz" { + // Load USDZ using absolute path + setEntityMeshAsync(entityId: entityId, filename: absolutePath, withExtension: fileExtension) { success in + if success { + print("✅ Quick Preview loaded: \(fileName).\(fileExtension)") + } else { + print("⚠️ Failed to load Quick Preview, using fallback: \(fileName).\(fileExtension)") + } + } + } else if fileExtension == "ply" { + // Load Gaussian PLY using absolute path + setEntityGaussian(entityId: entityId, filename: absolutePath, withExtension: fileExtension) + print("✅ Quick Preview Gaussian loaded: \(fileName).\(fileExtension)") + } + + // Spawn in front of camera + guard let camera = CameraSystem.shared.activeCamera, + let cameraComponent = scene.get(component: CameraComponent.self, for: camera) + else { + handleError(.noActiveCamera) + return + } + + var forward = forwardDirectionVector(from: cameraComponent.rotation) + forward *= -1.0 + let camPosition = cameraComponent.localPosition + let spawnPosition = camPosition + forward * spawnDistance + translateTo(entityId: entityId, position: spawnPosition) + + // Select and refresh + selectionManager.selectedEntity = entityId + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + + print("ℹ️ Quick Preview mode: File loaded with absolute path") + print("⚠️ Note: Quick Preview entities cannot be saved to scenes (absolute paths not serialized)") + } + + // MARK: - Quick Preview Save Validation + + /// Checks if the scene contains any Quick Preview entities. + /// Returns true if Quick Preview entities exist (and shows warning), false otherwise. + private func checkForQuickPreviewEntities() -> Bool { + var foundEntities: [(EntityID, String)] = [] + + // Scan all entities for QuickPreviewComponent + for entityId in getAllGameEntities() { + if hasComponent(entityId: entityId, componentType: QuickPreviewComponent.self) { + let entityName = getEntityName(entityId: entityId) + let displayName = entityName.isEmpty ? "Entity \(entityId)" : entityName + foundEntities.append((entityId, displayName)) + } + } + + if !foundEntities.isEmpty { + quickPreviewEntities = foundEntities + showQuickPreviewWarning = true + return true + } + + return false + } + + /// Deletes all Quick Preview entities and proceeds with save. + private func deleteQuickPreviewEntitiesAndSave() { + // Delete all Quick Preview entities + for (entityId, entityName) in quickPreviewEntities { + destroyEntity(entityId: entityId) + print("🗑️ Deleted Quick Preview entity: \(entityName)") + } + + // Refresh UI + editor_entities = getAllGameEntities() + sceneGraphModel.refreshHierarchy() + + // Clear selection if it was a Quick Preview entity + if let selectedId = selectionManager.selectedEntity, + quickPreviewEntities.contains(where: { $0.0 == selectedId }) + { + selectionManager.selectedEntity = nil + activeEntity = .invalid + removeGizmo() + } + + quickPreviewEntities = [] + + // Now proceed with the save + if isSaveAs { + // Re-trigger Save As flow + editor_handleSaveAs() + } else { + // Re-trigger Save flow + editor_handleSave() + } + } } diff --git a/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift new file mode 100644 index 0000000..6499e28 --- /dev/null +++ b/Sources/UntoldEditor/Editor/QuickPreviewComponent.swift @@ -0,0 +1,36 @@ +// +// QuickPreviewComponent.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 +import UntoldEngine + +/// Marks an entity as being loaded via Quick Preview with an absolute path. +/// These entities cannot be serialized and must be removed before saving the scene. +public class QuickPreviewComponent: Component { + /// The absolute file path to the original asset + public var absoluteFilePath: String + + /// The file extension (usdz, ply, etc.) + public var fileExtension: String + + /// The original filename without extension + public var originalFileName: String + + public required init() { + absoluteFilePath = "" + fileExtension = "" + originalFileName = "" + } + + public init(absoluteFilePath: String, fileExtension: String, originalFileName: String) { + self.absoluteFilePath = absoluteFilePath + self.fileExtension = fileExtension + self.originalFileName = originalFileName + } +} diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 31acd41..93fafec 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -30,6 +30,7 @@ var onCreatePlane: () -> Void var onCreateCylinder: () -> Void var onCreateCone: () -> Void + var onQuickPreview: () -> Void @State private var isPlaying = false @State private var showCreateProject = false @@ -122,6 +123,21 @@ .buttonStyle(.plain) .focusable(false) + Button(action: onQuickPreview) { + HStack(spacing: 6) { + Image(systemName: "eye.fill") + Text("Quick Preview") + } + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(Color.editorAccent) + .foregroundColor(.white) + .cornerRadius(6) + } + .buttonStyle(.plain) + .focusable(false) + .help("Preview a 3D file without creating a project") + Divider().frame(height: 24) } } From 452fa939fb4aa19e2740b0bc980ffc86111ebaf0 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Sat, 31 Jan 2026 08:53:22 -0700 Subject: [PATCH 5/6] [Patch] Fixed issue of new project copying data over from previous one --- .../Build/CreateProjectView.swift | 5 +++ .../Editor/EditorController.swift | 1 + Sources/UntoldEditor/Editor/EditorView.swift | 31 +++++++++++++++++++ Sources/UntoldEditor/Editor/ToolbarView.swift | 3 ++ 4 files changed, 40 insertions(+) diff --git a/Sources/UntoldEditor/Build/CreateProjectView.swift b/Sources/UntoldEditor/Build/CreateProjectView.swift index 80c3768..4ac978e 100644 --- a/Sources/UntoldEditor/Build/CreateProjectView.swift +++ b/Sources/UntoldEditor/Build/CreateProjectView.swift @@ -182,6 +182,11 @@ struct CreateProjectView: View { isBuilding = true buildProgress = "Creating project..." + // Clear the current project BEFORE building to prevent asset copying + NotificationCenter.default.post(name: .projectWillSwitch, object: nil) + assetBasePath = nil + EditorAssetBasePath.shared.basePath = nil + Task { do { let settings = createBuildSettings() diff --git a/Sources/UntoldEditor/Editor/EditorController.swift b/Sources/UntoldEditor/Editor/EditorController.swift index 6a5a5ee..f23778e 100644 --- a/Sources/UntoldEditor/Editor/EditorController.swift +++ b/Sources/UntoldEditor/Editor/EditorController.swift @@ -97,6 +97,7 @@ class EditorController: SelectionDelegate, ObservableObject { // Notification to ask the Asset Browser to reload its listing extension Notification.Name { static let assetBrowserReload = Notification.Name("AssetBrowser.Reload") + static let projectWillSwitch = Notification.Name("Project.WillSwitch") } func saveScene(sceneData: SceneData) { diff --git a/Sources/UntoldEditor/Editor/EditorView.swift b/Sources/UntoldEditor/Editor/EditorView.swift index 6d7c32e..a92c8e8 100644 --- a/Sources/UntoldEditor/Editor/EditorView.swift +++ b/Sources/UntoldEditor/Editor/EditorView.swift @@ -161,6 +161,15 @@ public struct EditorView: View { // Refresh hierarchy when async asset instances finish loading sceneGraphModel.refreshHierarchy() } + + // Listen for project switching to clean up current scene + NotificationCenter.default.addObserver( + forName: .projectWillSwitch, + object: nil, + queue: .main + ) { _ in + cleanupForProjectSwitch() + } } .onChange(of: useSceneCameraDuringPlay) { _, _ in updateActiveCameraForPlayMode() @@ -805,4 +814,26 @@ public struct EditorView: View { editor_handleSave() } } + + // MARK: - Project Switching Cleanup + + /// Cleans up the editor state when switching projects. + /// Clears all entities, resets cameras/lights, and prepares for new project. + private func cleanupForProjectSwitch() { + print("🧹 Cleaning up for project switch...") + + // Reuse the existing clear scene logic (handles entities, cameras, lights, etc.) + editor_clearScene() + + // Clear selected asset + selectedAsset = nil + + // Clear assets dictionary (will be repopulated by Asset Browser) + assets = [:] + + // Clear current scene URL + editorController?.currentSceneURL = nil + + print("✅ Editor cleaned up for new project") + } } diff --git a/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift index 93fafec..84b5ddc 100644 --- a/Sources/UntoldEditor/Editor/ToolbarView.swift +++ b/Sources/UntoldEditor/Editor/ToolbarView.swift @@ -353,6 +353,9 @@ } } + // Notify editor to clean up before switching projects + NotificationCenter.default.post(name: .projectWillSwitch, object: nil) + // Set the asset base path assetBasePath = gameDataPath EditorAssetBasePath.shared.basePath = gameDataPath From 26b46a955f371ddf6a45758aeebe00d85a826007 Mon Sep 17 00:00:00 2001 From: Harold Serrano Date: Sun, 1 Feb 2026 04:50:43 -0700 Subject: [PATCH 6/6] [Release] Preparing release 0.8.2 --- CHANGELOG.md | 7 +++++++ Package.swift | 2 +- Sources/UntoldEditor/main.swift | 4 ++-- Tests/UntoldEditorTests/ToolbarViewTests.swift | 15 ++++++++++----- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index babeddd..c53b4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Changelog +## v0.8.2 - 2026-02-01 +### 🐞 Fixes +- [Patch] added a dropdown menu to import button (645a6e7…) +- [Patch] Added parenting to scenegraph (7dd0179…) +- [Patch] Added option to select child entity by using shift,right mouse (c5210ee…) +- [Patch] Added quick preview (c6c7f40…) +- [Patch] Fixed issue of new project copying data over from previous one (452fa93…) ## v0.8.1 - 2026-01-26 ## v0.8.0 - 2026-01-19 ### 🐞 Fixes diff --git a/Package.swift b/Package.swift index 9005c94..aeda5a8 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.8.1"), + .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.8.2"), ], targets: [ .executableTarget( diff --git a/Sources/UntoldEditor/main.swift b/Sources/UntoldEditor/main.swift index 0598b7c..5f466d2 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) { - print("Launching Untold Engine Editor v0.2") + Logger.log(message: "Launching Untold Engine Editor v0.8.2") // 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.1.2" + window.title = "Untold Engine Editor v0.8.2" window.center() let hostingView = NSHostingView(rootView: EditorView()) diff --git a/Tests/UntoldEditorTests/ToolbarViewTests.swift b/Tests/UntoldEditorTests/ToolbarViewTests.swift index 02c0d66..011e13b 100644 --- a/Tests/UntoldEditorTests/ToolbarViewTests.swift +++ b/Tests/UntoldEditorTests/ToolbarViewTests.swift @@ -48,7 +48,8 @@ import XCTest onCreateSphere: { onCreateSphereCalled.pointee = true }, onCreatePlane: { onCreatePlaneCalled.pointee = true }, onCreateCylinder: { onCreateCylinderCalled.pointee = true }, - onCreateCone: { onCreateConeCalled.pointee = true } + onCreateCone: { onCreateConeCalled.pointee = true }, + onQuickPreview: {} ) } @@ -140,7 +141,8 @@ import XCTest onCreateSphere: {}, onCreatePlane: {}, onCreateCylinder: {}, - onCreateCone: {} + onCreateCone: {}, + onQuickPreview: {} ) // Wrap in a hosting controller to ensure SwiftUI can build the body. @@ -175,7 +177,8 @@ import XCTest onCreateSphere: { sphereCreated = true }, onCreatePlane: { planeCreated = true }, onCreateCylinder: { cylinderCreated = true }, - onCreateCone: { coneCreated = true } + onCreateCone: { coneCreated = true }, + onQuickPreview: {} ) // When: Invoking the primitive creation closures @@ -211,7 +214,8 @@ import XCTest onCreateSphere: { callCount += 1 }, onCreatePlane: { callCount += 1 }, onCreateCylinder: {}, - onCreateCone: {} + onCreateCone: {}, + onQuickPreview: {} ) // When: Calling the primitive closures @@ -244,7 +248,8 @@ import XCTest onCreateSphere: { sphereCount += 1 }, onCreatePlane: { planeCount += 1 }, onCreateCylinder: {}, - onCreateCone: {} + onCreateCone: {}, + onQuickPreview: {} ) // When: Calling specific primitive closures multiple times