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/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/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/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 c05eb4d..a92c8e8 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 {
@@ -81,7 +85,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
)
}
@@ -155,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()
@@ -197,6 +212,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() {
@@ -204,6 +232,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()
@@ -222,6 +256,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
@@ -575,4 +615,225 @@ 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)")
+ }
+
+ // 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()
+ }
+ }
+
+ // 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/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/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/Sources/UntoldEditor/Editor/ToolbarView.swift b/Sources/UntoldEditor/Editor/ToolbarView.swift
index 31acd41..84b5ddc 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)
}
}
@@ -337,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
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
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/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")
+ }
+ }
}
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")
+ }
+}
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