diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a6409..2e6f692 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ # Changelog +## v0.22.0 - 2026-10-05 +### 🐞 Fixes +- [Patch] Show every line of an export's output in the Console (#143) (a204708…) +### 🚀 Features +- [Feature] Editor UI redesign, part 2: hierarchy with eye and lock, scene tabs and Inspector sections (#142) (046df92…) +- [Feature] Cook imported sources into the folder they were imported into (#144) (5cfecd6…) +- [Feature] Editor UI redesign, part 3: viewport header, input, overlays and play mode (#146) (3d87595…) +- [Feature] Editor UI redesign, part 4: selecting several entities (#147) (19a88ff…) +- [Feature] Component SDK: every C module the engine was built with, not only CShaderTypes (#148) (9ede444…) ## v0.21.0 - 2026-09-27 ### 🐞 Fixes - [Patch] Dividers: system resize cursor and a preview line instead of live resizing (e27b770…) diff --git a/Package.swift b/Package.swift index fad52b2..a61522f 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.21.0"), + // .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.22.0"), // Temporarily tracks the Gaussian splat streaming series on the fork until those engine // PRs land in a release; restore the exact pin afterwards. .package(url: "https://github.com/miolabs/UntoldEngine.git", branch: "develop"), diff --git a/Sources/UntoldEditor/Components/ComponentCompiler.swift b/Sources/UntoldEditor/Components/ComponentCompiler.swift index 0c67bb9..b05f334 100644 --- a/Sources/UntoldEditor/Components/ComponentCompiler.swift +++ b/Sources/UntoldEditor/Components/ComponentCompiler.swift @@ -140,11 +140,11 @@ enum ComponentCompiler { if request.unit.reloadableImports.isEmpty == false { arguments += ["-I", request.outputDirectory.path] } - arguments += [ - "-Xcc", "-fmodule-map-file=\(request.sdk.cShaderTypesModuleMap.path)", - // Link nothing of the engine: its symbols are found in the running editor at load. - "-Xlinker", "-undefined", "-Xlinker", "dynamic_lookup", - ] + for moduleMap in request.sdk.cModuleMaps { + arguments += ["-Xcc", "-fmodule-map-file=\(moduleMap.path)"] + } + // Link nothing of the engine: its symbols are found in the running editor at load. + arguments += ["-Xlinker", "-undefined", "-Xlinker", "dynamic_lookup"] arguments += request.unit.sources.map(\.path) return arguments } diff --git a/Sources/UntoldEditor/Components/ComponentProject.swift b/Sources/UntoldEditor/Components/ComponentProject.swift index f9fb640..261e161 100644 --- a/Sources/UntoldEditor/Components/ComponentProject.swift +++ b/Sources/UntoldEditor/Components/ComponentProject.swift @@ -114,7 +114,7 @@ struct ComponentProjectLayout: Equatable { let pluginsDirectory: URL /// In build order: package runtimes, package editor sources, then the project's folder. let units: [ComponentSourceUnit] - /// Manifest and plugin package problems, shown in the Plugins panel. + /// Manifest, plugin package and SDK problems, shown in the Plugins panel. let problems: [String] var pluginsDirectoryExists: Bool { @@ -159,6 +159,11 @@ enum ComponentSourceLocator { problems.append("\(EditorProjectManifest.fileName) uses an old key: \(legacyKey).") } + // The compiler would only say that a required module is missing. + for module in sdk?.cModulesWithoutModuleMap ?? [] { + problems.append("No module map was found for the C module \(module), which this editor was built with. Plugins do not compile if the engine imports it.") + } + let pluginsDirectory: URL if let custom = manifest?.pluginsFolder, custom.isEmpty == false { pluginsDirectory = resolve(custom, relativeTo: root) diff --git a/Sources/UntoldEditor/Components/ComponentSDK.swift b/Sources/UntoldEditor/Components/ComponentSDK.swift index 16693c5..d36dff7 100644 --- a/Sources/UntoldEditor/Components/ComponentSDK.swift +++ b/Sources/UntoldEditor/Components/ComponentSDK.swift @@ -11,7 +11,7 @@ import Foundation -/// The Swift modules a component library compiles against. +/// The modules a component library compiles against. /// /// Compiling against the editor's own modules, and linking nothing, is what makes loaded code /// share the editor's engine: one `scene`, one registry. A packaged editor ships them in @@ -27,13 +27,24 @@ struct ComponentSDK: Equatable { var target: String var languageMode: String var providedModules: [String] + /// The C modules shipped beside `Modules`, each a folder of headers with a + /// `module.modulemap`. An SDK packaged before the list existed has CShaderTypes alone. + var cModules: [String]? } static let bundleDirectoryName = "ComponentSDK" static let manifestFileName = "sdk.json" + /// The engine's own C module: every SDK has it. + static let cShaderTypesModule = "CShaderTypes" let modulesDirectory: URL - let cShaderTypesModuleMap: URL + /// The module map of every C module the modules in `modulesDirectory` import: CShaderTypes + /// first, then any other the engine was built with. The compiler does not find these by + /// itself, and cannot import `UntoldEngine` without them. + let cModuleMaps: [URL] + /// C targets the editor was built with whose module map was not found. Always empty for a + /// packaged editor, where the packaging check has compiled against the SDK. + let cModulesWithoutModuleMap: [String] /// Modules the editor itself links, which a plugin must therefore never bring a second copy of. let providedModules: [String] let targetTriple: String @@ -70,17 +81,19 @@ struct ComponentSDK: Equatable { static func resolveBundled(at sdkRoot: URL, fileManager: FileManager = .default) -> ComponentSDK? { let modules = sdkRoot.appendingPathComponent("Modules", isDirectory: true) - let moduleMap = sdkRoot.appendingPathComponent("CShaderTypes/module.modulemap") let manifestURL = sdkRoot.appendingPathComponent(manifestFileName) guard hasRequiredModules(in: modules, fileManager: fileManager), - fileManager.fileExists(atPath: moduleMap.path), let data = try? Data(contentsOf: manifestURL), let manifest = try? JSONDecoder().decode(Manifest.self, from: data) else { return nil } + let moduleMaps = (manifest.cModules ?? [cShaderTypesModule]) + .map { sdkRoot.appendingPathComponent("\($0)/module.modulemap") } + guard moduleMaps.allSatisfy({ fileManager.fileExists(atPath: $0.path) }) else { return nil } return ComponentSDK( modulesDirectory: modules, - cShaderTypesModuleMap: moduleMap, + cModuleMaps: moduleMaps, + cModulesWithoutModuleMap: [], providedModules: manifest.providedModules, targetTriple: manifest.target, recordedCompilerVersion: manifest.swiftCompilerVersion, @@ -95,18 +108,20 @@ struct ComponentSDK: Equatable { /// the module map in `CShaderTypes.build/`. static func resolveFromBuildProducts(productsDirectory: URL, fileManager: FileManager = .default) -> ComponentSDK? { let moduleCandidates = [productsDirectory, productsDirectory.appendingPathComponent("Modules", isDirectory: true)] - let moduleMapCandidates = [ - productsDirectory.appendingPathComponent("../../Intermediates.noindex/GeneratedModuleMaps/CShaderTypes.modulemap").standardizedFileURL, - productsDirectory.appendingPathComponent("CShaderTypes.build/module.modulemap"), - ] guard let modules = moduleCandidates.first(where: { hasRequiredModules(in: $0, fileManager: fileManager) }), - let moduleMap = moduleMapCandidates.first(where: { fileManager.fileExists(atPath: $0.path) }) + let layout = ComponentSDKBuildLayout.allCases.first(where: { + let moduleMap = $0.generatedModuleMap(of: cShaderTypesModule, productsDirectory: productsDirectory) + return fileManager.fileExists(atPath: moduleMap.path) + }) else { return nil } + let swiftModules = moduleNames(in: modules, fileManager: fileManager) + let cModules = layout.cModules(productsDirectory: productsDirectory, swiftModules: swiftModules, fileManager: fileManager) return ComponentSDK( modulesDirectory: modules, - cShaderTypesModuleMap: moduleMap, - providedModules: moduleNames(in: modules, fileManager: fileManager), + cModuleMaps: cModules.moduleMaps, + cModulesWithoutModuleMap: cModules.withoutModuleMap, + providedModules: swiftModules, targetTriple: defaultTargetTriple, recordedCompilerVersion: nil, engineURL: nil, diff --git a/Sources/UntoldEditor/Components/ComponentSDKBuildLayout.swift b/Sources/UntoldEditor/Components/ComponentSDKBuildLayout.swift new file mode 100644 index 0000000..7e3121a --- /dev/null +++ b/Sources/UntoldEditor/Components/ComponentSDKBuildLayout.swift @@ -0,0 +1,238 @@ +// +// ComponentSDKBuildLayout.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import Foundation + +/// How the build of an editor run from source laid out its folder, and with it the C modules +/// that build has. +/// +/// The engine is built without library evolution, so a component that imports `UntoldEngine` +/// makes the compiler load every module the engine imports. A Swift module it finds by itself in +/// the modules folder. A C module it does not: CShaderTypes, a C target the engine adds, or one +/// that comes with a package the engine depends on each need their module map on the command +/// line, as the build system passed it when it built the editor. +enum ComponentSDKBuildLayout: CaseIterable { + /// Swift Build, which Xcode uses too: a `.o` beside the products for every target, + /// and the module maps it generates together under `Intermediates.noindex`. + case swiftBuild + /// SwiftPM's native build system: a `.build` folder beside the products for every + /// target, with the target's objects and the module map generated for it. + case native + + /// The C modules of a build. + struct CModules: Equatable { + /// The module map of every C target that was built with the editor, CShaderTypes first. + var moduleMaps: [URL] + /// The targets that were built with the editor and whose module map was not found. + var withoutModuleMap: [String] = [] + } + + /// Where the build system writes the module map of a C target that has none of its own. + func generatedModuleMap(of target: String, productsDirectory: URL) -> URL { + switch self { + case .swiftBuild: + return productsDirectory + .appendingPathComponent("../../Intermediates.noindex/GeneratedModuleMaps/\(target).modulemap") + .standardizedFileURL + case .native: + return productsDirectory.appendingPathComponent("\(target).build/module.modulemap") + } + } + + /// The C targets that were built with the editor, with the module map of each. + /// + /// Only what was compiled counts, told by the objects it left: the native build system also + /// writes a module map for a target the package declares and the editor does not use. And + /// only what is still in its package: a build folder keeps the objects and the module map of + /// a target that is gone, and the compiler warns about such a module map in every build. + func cModules(productsDirectory: URL, swiftModules: [String], fileManager: FileManager = .default) -> CModules { + let cShaderTypes = ComponentSDK.cShaderTypesModule + var modules = CModules(moduleMaps: [generatedModuleMap(of: cShaderTypes, productsDirectory: productsDirectory)]) + // Searched only for a target that brings its own module map. + var packages: [URL]? + for target in builtTargets(productsDirectory: productsDirectory, except: swiftModules + [cShaderTypes], fileManager: fileManager) { + let generated = generatedModuleMap(of: target, productsDirectory: productsDirectory) + if fileManager.fileExists(atPath: generated.path) { + if Self.headersExist(ofGeneratedModuleMap: generated, fileManager: fileManager) { + modules.moduleMaps.append(generated) + } + continue + } + let searched = packages ?? packageDirectories(productsDirectory: productsDirectory, fileManager: fileManager) + packages = searched + if let own = Self.ownModuleMap(of: target, inPackages: searched, fileManager: fileManager) { + modules.moduleMaps.append(own) + } else { + modules.withoutModuleMap.append(target) + } + } + return modules + } + + /// The targets that left objects in this build, without the ones in `excluded`. + func builtTargets(productsDirectory: URL, except excluded: [String], fileManager: FileManager = .default) -> [String] { + let entries = (try? fileManager.contentsOfDirectory(atPath: productsDirectory.path)) ?? [] + let suffix = self == .swiftBuild ? ".o" : ".build" + let targets = entries + .filter { $0.hasSuffix(suffix) } + .map { String($0.dropLast(suffix.count)) } + .filter { excluded.contains($0) == false } + .sorted() + guard self == .native else { return targets } + return targets.filter { target in + let folder = productsDirectory.appendingPathComponent("\(target).build", isDirectory: true) + guard let enumerator = fileManager.enumerator(at: folder, includingPropertiesForKeys: nil) else { return false } + return enumerator.contains { ($0 as? URL)?.pathExtension == "o" } + } + } + + /// A generated module map names the target's headers, a folder or one header, by absolute + /// path. + static func headersExist(ofGeneratedModuleMap moduleMap: URL, fileManager: FileManager = .default) -> Bool { + guard let text = try? String(contentsOf: moduleMap, encoding: .utf8), + let umbrella = try? NSRegularExpression(pattern: #"umbrella\s+(?:header\s+)?"([^"]+)""#) + else { return false } + return umbrella.matches(in: text, range: NSRange(text.startIndex..., in: text)).allSatisfy { match in + guard let path = Range(match.range(at: 1), in: text) else { return true } + return fileManager.fileExists(atPath: String(text[path])) + } + } + + /// The folders of the packages the build was made from. + /// + /// SwiftPM clones them into `checkouts` of its scratch folder, Xcode into + /// `SourcePackages/checkouts` of its derived data folder. A package used by path stays where + /// it is. The `workspace-state.json` beside `checkouts` is SwiftPM's record of both, and + /// leaves out a clone the build no longer uses; without it, every clone counts. + func packageDirectories(productsDirectory: URL, fileManager: FileManager = .default) -> [URL] { + let twoUp = productsDirectory.appendingPathComponent("../..").standardizedFileURL + let workspaces: [URL] + switch self { + case .swiftBuild: + // `/out/Products/`, or `/Build/Products/`. + let root = twoUp.deletingLastPathComponent() + workspaces = [root, root.appendingPathComponent("SourcePackages", isDirectory: true)] + case .native: + // `//`. + workspaces = [twoUp] + } + + return workspaces.flatMap { workspace -> [URL] in + let checkouts = workspace.appendingPathComponent("checkouts", isDirectory: true) + if let recorded = Self.recordedPackages(workspaceState: workspace.appendingPathComponent("workspace-state.json"), checkouts: checkouts) { + return recorded + } + let cloned = (try? fileManager.contentsOfDirectory(atPath: checkouts.path)) ?? [] + return cloned.filter { $0.hasPrefix(".") == false }.sorted().map { checkouts.appendingPathComponent($0, isDirectory: true) } + } + } + + /// The packages `workspace-state.json` lists: a cloned one in `checkouts`, one used by path + /// where it is. `nil` when the file is not there or is not one this code can read. + private static func recordedPackages(workspaceState: URL, checkouts: URL) -> [URL]? { + struct State: Decodable { + struct Object: Decodable { + struct Dependency: Decodable { + struct PackageRef: Decodable { + let kind: String + let location: String + } + + let packageRef: PackageRef + let subpath: String + } + + let dependencies: [Dependency] + } + + let object: Object + } + guard let data = try? Data(contentsOf: workspaceState), + let state = try? JSONDecoder().decode(State.self, from: data) + else { return nil } + return state.object.dependencies.map { dependency in + dependency.packageRef.kind == "fileSystem" + ? URL(fileURLWithPath: dependency.packageRef.location, isDirectory: true) + : checkouts.appendingPathComponent(dependency.subpath, isDirectory: true) + } + } + + /// The `module.modulemap` a C target brings itself, which the build system then uses as it + /// is: in the target's `include` folder by default, somewhere else in its package when the + /// target says so. + static func ownModuleMap(of module: String, inPackages packages: [URL], fileManager: FileManager = .default) -> URL? { + for package in packages { + let conventional = package.appendingPathComponent("Sources/\(module)/include/module.modulemap") + if moduleMap(at: conventional, declares: module) { + return conventional + } + } + for package in packages { + guard let enumerator = fileManager.enumerator(atPath: package.path) else { continue } + var declaring: [String] = [] + while let path = enumerator.nextObject() as? String { + let name = (path as NSString).lastPathComponent + if name.hasPrefix(".") { + // `.build`, `.git`: nothing of the package's own. + enumerator.skipDescendants() + } else if name == "module.modulemap", moduleMap(at: package.appendingPathComponent(path), declares: module) { + declaring.append(path) + } + } + // A copy kept deeper in the package (a benchmark, a fixture) is not the target's. + let nearest = declaring.min { ($0.components(separatedBy: "/").count, $0) < ($1.components(separatedBy: "/").count, $1) } + if let nearest { + return package.appendingPathComponent(nearest) + } + } + return nil + } + + static func moduleMap(at url: URL, declares module: String) -> Bool { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return false } + return topLevelModules(inModuleMap: text).contains(module) + } + + /// The modules a module map declares itself: `framework module A { module B {} }` declares A, + /// and B only as a part of it. + static func topLevelModules(inModuleMap text: String) -> [String] { + // A comment, a string, a word or a brace. Comments and strings are matched so that what + // is inside them is not read as a word or a brace. + let pattern = #"//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_]*|[{}]"# + guard let tokens = try? NSRegularExpression(pattern: pattern) else { return [] } + var modules: [String] = [] + var depth = 0 + var nameFollows = false + for match in tokens.matches(in: text, range: NSRange(text.startIndex..., in: text)) { + guard let range = Range(match.range, in: text) else { continue } + let token = String(text[range]) + switch token.first { + case "/": + continue + case "{": + depth += 1 + nameFollows = false + case "}": + depth -= 1 + default: + if nameFollows { + nameFollows = false + if depth == 0 { + modules.append(token.trimmingCharacters(in: CharacterSet(charactersIn: "\""))) + } + } else if token == "module" { + nameFollows = true + } + } + } + return modules + } +} diff --git a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift index 4b1570b..fd05f22 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Contents.swift @@ -156,6 +156,17 @@ extension AssetBrowserView { } } } + if let sourceFolder = asset.sourceFolder { + Button { + if selectedDirURL != nil { + selectedDirURL = sourceFolder + } else if selectedCategory != AssetCategory.scripts.rawValue { + folderPathStack.append(sourceFolder) + } + } label: { + Label("Reveal Source Folder", systemImage: "folder") + } + } Button(role: .destructive) { pendingDeleteAsset = asset showDeleteConfirmation = true @@ -183,11 +194,25 @@ extension AssetBrowserView { @ViewBuilder func folderContentsView(for folder: URL, selectionManager _: SelectionManager) -> some View { if let contents = try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) { + // A folder that shares its stem with a sibling runtime asset is that + // asset's cook source (see `runtimeExportLocation`): it's not listed on + // its own, only reachable via that asset's "Reveal Source Folder" menu. + let sourceFolderByStem: [String: URL] = contents.reduce(into: [:]) { result, item in + guard allRuntimeAssetExtensions.contains(item.pathExtension.lowercased()) else { return } + let stem = item.deletingPathExtension().lastPathComponent + let candidateFolder = folder.appendingPathComponent(stem, isDirectory: true) + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: candidateFolder.path, isDirectory: &isDir), isDir.boolValue { + result[stem] = candidateFolder + } + } + let items = contents.compactMap { item -> Asset? in var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) { let itemCategory = selectedCategory ?? inferCategory(for: item)?.rawValue ?? "" if isDir.boolValue { + guard sourceFolderByStem[item.lastPathComponent] == nil else { return nil } return Asset(name: item.lastPathComponent, category: itemCategory, path: item, isFolder: true) } else { // Imported sources (USD, .blend) are listed too: they stay in the @@ -200,9 +225,14 @@ extension AssetBrowserView { .union(sourceAssetExtensions) guard allowedExtensions.contains(itemExtension) else { return nil } + let sourceFolder = allRuntimeAssetExtensions.contains(itemExtension) + ? sourceFolderByStem[item.deletingPathExtension().lastPathComponent] + : nil + return Asset(name: item.lastPathComponent, category: itemCategory, - path: item) + path: item, + sourceFolder: sourceFolder) } } return nil diff --git a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift index 24b7e32..61a7a95 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowser/AssetBrowserView+Loading.swift @@ -50,10 +50,24 @@ extension AssetBrowserView { includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) { + // A folder that shares its stem with a sibling runtime asset is that + // asset's cook source (see `runtimeExportLocation`): it's not listed on + // its own, only reachable via that asset's "Reveal Source Folder" menu. + let sourceFolderByStem: [String: URL] = contents.reduce(into: [:]) { result, item in + guard allRuntimeAssetExtensions.contains(item.pathExtension.lowercased()) else { return } + let stem = item.deletingPathExtension().lastPathComponent + let candidateFolder = categoryPath.appendingPathComponent(stem, isDirectory: true) + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: candidateFolder.path, isDirectory: &isDir), isDir.boolValue { + result[stem] = candidateFolder + } + } + for item in contents { var isDir: ObjCBool = false if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir) { if isDir.boolValue { + guard sourceFolderByStem[item.lastPathComponent] == nil else { continue } // It’s a folder — valid for all categories categoryAssets.append(Asset(name: item.lastPathComponent, category: category.rawValue, @@ -81,11 +95,16 @@ extension AssetBrowserView { path: item, isFolder: false)) } else if category == .models || category == .animations { - if runtimeAssetExtensions(for: category).contains(item.pathExtension.lowercased()) || sourceAssetExtensions.contains(item.pathExtension.lowercased()) { + let itemExtension = item.pathExtension.lowercased() + if runtimeAssetExtensions(for: category).contains(itemExtension) || sourceAssetExtensions.contains(itemExtension) { + let sourceFolder = allRuntimeAssetExtensions.contains(itemExtension) + ? sourceFolderByStem[item.deletingPathExtension().lastPathComponent] + : nil categoryAssets.append(Asset(name: item.lastPathComponent, category: category.rawValue, path: item, - isFolder: false)) + isFolder: false, + sourceFolder: sourceFolder)) } } else if category == .scripts { // Not used anymore due to flat listing, but keep for safety (won’t execute due to continue above) diff --git a/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift b/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift index e8f43a3..da65f83 100644 --- a/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift +++ b/Sources/UntoldEditor/Editor/AssetBrowser/AssetModel.swift @@ -17,6 +17,10 @@ public struct Asset: Identifiable { public let category: String public let path: URL var isFolder: Bool = false + /// The source/working folder this runtime asset was cooked from (same stem, + /// e.g. `bl.untoldpack` <- `bl/`), if one exists. Hidden from the listing in + /// its own right; reachable via the asset's "Reveal Source Folder" context menu. + var sourceFolder: URL? } enum AssetCategory: String, CaseIterable { diff --git a/Sources/UntoldEditor/Editor/AssetPacks/AssetPackCatalog.swift b/Sources/UntoldEditor/Editor/AssetPacks/AssetPackCatalog.swift index e4fe882..bc23639 100644 --- a/Sources/UntoldEditor/Editor/AssetPacks/AssetPackCatalog.swift +++ b/Sources/UntoldEditor/Editor/AssetPacks/AssetPackCatalog.swift @@ -110,21 +110,9 @@ struct AssetPackInstallResult: Equatable { } func defaultAssetPackCatalog() -> AssetPackCatalog { - AssetPackCatalog( - version: "1.0.0", - assets: [ - AssetPackCatalogItem( - id: "starter", - name: "StarterPack", - description: "Soccer field, goals, ball, and a sample scene.", - version: "1.0.0", - downloadURL: "https://d8pyi1c08k1w.cloudfront.net/StarterPack.zip", - size: "5.3 MB", - thumbnailName: "starterpack", - replacePaths: ["Models/starterpack"] - ), - ] - ) + // No asset packs are hosted; the Explore panel (hidden via PanelID.available) + // has nothing to list. + AssetPackCatalog(version: "1.0.0", assets: []) } func configuredAssetPackCatalogURL( diff --git a/Sources/UntoldEditor/Editor/Docking/PanelID.swift b/Sources/UntoldEditor/Editor/Docking/PanelID.swift index bf947ee..53f4c5b 100644 --- a/Sources/UntoldEditor/Editor/Docking/PanelID.swift +++ b/Sources/UntoldEditor/Editor/Docking/PanelID.swift @@ -88,5 +88,6 @@ enum PanelID: String, Codable, CaseIterable, Identifiable { /// The panels this build of the editor offers. static var available: [PanelID] { allCases.filter { $0 != .plugins || EditorFeatureFlags.enableCodeComponents } + .filter { $0 != .explore } } } diff --git a/Sources/UntoldEditor/Editor/Explore/StarterStreamModels.swift b/Sources/UntoldEditor/Editor/Explore/StarterStreamModels.swift index 3560e41..06b64c9 100644 --- a/Sources/UntoldEditor/Editor/Explore/StarterStreamModels.swift +++ b/Sources/UntoldEditor/Editor/Explore/StarterStreamModels.swift @@ -23,33 +23,9 @@ struct StreamModelCameraFrame { let usesOriginOrbit: Bool } -let starterStreamModels: [StreamModelCatalogItem] = [ - .init( - id: "dungeon", - title: "Game Dungeon", - manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Dungeon/dungeon.json")! - ), - .init( - id: "city", - title: "Cartoon City", - manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/city/city.json")! - ), - .init( - id: "f1car", - title: "Formula 1", - manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/F1Car/F1Car.json")! - ), - .init( - id: "airplane", - title: "Skyhawk", - manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Shyhawk_stream/Skyhawks.json")! - ), - .init( - id: "porsche964", - title: "Porsche 964", - manifestURL: URL(string: "https://d8pyi1c08k1w.cloudfront.net/Porsche964-stream/Porsche964-stream.json")! - ), -] +/// No streamed demo scenes are hosted; the Explore gallery (unreachable since +/// the editor now launches straight into the full editor) has nothing to list. +let starterStreamModels: [StreamModelCatalogItem] = [] extension StreamModelCatalogItem { var cameraFrame: StreamModelCameraFrame { diff --git a/Sources/UntoldEditor/Editor/Hierarchy/EntityRow.swift b/Sources/UntoldEditor/Editor/Hierarchy/EntityRow.swift index 7b639e1..48a8df9 100644 --- a/Sources/UntoldEditor/Editor/Hierarchy/EntityRow.swift +++ b/Sources/UntoldEditor/Editor/Hierarchy/EntityRow.swift @@ -12,8 +12,8 @@ import SwiftUI import UntoldEngine /// One entity in the hierarchy: caret, type icon, name, then the eye and the -/// lock. A hidden entity, or one under a hidden parent, is greyed; the selected -/// row is on the accent. +/// lock. A hidden entity, or one under a hidden parent, is greyed; a selected +/// row is on the accent, and with several selected each of them is. struct EntityRow: View { let entityid: EntityID let entityName: String @@ -23,7 +23,7 @@ struct EntityRow: View { @ObservedObject var selectionManager: SelectionManager private var isSelected: Bool { - entityid == selectionManager.selectedEntity + selectionManager.isSelected(entityid) } private var isAssetNode: Bool { diff --git a/Sources/UntoldEditor/Editor/Hierarchy/HierarchyFooterView.swift b/Sources/UntoldEditor/Editor/Hierarchy/HierarchyFooterView.swift index 0ca6c2e..3ae2e1a 100644 --- a/Sources/UntoldEditor/Editor/Hierarchy/HierarchyFooterView.swift +++ b/Sources/UntoldEditor/Editor/Hierarchy/HierarchyFooterView.swift @@ -11,14 +11,15 @@ import SwiftUI /// The line under the hierarchy tree: how many entities the scene has and how -/// many are selected. +/// many are selected. From Play to Stop it says why a click selects nothing. struct HierarchyFooterView: View { let entityCount: Int let selectedCount: Int + var isPlaying = false var body: some View { HStack { - Text(Self.summary(entityCount: entityCount, selectedCount: selectedCount)) + Text(Self.summary(entityCount: entityCount, selectedCount: selectedCount, isPlaying: isPlaying)) .font(EditorType.hint) .foregroundColor(.editorTextSecondary) .lineLimit(1) @@ -31,8 +32,12 @@ struct HierarchyFooterView: View { } } - /// "5 entities · 1 selected", with the status bar's entity wording. - static func summary(entityCount: Int, selectedCount: Int) -> String { - "\(EditorStatusModel.entities(entityCount)) · \(selectedCount) selected" + /// "5 entities · 1 selected", with the status bar's entity wording; while + /// the game plays, "5 entities · no selecting while playing". + static func summary(entityCount: Int, selectedCount: Int, isPlaying: Bool = false) -> String { + if isPlaying { + return "\(EditorStatusModel.entities(entityCount)) · no selecting while playing" + } + return "\(EditorStatusModel.entities(entityCount)) · \(selectedCount) selected" } } diff --git a/Sources/UntoldEditor/Editor/Hierarchy/HierarchyNode.swift b/Sources/UntoldEditor/Editor/Hierarchy/HierarchyNode.swift index 163d48f..553dedc 100644 --- a/Sources/UntoldEditor/Editor/Hierarchy/HierarchyNode.swift +++ b/Sources/UntoldEditor/Editor/Hierarchy/HierarchyNode.swift @@ -25,6 +25,8 @@ struct HierarchyNode: View { var onDeleteEntity: (EntityID) -> Void = { _ in } var onDropRow: (DroppedRowPayload, EntityID?) -> Void = { _, _ in } var addActions: AddEntityActions = .init() + /// True from Play to Stop, when a click on a row selects nothing. + var isPlaying = false @State private var isDragOver = false var body: some View { @@ -51,7 +53,14 @@ struct HierarchyNode: View { // One group indent per level, so a child's caret sits under its parent's icon. .padding(.leading, CGFloat(depth) * 16) .onTapGesture { - selectionManager.inspectEntity(entityId: entityId) + switch HierarchyRowClick.click(isPlaying: isPlaying, modifiers: NSEvent.modifierFlags) { + case .none: + break + case .select: + selectionManager.inspectEntity(entityId: entityId) + case .toggle: + selectionManager.toggleSelection(of: entityId) + } } .contextMenu { contextMenuContent @@ -79,7 +88,8 @@ struct HierarchyNode: View { onUnparentEntity: onUnparentEntity, onDeleteEntity: onDeleteEntity, onDropRow: onDropRow, - addActions: addActions + addActions: addActions, + isPlaying: isPlaying ) } } diff --git a/Sources/UntoldEditor/Editor/Hierarchy/HierarchyRowClick.swift b/Sources/UntoldEditor/Editor/Hierarchy/HierarchyRowClick.swift new file mode 100644 index 0000000..2956112 --- /dev/null +++ b/Sources/UntoldEditor/Editor/Hierarchy/HierarchyRowClick.swift @@ -0,0 +1,31 @@ +// +// HierarchyRowClick.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import AppKit + +/// What a click on a row of the hierarchy does. +enum HierarchyRowClick: Equatable { + /// Nothing: from Play to Stop the hierarchy selects nothing, as the + /// viewport does. + case none + /// The row alone is the selection. + case select + /// The row joins the selection, or leaves it when it was selected. + case toggle + + /// A click with ⇧ or ⌘ held adds to the selection, as a ⇧ click in the + /// viewport does and as ⌘ does in any list. + static func click(isPlaying: Bool, modifiers: NSEvent.ModifierFlags) -> HierarchyRowClick { + guard isPlaying == false else { + return .none + } + return modifiers.contains(.shift) || modifiers.contains(.command) ? .toggle : .select + } +} diff --git a/Sources/UntoldEditor/Editor/Hierarchy/SceneHierarchyView.swift b/Sources/UntoldEditor/Editor/Hierarchy/SceneHierarchyView.swift index 72ef4d0..ccc655e 100644 --- a/Sources/UntoldEditor/Editor/Hierarchy/SceneHierarchyView.swift +++ b/Sources/UntoldEditor/Editor/Hierarchy/SceneHierarchyView.swift @@ -28,6 +28,8 @@ struct SceneHierarchyView: View { /// An asset browser or Lights shelf row dropped on the tree: on the root row /// the parent is `nil` (scene root), on an entity row it is that entity. var onDropRow: (DroppedRowPayload, EntityID?) -> Void = { _, _ in } + /// From Play to Stop the hierarchy selects nothing. + @ObservedObject var playback = EditorPlaybackSettings.shared @State private var isTreeExpanded = true @@ -50,10 +52,7 @@ struct SceneHierarchyView: View { } private var selectedCount: Int { - guard let selected = selectionManager.selectedEntity, selected != .invalid else { - return 0 - } - return 1 + selectionManager.selectedEntities.count } var body: some View { @@ -67,7 +66,11 @@ struct SceneHierarchyView: View { name: sceneName, isExpanded: $isTreeExpanded, isSelected: selectionManager.sceneSelected, - onSelect: { selectionManager.selectScene() }, + onSelect: { + if playback.isSessionActive == false { + selectionManager.selectScene() + } + }, onDropRow: { payload in onDropRow(payload, nil) } ) @@ -84,7 +87,8 @@ struct SceneHierarchyView: View { onUnparentEntity: onUnparentEntity, onDeleteEntity: onDeleteEntity, onDropRow: onDropRow, - addActions: addActions + addActions: addActions, + isPlaying: playback.isSessionActive ) } } @@ -95,7 +99,7 @@ struct SceneHierarchyView: View { .scrollContentBackground(.hidden) .frame(maxWidth: .infinity, maxHeight: .infinity) - HierarchyFooterView(entityCount: entityCount, selectedCount: selectedCount) + HierarchyFooterView(entityCount: entityCount, selectedCount: selectedCount, isPlaying: playback.isSessionActive) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.editorPanelBackground) diff --git a/Sources/UntoldEditor/Editor/Inspector/InspectorSeveralSelectedView.swift b/Sources/UntoldEditor/Editor/Inspector/InspectorSeveralSelectedView.swift new file mode 100644 index 0000000..64d6dcf --- /dev/null +++ b/Sources/UntoldEditor/Editor/Inspector/InspectorSeveralSelectedView.swift @@ -0,0 +1,47 @@ +// +// InspectorSeveralSelectedView.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import SwiftUI + +/// The Inspector with several entities selected: how many, and what can be +/// done with them. The properties of one show when it is selected alone. +struct InspectorSeveralSelectedView: View { + let count: Int + + var body: some View { + VStack(spacing: 10) { + ZStack { + square.offset(x: -5, y: -5).foregroundColor(.editorTextDisabled) + square.offset(x: 5, y: 5).foregroundColor(.editorAccent) + } + .frame(width: 44, height: 44) + Text(Self.title(count: count)) + .font(EditorType.title) + .foregroundColor(.editorTextSecondary) + Text("The gizmo moves, turns and scales them together. Click one to see its properties.") + .font(EditorType.hint) + .foregroundColor(.editorTextTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 220) + } + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var square: some View { + RoundedRectangle(cornerRadius: EditorType.Radius.field) + .strokeBorder(style: StrokeStyle(lineWidth: 1.5)) + .frame(width: 32, height: 32) + } + + static func title(count: Int) -> String { + "\(count) entities selected" + } +} diff --git a/Sources/UntoldEditor/Editor/Inspector/InspectorView.swift b/Sources/UntoldEditor/Editor/Inspector/InspectorView.swift index 32b211a..36e4d23 100644 --- a/Sources/UntoldEditor/Editor/Inspector/InspectorView.swift +++ b/Sources/UntoldEditor/Editor/Inspector/InspectorView.swift @@ -14,7 +14,8 @@ import UniformTypeIdentifiers import UntoldEngine /// The Inspector: the entity's header, then one section per component with -/// its editor, then Add Component. With nothing to show, the empty state. +/// its editor, then Add Component. With nothing to show, the empty state, and +/// with several entities selected how many they are. struct InspectorView: View { /// The gate `body` puts the Splat Twin section behind: the section's own policy, in every /// authoring mode (the composition-only inspector hides the Gaussian editor, not this). @@ -30,12 +31,14 @@ struct InspectorView: View { @Binding var selectedAsset: Asset? /// The entity the Inspector shows: the pinned one while it is in the scene, - /// otherwise the selection. + /// otherwise the selection when it is one entity. var inspectedEntity: EntityID? { if let pinned = selectionManager.pinnedInspection, sceneGraphModel.contains(pinned) { return pinned } - guard let selected = selectionManager.selectedEntity, selected != .invalid else { + guard selectionManager.hasSeveralSelected == false, + let selected = selectionManager.selectedEntity, selected != .invalid + else { return nil } return selected @@ -128,6 +131,8 @@ struct InspectorView: View { .padding(10) } } + } else if selectionManager.hasSeveralSelected { + InspectorSeveralSelectedView(count: selectionManager.selectedEntities.count) } else { InspectorEmptyStateView() } diff --git a/Sources/UntoldEditor/Editor/Inspector/StaticBatchingEditorView.swift b/Sources/UntoldEditor/Editor/Inspector/StaticBatchingEditorView.swift index 628d0d2..f11c8a9 100644 --- a/Sources/UntoldEditor/Editor/Inspector/StaticBatchingEditorView.swift +++ b/Sources/UntoldEditor/Editor/Inspector/StaticBatchingEditorView.swift @@ -104,9 +104,9 @@ struct StaticBatchingEditorView: View { // Update checkbox state when view appears staticBatchCheckboxState = isMarkedAsStatic(entityId: entityId) } - .onChange(of: entityId) { newEntityId in + .onChange(of: entityId) { // Update checkbox state when entity selection changes - staticBatchCheckboxState = isMarkedAsStatic(entityId: newEntityId) + staticBatchCheckboxState = isMarkedAsStatic(entityId: entityId) } } diff --git a/Sources/UntoldEditor/Editor/Project/EditorController.swift b/Sources/UntoldEditor/Editor/Project/EditorController.swift index 8240209..8599f43 100644 --- a/Sources/UntoldEditor/Editor/Project/EditorController.swift +++ b/Sources/UntoldEditor/Editor/Project/EditorController.swift @@ -122,6 +122,20 @@ class EditorController: SelectionDelegate, ObservableObject { } } + func didSelectEntities(_ entityIds: [EntityID]) { + DispatchQueue.main.async { + // A locked entity keeps the viewport from selecting it, among several too. + self.selectionManager.selectEntities(entityIds.filter { self.selectionManager.isEffectivelyLocked($0) == false }) + } + } + + func didToggleEntity(_ entityId: EntityID) { + DispatchQueue.main.async { + guard self.selectionManager.isEffectivelyLocked(entityId) == false else { return } + self.selectionManager.toggleSelection(of: entityId) + } + } + func resetActiveAxis() { activeAxis = .none } diff --git a/Sources/UntoldEditor/Editor/Project/EditorGroupTransformChangeCommand.swift b/Sources/UntoldEditor/Editor/Project/EditorGroupTransformChangeCommand.swift new file mode 100644 index 0000000..f1a6781 --- /dev/null +++ b/Sources/UntoldEditor/Editor/Project/EditorGroupTransformChangeCommand.swift @@ -0,0 +1,40 @@ +// +// EditorGroupTransformChangeCommand.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import Foundation +import UntoldEngine + +/// Several entities moved, turned or scaled together by one drag of the +/// gizmo: one step to undo, which puts every one of them back. +struct EditorGroupTransformChangeCommand: EditorUndoCommand { + struct Change { + let entityId: EntityID + let before: EditorTransformSnapshot + let after: EditorTransformSnapshot + } + + let changes: [Change] + + var name: String { + "Transform \(changes.count) Entities" + } + + func undo() { + for change in changes { + change.before.apply(to: change.entityId) + } + } + + func redo() { + for change in changes { + change.after.apply(to: change.entityId) + } + } +} diff --git a/Sources/UntoldEditor/Editor/Project/EditorUndoManager.swift b/Sources/UntoldEditor/Editor/Project/EditorUndoManager.swift index 9d2fdfd..4402122 100644 --- a/Sources/UntoldEditor/Editor/Project/EditorUndoManager.swift +++ b/Sources/UntoldEditor/Editor/Project/EditorUndoManager.swift @@ -296,6 +296,34 @@ final class EditorUndoManager: ObservableObject { ) } + /// A drag of the gizmo began on these entities: where each stands is kept, + /// so that the drag is undone in one step. + func beginTransformEdit(entityIds: [EntityID]) { + for entityId in entityIds { + beginTransformEdit(entityId: entityId) + } + } + + /// The drag ended: what it changed is one step to undo, whether it moved + /// one entity or several. + func commitTransformEdit(entityIds: [EntityID]) { + let changes = entityIds.compactMap { entityId -> EditorGroupTransformChangeCommand.Change? in + guard let before = transformEditStart.removeValue(forKey: entityId), + hasComponent(entityId: entityId, componentType: LocalTransformComponent.self) + else { + return nil + } + let after = EditorTransformSnapshot(entityId: entityId) + return before == after ? nil : .init(entityId: entityId, before: before, after: after) + } + + if changes.count == 1, let change = changes.first { + registerTransformChange(entityId: change.entityId, before: change.before, after: change.after) + } else if changes.isEmpty == false { + register(EditorGroupTransformChangeCommand(changes: changes)) + } + } + func registerValueChange( name: String, oldValue: Value, diff --git a/Sources/UntoldEditor/Editor/Project/SelectionManager+Several.swift b/Sources/UntoldEditor/Editor/Project/SelectionManager+Several.swift new file mode 100644 index 0000000..6ffa9d1 --- /dev/null +++ b/Sources/UntoldEditor/Editor/Project/SelectionManager+Several.swift @@ -0,0 +1,134 @@ +// +// SelectionManager+Several.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import Foundation +import simd +import UntoldEngine + +/// A selection of several entities: the rectangle dragged over the viewport +/// selects them at once, a ⇧ click adds one or takes it out. Each one shows +/// its box, and one gizmo in the middle of them moves, turns and scales them +/// together. +extension SelectionManager { + var hasSeveralSelected: Bool { + selectedEntities.count > 1 + } + + func isSelected(_ entityId: EntityID) -> Bool { + selectedEntities.contains(entityId) + } + + /// Selects `entityIds`, in their order and each once; the last one is the + /// one the Inspector names. One entity is selected as a click on its row + /// selects it, and none clears the selection. + func selectEntities(_ entityIds: [EntityID]) { + var seen = Set() + let entities = entityIds.filter { $0 != .invalid && seen.insert($0).inserted } + + guard entities.count > 1 else { + if let only = entities.first { + inspectEntity(entityId: only) + } else { + clearSelection() + } + return + } + setSelection(toSeveral: entities) + } + + /// Adds an entity to the selection, or takes it out when it is selected. + func toggleSelection(of entityId: EntityID) { + guard entityId != .invalid else { return } + if isSelected(entityId) { + selectEntities(selectedEntities.filter { $0 != entityId }) + } else { + selectEntities(selectedEntities + [entityId]) + } + } + + /// Entities that left the scene leave a selection of several, which goes + /// on with the rest. One entity selected alone is the caller's to clear. + func forgetEntitiesThatLeftTheScene() { + guard hasSeveralSelected else { return } + let inTheScene = Set(getAllGameEntities()) + let remaining = selectedEntities.filter(inTheScene.contains) + if remaining != selectedEntities { + selectEntities(remaining) + } + } + + /// The selected entities the gizmo moves: those it may go on, without the + /// ones under another of them, which move with their parent. + var transformTargets: [EntityID] { + let movable = selectedEntities.filter(takesGizmo) + let among = Set(movable) + return movable.filter { hasAncestor($0, in: among) == false } + } + + private func hasAncestor(_ entityId: EntityID, in set: Set) -> Bool { + var current = parentInSceneGraph(of: entityId) + var depth = 0 + while let id = current, depth < 64 { + if set.contains(id) { + return true + } + current = parentInSceneGraph(of: id) + depth += 1 + } + return false + } + + /// Hands the selection of several to the viewport: the boxes to draw, the + /// entities the gizmo moves and the gizmo itself, in the middle of them. + func activateSeveral() { + EditorRepresentationHandles.select(nil) + SelectionHighlights.shared.boxes = highlightBoxes() + + let targets = transformTargets + // The gizmo's own axes, in Local space, are those of the last one + // selected that it moves. + guard let active = targets.last(where: { $0 == selectedEntity }) ?? targets.last else { + gizmoTargets = [] + activeEntity = .invalid + gizmoActive = false + removeGizmo() + return + } + + gizmoTargets = targets + activeEntity = active + createGizmo(forTool: EditorViewportSettings.shared.tool) + } + + /// One entity alone is selected, or none: nothing is kept of several. + func forgetSeveral() { + if gizmoTargets.isEmpty == false { + gizmoTargets = [] + } + SelectionHighlights.shared.boxes = [] + } + + /// The box of every selected entity that shows in the viewport, hidden + /// ones left out: around what it draws, or where it stands when it draws + /// nothing. + func highlightBoxes() -> [SelectionHighlightBox] { + selectedEntities.compactMap { entityId in + guard isEffectivelyHidden(entityId) == false, + hasComponent(entityId: entityId, componentType: WorldTransformComponent.self) + else { + return nil + } + guard let bounds = renderableBoundsInOwnSpace(entityId: entityId) else { + return SelectionHighlightBox.point(entityId: entityId) + } + return SelectionHighlightBox(entityId: entityId, minimum: bounds.min, maximum: bounds.max, isPoint: false) + } + } +} diff --git a/Sources/UntoldEditor/Editor/Project/SelectionManager.swift b/Sources/UntoldEditor/Editor/Project/SelectionManager.swift index 8ffe2f0..3184fff 100644 --- a/Sources/UntoldEditor/Editor/Project/SelectionManager.swift +++ b/Sources/UntoldEditor/Editor/Project/SelectionManager.swift @@ -47,6 +47,27 @@ protocol SelectionDelegate: AnyObject { /// A click on empty viewport space: nothing is selected any more. func didClearSelection() func resetActiveAxis() + /// These entities stood inside the rectangle dragged over the viewport, + /// the nearest one last: they are the selection. + func didSelectEntities(_ entityIds: [EntityID]) + /// A ⇧ click on an entity: it joins the selection, or leaves it. + func didToggleEntity(_ entityId: EntityID) +} + +/// A delegate that knows of one selected entity takes the nearest of +/// several, and a toggled one as selected. +extension SelectionDelegate { + func didSelectEntities(_ entityIds: [EntityID]) { + if let nearest = entityIds.last { + didSelectEntity(nearest) + } else { + didClearSelection() + } + } + + func didToggleEntity(_ entityId: EntityID) { + didSelectEntity(entityId) + } } struct MeshInspectionSelection: Equatable { @@ -103,7 +124,25 @@ class SceneGraphModel: ObservableObject { } class SelectionManager: ObservableObject { - @Published var selectedEntity: EntityID? = .invalid + /// The selected entity, which the Inspector shows. With several selected + /// it is the last of them. Whoever sets it selects that entity alone. + @Published var selectedEntity: EntityID? = .invalid { + didSet { + guard isSelectingSeveral == false else { return } + let alone = selectedEntity.flatMap { $0 == .invalid ? nil : [$0] } ?? [] + if selectedEntities != alone { + selectedEntities = alone + } + forgetSeveral() + } + } + + /// Every selected entity, in the order they were selected. One entity + /// alone is a selection of one; `selectEntities` and `toggleSelection` + /// select several. + @Published private(set) var selectedEntities: [EntityID] = [] + /// True while `selectedEntity` is set as one of several. + private var isSelectingSeveral = false @Published var inspectedMesh: MeshInspectionSelection? /// True when the project itself is selected in the Scene Graph panel. Drives /// the right panel to show Environment/Effects instead of the Inspector. @@ -237,28 +276,58 @@ class SelectionManager: ObservableObject { } } - /// The box in the world around what the selection draws, for framing it. - /// Nil with nothing selected, and for a selection that draws nothing, - /// such as a light. + /// Makes `entities`, two or more of them, the selection. The last one is + /// `selectedEntity`. The gizmo goes to the middle of those it can move. + func setSelection(toSeveral entities: [EntityID]) { + projectSelected = false + sceneSelected = false + inspectedMesh = nil + isSelectingSeveral = true + selectedEntities = entities + selectedEntity = entities.last + isSelectingSeveral = false + activateSeveral() + } + + /// Whether the gizmo may go on an entity: it has a place in the scene and + /// in the world, it is neither hidden nor locked, and it shows in the + /// viewport. + func takesGizmo(_ entityId: EntityID) -> Bool { + guard hasBothTransforms(entityId), canMove(entityId) else { return false } + return entityOrChildrenHaveRenderableRepresentation(entityId: entityId) + || EditorRepresentationRenderer.drawing(for: entityId) != nil + } + + /// The box in the world around what the selection draws, for framing it: + /// around all of it when several entities are selected. Nil with nothing + /// selected, and for a selection that draws nothing, such as a light. func selectionBounds() -> (min: simd_float3, max: simd_float3)? { - guard let selected = selectedEntity, selected != .invalid else { return nil } - let entity = sceneTransformEntity(for: selected) - guard hasComponent(entityId: entity, componentType: LocalTransformComponent.self) else { return nil } - return worldBoundsOfRenderableHierarchy(entityId: entity) + var bounds: (min: simd_float3, max: simd_float3)? + for selected in selectedEntities { + let entity = sceneTransformEntity(for: selected) + guard hasComponent(entityId: entity, componentType: LocalTransformComponent.self), + let box = worldBoundsOfRenderableHierarchy(entityId: entity) + else { continue } + bounds = bounds.map { (simd_min($0.min, box.min), simd_max($0.max, box.max)) } ?? box + } + return bounds } - /// What F frames: the selection's box, or for a selection that draws - /// nothing a box of `pointExtent` around where it stands. + /// What F frames: the selection's box, with a box of `pointExtent` where + /// each selected entity that draws nothing stands. func selectionFramingBounds(pointExtent: Float = 1) -> (min: simd_float3, max: simd_float3)? { - if let bounds = selectionBounds() { - return bounds - } - guard let selected = selectedEntity, selected != .invalid else { return nil } - let entity = sceneTransformEntity(for: selected) - guard hasComponent(entityId: entity, componentType: WorldTransformComponent.self) else { return nil } - let position = getPosition(entityId: entity) + var bounds = selectionBounds() let half = simd_float3(repeating: pointExtent / 2) - return (position - half, position + half) + for selected in selectedEntities { + let entity = sceneTransformEntity(for: selected) + guard hasComponent(entityId: entity, componentType: WorldTransformComponent.self), + worldBoundsOfRenderableHierarchy(entityId: entity) == nil + else { continue } + let position = getPosition(entityId: entity) + let box = (min: position - half, max: position + half) + bounds = bounds.map { (simd_min($0.min, box.min), simd_max($0.max, box.max)) } ?? box + } + return bounds } // MARK: - Hidden and locked entities @@ -305,6 +374,17 @@ class SelectionManager: ObservableObject { setHidden(entityId, isHidden(entityId) == false) } + /// Hides every selected entity, with everything under each: what H does. + func hideSelection() { + let selected = selectedEntities.filter { $0 != .invalid } + guard selected.isEmpty == false else { return } + for entityId in selected { + hiddenEntities.insert(entityId) + applyVisibility(under: entityId) + } + refreshGizmo() + } + /// Shows every hidden entity again. func showAllEntities() { let hidden = hiddenEntities @@ -375,7 +455,9 @@ class SelectionManager: ObservableObject { /// the tool when that changes. func refreshGizmo() { guard let selected = selectedEntity, selected != .invalid else { return } - if let mesh = inspectedMesh { + if hasSeveralSelected { + activateSeveral() + } else if let mesh = inspectedMesh { inspectMesh(entityId: mesh.entityId, meshIndex: mesh.meshIndex) } else { selectEntity(entityId: sceneTransformEntity(for: selected), inspectEntityId: selected) diff --git a/Sources/UntoldEditor/Editor/Project/SelectionWorldBounds.swift b/Sources/UntoldEditor/Editor/Project/SelectionWorldBounds.swift index b6a4d8a..6962b62 100644 --- a/Sources/UntoldEditor/Editor/Project/SelectionWorldBounds.swift +++ b/Sources/UntoldEditor/Editor/Project/SelectionWorldBounds.swift @@ -55,3 +55,40 @@ func worldBoundsOfRenderableHierarchy(entityId: EntityID) -> (min: simd_float3, return found ? (minimum, maximum) : nil } + +/// The box around what an entity and everything under it draw, in the +/// entity's own space: drawn with the entity's world transform it stands +/// around the entity wherever that is, turned and scaled with it. Nil when +/// nothing under the entity draws anything. +func renderableBoundsInOwnSpace(entityId: EntityID) -> (min: simd_float3, max: simd_float3)? { + var minimum = simd_float3(repeating: .greatestFiniteMagnitude) + var maximum = simd_float3(repeating: -.greatestFiniteMagnitude) + var found = false + var pending = [entityId] + var visited = 0 + + while let current = pending.popLast(), visited < 100_000 { + visited += 1 + pending.append(contentsOf: getEntityChildren(parentId: current)) + + guard hasComponent(entityId: current, componentType: RenderComponent.self) + || hasComponent(entityId: current, componentType: GaussianComponent.self), + let local = scene.get(component: LocalTransformComponent.self, for: current), + let toEntity = localTransformMatrix(from: current, to: entityId) + else { + continue + } + + let box = transformedBoundingBox(min: local.boundingBox.min, max: local.boundingBox.max, transform: toEntity) + guard box.min.x.isFinite, box.min.y.isFinite, box.min.z.isFinite, + box.max.x.isFinite, box.max.y.isFinite, box.max.z.isFinite + else { + continue + } + minimum = simd_min(minimum, box.min) + maximum = simd_max(maximum, box.max) + found = true + } + + return found ? (minimum, maximum) : nil +} diff --git a/Sources/UntoldEditor/Editor/Shared/LoadingIndicatorView.swift b/Sources/UntoldEditor/Editor/Shared/LoadingIndicatorView.swift index e932fed..e824e77 100644 --- a/Sources/UntoldEditor/Editor/Shared/LoadingIndicatorView.swift +++ b/Sources/UntoldEditor/Editor/Shared/LoadingIndicatorView.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. // +import Combine import SwiftUI import UntoldEngine diff --git a/Sources/UntoldEditor/Editor/Shared/MinimalLoadingIndicator.swift b/Sources/UntoldEditor/Editor/Shared/MinimalLoadingIndicator.swift index 2604b4a..712fda4 100644 --- a/Sources/UntoldEditor/Editor/Shared/MinimalLoadingIndicator.swift +++ b/Sources/UntoldEditor/Editor/Shared/MinimalLoadingIndicator.swift @@ -9,6 +9,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. // +import Combine import SwiftUI import UntoldEngine diff --git a/Sources/UntoldEditor/Editor/Tasks/TasksPanelView.swift b/Sources/UntoldEditor/Editor/Tasks/TasksPanelView.swift index 9d08134..d78b56c 100644 --- a/Sources/UntoldEditor/Editor/Tasks/TasksPanelView.swift +++ b/Sources/UntoldEditor/Editor/Tasks/TasksPanelView.swift @@ -13,6 +13,7 @@ // spinner plus elapsed time, and a cancel button for jobs that support it. // +import Combine import SwiftUI struct TasksPanelView: View { diff --git a/Sources/UntoldEditor/Editor/Viewport/MarqueeFrustum.swift b/Sources/UntoldEditor/Editor/Viewport/MarqueeFrustum.swift new file mode 100644 index 0000000..228d2e2 --- /dev/null +++ b/Sources/UntoldEditor/Editor/Viewport/MarqueeFrustum.swift @@ -0,0 +1,110 @@ +// +// MarqueeFrustum.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import CoreGraphics +import simd + +extension MarqueeGeometry { + /// The part of the world that can show in the rectangle: what lies + /// between the four planes that go through the camera and the sides of + /// the rectangle, in front of the camera. + /// + /// It is made once for a rectangle and then asked about every entity of + /// the scene, so asking is kept cheap: a box that is all beyond one of + /// the planes, as nearly every box of a large scene is, is told outside + /// from its middle and its size alone. + struct Frustum { + let area: DeviceRect + + /// From the world to clip space: the camera's projection after its view. + let toClip: simd_float4x4 + + /// The planes, each as the row of numbers that tells how far a point + /// of the world is on the rectangle's side of it: the left, the + /// right, the bottom and the top of the rectangle, and the plane the + /// camera stands on. + let sides: [simd_float4] + + /// Nil for a viewport without a size, where nothing shows. + init?(_ rect: CGRect, view: View) { + guard let area = MarqueeGeometry.deviceRect(rect, in: view.size) else { + return nil + } + self.area = area + toClip = simd_mul(view.perspectiveSpace, view.viewSpace) + + // A row of the matrix gives one clip coordinate of a point of + // the world, so a side of the rectangle, such as x >= low * w, + // is a row of numbers as well. + let rows = toClip.transpose + let x = rows.columns.0 + let y = rows.columns.1 + let w = rows.columns.3 + sides = [ + x - area.low.x * w, + area.high.x * w - x, + y - area.low.y * w, + area.high.y * w - y, + w, + ] + } + + /// How a box given in an entity's own space, which `modelSpace` + /// carries into the world, stands to the rectangle. + /// + /// It is outside when all of it is beyond the same plane: nothing + /// drawn within the box can then fall on the rectangle. That errs on + /// the side of `across`: a box near a corner of the rectangle may be + /// told across though it shows nowhere in it. It is inside with all + /// eight of its corners in front of the camera and within the + /// rectangle, its border included. + func place( + ofBoxMinimum minimum: simd_float3, + boxMaximum maximum: simd_float3, + modelSpace: simd_float4x4 + ) -> Place { + let middle = simd_float4((minimum + maximum) * 0.5, 1) + let half = simd_abs(maximum - minimum) * 0.5 + + // All of it is beyond a plane when its middle is farther beyond + // than the box reaches back toward it. The planes go through the + // camera, so this holds for a box behind the camera as well. + for side in sides { + let sideForTheBox = simd_mul(side, modelSpace) + let reach = simd_dot(simd_abs(simd_float3(sideForTheBox.x, sideForTheBox.y, sideForTheBox.z)), half) + if simd_dot(sideForTheBox, middle) + reach < 0 { + return .outside + } + } + + let boxToClip = simd_mul(toClip, modelSpace) + for corner in 0 ..< 8 { + let clip = simd_mul(boxToClip, simd_float4( + corner & 1 == 0 ? minimum.x : maximum.x, + corner & 2 == 0 ? minimum.y : maximum.y, + corner & 4 == 0 ? minimum.z : maximum.z, + 1 + )) + // A corner behind the camera has no place on the screen, and + // neither has one that is nowhere. + guard area.contains(clip) else { + return .across + } + } + return .inside + } + + /// Whether a point of the world shows in the rectangle: for what has + /// a place and no box, such as a light. + func contains(_ point: simd_float3) -> Bool { + area.contains(simd_mul(toClip, simd_float4(point, 1))) + } + } +} diff --git a/Sources/UntoldEditor/Editor/Viewport/MarqueeGeometry.swift b/Sources/UntoldEditor/Editor/Viewport/MarqueeGeometry.swift new file mode 100644 index 0000000..a576c06 --- /dev/null +++ b/Sources/UntoldEditor/Editor/Viewport/MarqueeGeometry.swift @@ -0,0 +1,133 @@ +// +// MarqueeGeometry.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import CoreGraphics +import simd + +/// The rectangle dragged over the viewport and what stands inside it. Pure, +/// so it is tested without a view or a scene. +/// +/// The rectangle is measured as the canvas measures the pointer: in points +/// from the bottom left of the viewport. An entity is inside when all of its +/// box is, the one drawn around it when it is selected: every corner of the +/// box in front of the camera and within the rectangle. A floor or a wall +/// that reaches out of the rectangle is therefore left out, however much of +/// it shows in it. Such a box is across the rectangle, and one that cannot +/// show in it at all is outside. +enum MarqueeGeometry { + /// The nearest a point may be to the camera's plane and still be projected. + static let nearestDepth: Float = 0.01 + + /// How the camera sees the world: its two matrices and the size of the + /// viewport in points. + struct View { + let viewSpace: simd_float4x4 + let perspectiveSpace: simd_float4x4 + let size: CGSize + } + + /// The rectangle between where a drag began and where it is now, + /// whichever way it went. + static func rect(from start: CGPoint, to end: CGPoint) -> CGRect { + CGRect( + x: min(start.x, end.x), + y: min(start.y, end.y), + width: abs(end.x - start.x), + height: abs(end.y - start.y) + ) + } + + /// The same rectangle for a view that measures from its top left, as + /// SwiftUI does. + static func flipped(_ rect: CGRect, inHeight height: CGFloat) -> CGRect { + CGRect(x: rect.minX, y: height - rect.maxY, width: rect.width, height: rect.height) + } + + /// How a box stands to the rectangle. + enum Place: Equatable { + /// All of it is in the rectangle. + case inside + /// It reaches out of the rectangle, and part of it may show in it. + case across + /// None of it can show in the rectangle. + case outside + } + + /// Whether a box given in an entity's own space, which `modelSpace` + /// carries into the world, stands inside the rectangle: all eight of its + /// corners in front of the camera and within the rectangle, its border + /// included. A corner behind the camera has no place on the screen, so a + /// box that reaches there is not inside. + static func contains( + _ rect: CGRect, + boxMinimum minimum: simd_float3, + boxMaximum maximum: simd_float3, + modelSpace: simd_float4x4, + view: View + ) -> Bool { + place(ofBoxMinimum: minimum, boxMaximum: maximum, modelSpace: modelSpace, in: rect, view: view) == .inside + } + + /// How a box given in an entity's own space, which `modelSpace` carries + /// into the world, stands to the rectangle. For many boxes and one + /// rectangle, make the `Frustum` once and ask it. + static func place( + ofBoxMinimum minimum: simd_float3, + boxMaximum maximum: simd_float3, + modelSpace: simd_float4x4, + in rect: CGRect, + view: View + ) -> Place { + Frustum(rect, view: view)?.place(ofBoxMinimum: minimum, boxMaximum: maximum, modelSpace: modelSpace) ?? .outside + } + + /// Whether a point of the world is inside the rectangle: for what has a + /// place and no box, such as a light. + static func contains(_ rect: CGRect, point: simd_float3, view: View) -> Bool { + Frustum(rect, view: view)?.contains(point) ?? false + } + + // MARK: - The parts + + /// A rectangle in device coordinates, which run from -1 to 1 across the + /// viewport with Y up. + struct DeviceRect: Equatable { + let low: simd_float2 + let high: simd_float2 + + /// Whether a point in clip space is in front of the camera and, on + /// the screen, within the rectangle or on its border. + func contains(_ clip: simd_float4) -> Bool { + guard clip.w.isFinite, clip.w >= MarqueeGeometry.nearestDepth, clip.x.isFinite, clip.y.isFinite else { + return false + } + let place = simd_float2(clip.x, clip.y) / clip.w + return place.x >= low.x && place.x <= high.x && place.y >= low.y && place.y <= high.y + } + } + + static func deviceRect(_ rect: CGRect, in size: CGSize) -> DeviceRect? { + guard size.width > 0, size.height > 0, rect.isNull == false, rect.isInfinite == false else { + return nil + } + let low = simd_float2( + Float(rect.minX / size.width) * 2 - 1, + Float(rect.minY / size.height) * 2 - 1 + ) + let high = simd_float2( + Float(rect.maxX / size.width) * 2 - 1, + Float(rect.maxY / size.height) * 2 - 1 + ) + guard low.x.isFinite, low.y.isFinite, high.x.isFinite, high.y.isFinite else { + return nil + } + return DeviceRect(low: low, high: high) + } +} diff --git a/Sources/UntoldEditor/Editor/Viewport/MarqueeView.swift b/Sources/UntoldEditor/Editor/Viewport/MarqueeView.swift new file mode 100644 index 0000000..bf0a33e --- /dev/null +++ b/Sources/UntoldEditor/Editor/Viewport/MarqueeView.swift @@ -0,0 +1,35 @@ +// +// MarqueeView.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import SwiftUI + +/// The dashed rectangle dragged over the viewport, which selects what is +/// inside it when the button is released. It takes no click itself. +struct MarqueeView: View { + /// The rectangle in points from the top left of the viewport. + let rect: CGRect + + static let lineWidth: CGFloat = 1.5 + static let cornerRadius: CGFloat = 3 + + var body: some View { + let shape = RoundedRectangle(cornerRadius: min(Self.cornerRadius, min(rect.width, rect.height) / 2)) + shape + .fill(Color.editorAccent.opacity(0.10)) + .overlay { + shape.stroke(Color.editorAccent, style: StrokeStyle(lineWidth: Self.lineWidth, dash: [6, 4])) + } + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/Sources/UntoldEditor/Editor/Viewport/ViewportHint.swift b/Sources/UntoldEditor/Editor/Viewport/ViewportHint.swift index fad17ca..d64d263 100644 --- a/Sources/UntoldEditor/Editor/Viewport/ViewportHint.swift +++ b/Sources/UntoldEditor/Editor/Viewport/ViewportHint.swift @@ -23,6 +23,8 @@ struct ViewportHint: Equatable, Identifiable { /// The hints of the viewport, from how the camera is steered now. enum ViewportHints { static let frameSelection = ViewportHint(action: "Frame selected", keys: "F") + static let select = ViewportHint(action: "Select", keys: "Click or drag") + static let addToSelection = ViewportHint(action: "Add or remove", keys: "⇧ Click") static let look = ViewportHint(action: "Look", keys: "Right drag") static let fly = ViewportHint(action: "Fly", keys: "W A S D Q E") static let pan = ViewportHint(action: "Pan", keys: "⇧ Right drag") @@ -39,9 +41,14 @@ enum ViewportHints { } /// The hints by what helps first, so a narrow viewport drops from the - /// end. Framing is only hinted with something selected to frame. - static func hints(style: CameraNavigationStyle, hasSelection: Bool) -> [ViewportHint] { + /// end. Selecting is hinted where the viewport selects, which explore mode + /// does not, and framing and adding to the selection only with something + /// selected. + static func hints(style: CameraNavigationStyle, hasSelection: Bool, canSelect: Bool = false) -> [ViewportHint] { var hints = hasSelection ? [frameSelection] : [] + if canSelect { + hints += hasSelection ? [select, addToSelection] : [select] + } hints += [look, fly, scroll(style: style), pan, move] // In the Blender style scrolling orbits already. if scroll(style: style).action != orbit.action { diff --git a/Sources/UntoldEditor/Editor/Viewport/ViewportMarqueeStore.swift b/Sources/UntoldEditor/Editor/Viewport/ViewportMarqueeStore.swift new file mode 100644 index 0000000..8f2126d --- /dev/null +++ b/Sources/UntoldEditor/Editor/Viewport/ViewportMarqueeStore.swift @@ -0,0 +1,37 @@ +// +// ViewportMarqueeStore.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import Combine +import CoreGraphics + +/// The rectangle being dragged over the viewport to select what is inside it. +/// The canvas writes it while the left button is dragged, and the viewport +/// draws it. +final class ViewportMarqueeStore: ObservableObject { + static let shared = ViewportMarqueeStore() + + /// The rectangle in points from the top left of the viewport, as the + /// view that draws it measures; nil while none is dragged. + @Published private(set) var rect: CGRect? + + init() {} + + func show(_ rect: CGRect) { + if self.rect != rect { + self.rect = rect + } + } + + func hide() { + if rect != nil { + rect = nil + } + } +} diff --git a/Sources/UntoldEditor/Editor/Viewport/ViewportOverlaysView.swift b/Sources/UntoldEditor/Editor/Viewport/ViewportOverlaysView.swift index cc766ee..cd010f8 100644 --- a/Sources/UntoldEditor/Editor/Viewport/ViewportOverlaysView.swift +++ b/Sources/UntoldEditor/Editor/Viewport/ViewportOverlaysView.swift @@ -11,8 +11,9 @@ import SwiftUI /// What the editor draws over the scene: the mode badge and the frame -/// statistics at the top left, the navigation controls at the top right and -/// the shortcut hints along the bottom. Only the navigation controls take the +/// statistics at the top left, the navigation controls at the top right, the +/// shortcut hints along the bottom, and the rectangle while one is dragged +/// to select what is inside it. Only the navigation controls take the /// pointer; everywhere else a click reaches the scene. struct ViewportOverlaysView: View { /// True while the viewport shows the editor's camera for editing: the @@ -30,11 +31,22 @@ struct ViewportOverlaysView: View { @ObservedObject var overlays = EditorViewportOverlaySettings.shared @ObservedObject var store = ViewportOverlayStore.shared @ObservedObject var navigation = EditorNavigationSettings.shared + @ObservedObject var marquee = ViewportMarqueeStore.shared static let margin: CGFloat = 12 + /// Selecting is hinted where the editor's overlays show: there the + /// viewport selects. + private var hints: [ViewportHint] { + ViewportHints.hints(style: navigation.style, hasSelection: hasSelection, canSelect: showsEditorOverlays) + } + var body: some View { ZStack { + if showsEditorOverlays, let rect = marquee.rect { + MarqueeView(rect: rect) + } + VStack(alignment: .leading, spacing: 6) { if showsEditorOverlays, overlays.isShown(.modeBadge) { ModeBadgeView(mode: mode) @@ -54,7 +66,7 @@ struct ViewportOverlaysView: View { } if showsHints, overlays.isShown(.hints) { - ViewportHintChips(hints: ViewportHints.hints(style: navigation.style, hasSelection: hasSelection)) + ViewportHintChips(hints: hints) .padding(Self.margin) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } diff --git a/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift b/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift index c59b7fa..e4ad914 100644 --- a/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift +++ b/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift @@ -66,7 +66,10 @@ extension EditorView { EditorSceneDirtyState.shared.markDirty() editor_entities = getAllGameEntities() - if selectionManager.selectedEntity == entityId { + if selectionManager.hasSeveralSelected { + // The rest of the selection stays, with the gizmo in its middle. + selectionManager.forgetEntitiesThatLeftTheScene() + } else if selectionManager.selectedEntity == entityId { selectionManager.selectedEntity = nil activeEntity = .invalid removeGizmo() @@ -139,8 +142,6 @@ extension EditorView { var forward = forwardDirectionVector(from: cameraComponent.rotation) forward *= -1.0 - let camPosition = cameraComponent.localPosition - let spawnPosition = camPosition + forward * spawnDistance translateTo(entityId: entityId, position: simd_float3(0.0, 0.0, 0.0)) selectionManager.selectedEntity = entityId diff --git a/Sources/UntoldEditor/Editor/Window/EditorView+EntityStates.swift b/Sources/UntoldEditor/Editor/Window/EditorView+EntityStates.swift index 3d0ec5c..f8267a7 100644 --- a/Sources/UntoldEditor/Editor/Window/EditorView+EntityStates.swift +++ b/Sources/UntoldEditor/Editor/Window/EditorView+EntityStates.swift @@ -12,14 +12,12 @@ import SwiftUI import UntoldEngine extension EditorView { - /// H: hides the selected entity, the row the hierarchy shows selected, with - /// everything under it. With nothing selected, nothing happens, and + /// H: hides the selection, the rows the hierarchy shows selected, with + /// everything under them. With nothing selected, nothing happens, and /// while the game plays the key is the game's. func editor_hideSelectedEntity() { - guard experienceMode == .edit, isPlaying == false, - let selected = selectionManager.selectedEntity, selected != .invalid - else { return } - selectionManager.setHidden(selected, true) + guard experienceMode == .edit, isPlaying == false else { return } + selectionManager.hideSelection() } /// ⌥H: shows every hidden entity again. diff --git a/Sources/UntoldEditor/Editor/Window/EditorView.swift b/Sources/UntoldEditor/Editor/Window/EditorView.swift index b15ec5d..e263fe9 100644 --- a/Sources/UntoldEditor/Editor/Window/EditorView.swift +++ b/Sources/UntoldEditor/Editor/Window/EditorView.swift @@ -60,7 +60,7 @@ public struct EditorView: View { @State var panelSearchText: [PanelID: String] = [:] @State var renderPauseGeneration = 0 let panelAnimationDuration = 0.28 - @State var showWelcomeStart = true + @State var showWelcomeStart = false @State var showCameraControlHints = false @State var showQuickPreviewWarning = false @State var quickPreviewEntities: [(EntityID, String)] = [] @@ -72,8 +72,8 @@ public struct EditorView: View { @State var quickPreviewCompressGeometry = false @State var quickPreviewCompressTextures = false @State var quickPreviewAstcencBinPath = "" - @State var experienceMode: EditorExperienceMode = .explore - @State var showDemoGallery = true + @State var experienceMode: EditorExperienceMode = .edit + @State var showDemoGallery = false @State var showPreviewImportGallery = false @State var activeDemoScene: DemoSceneCatalogItem? @State var activeDemoCameraFrame: StreamModelCameraFrame? diff --git a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift index db912d0..b5301f3 100644 --- a/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift +++ b/Sources/UntoldEditor/Renderer/EditorRenderPasses.swift @@ -538,7 +538,10 @@ extension RenderPasses { } static let highlightExecution: (MTLCommandBuffer) -> Void = { commandBuffer in - if activeEntity == .invalid { + // With several entities selected each one has its box here. + let selectionBoxes = SelectionHighlights.shared.boxes + + if activeEntity == .invalid, selectionBoxes.isEmpty { clearGizmoLayerExecution(commandBuffer) return } @@ -591,6 +594,18 @@ extension RenderPasses { renderEncoder.setFrontFacing(.counterClockwise) + if selectionBoxes.isEmpty == false { + renderEncoder.setVertexBytes( + &cameraComponent.viewSpace, length: MemoryLayout.stride, index: 1 + ) + renderEncoder.setVertexBytes( + &renderInfo.perspectiveSpace, length: MemoryLayout.stride, index: 2 + ) + drawSelectionBoxes(selectionBoxes, with: renderEncoder) + renderEncoder.updateFence(renderInfo.fence, after: .fragment) + return + } + guard let worldTransform = scene.get(component: WorldTransformComponent.self, for: activeEntity) else { handleError(.noWorldTransformComponent) return diff --git a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift index 2dd6082..56f30dd 100644 --- a/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift +++ b/Sources/UntoldEditor/Renderer/EditorUntoldRenderer.swift @@ -78,14 +78,20 @@ extension UntoldRenderer { InputSystem.shared.mouseActive = false } - /// Remove static batching when entity is transformed via gizmo + /// Remove static batching when entity is transformed via gizmo: from the + /// active entity and, with several selected, from all the gizmo moves. @inline(__always) func handleStaticBatchOnTransform(entityId: EntityID) { - if hasComponent(entityId: entityId, componentType: StaticBatchComponent.self) { - removeEntityStaticBatchComponent(entityId: entityId) - if isBatchingEnabled() { - generateBatches() - } + let transformed = entityId == activeEntity ? gizmoTransformTargets() : [entityId] + let batched = transformed.filter { hasComponent(entityId: $0, componentType: StaticBatchComponent.self) } + guard batched.isEmpty == false else { + return + } + for entity in batched { + removeEntityStaticBatchComponent(entityId: entity) + } + if isBatchingEnabled() { + generateBatches() } } @@ -112,8 +118,8 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort ) let t = axis * amt - // The gizmo moves in world space; the entity's position is its parent's. - translateBy(entityId: activeEntity, position: localTranslation(ofWorld: t, for: activeEntity)) + // The gizmo moves in world space; an entity's position is its parent's. + applyGizmoTranslation(byWorld: t) translateBy(entityId: parentEntityIdGizmo, position: t) refreshInspector() consumeMouseDragDelta() @@ -128,8 +134,8 @@ extension UntoldRenderer { projectionMatrix: renderInfo.perspectiveSpace, viewportSize: renderInfo.viewPort) let t = axis * amt - // The gizmo moves in world space; the entity's position is its parent's. - translateBy(entityId: activeEntity, position: localTranslation(ofWorld: t, for: activeEntity)) + // The gizmo moves in world space; an entity's position is its parent's. + applyGizmoTranslation(byWorld: t) translateBy(entityId: parentEntityIdGizmo, position: t) refreshInspector() consumeMouseDragDelta() @@ -144,8 +150,8 @@ extension UntoldRenderer { projectionMatrix: renderInfo.perspectiveSpace, viewportSize: renderInfo.viewPort) let t = axis * amt - // The gizmo moves in world space; the entity's position is its parent's. - translateBy(entityId: activeEntity, position: localTranslation(ofWorld: t, for: activeEntity)) + // The gizmo moves in world space; an entity's position is its parent's. + applyGizmoTranslation(byWorld: t) translateBy(entityId: parentEntityIdGizmo, position: t) refreshInspector() consumeMouseDragDelta() @@ -165,7 +171,7 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort, sensitivity: 100.0 ) - applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: snappedGizmoRotationDelta(degrees: -angle * 10)) + applyGizmoRotation(axis: axis, degrees: snappedGizmoRotationDelta(degrees: -angle * 10)) syncLightDirectionHandleToActiveLight(entityId: activeEntity) refreshInspector() consumeMouseDragDelta() @@ -183,7 +189,7 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort, sensitivity: 100.0 ) - applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: snappedGizmoRotationDelta(degrees: angle * 10)) + applyGizmoRotation(axis: axis, degrees: snappedGizmoRotationDelta(degrees: angle * 10)) syncLightDirectionHandleToActiveLight(entityId: activeEntity) refreshInspector() consumeMouseDragDelta() @@ -201,7 +207,7 @@ extension UntoldRenderer { viewportSize: renderInfo.viewPort, sensitivity: 100.0 ) - applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: snappedGizmoRotationDelta(degrees: angle * 10)) + applyGizmoRotation(axis: axis, degrees: snappedGizmoRotationDelta(degrees: angle * 10)) syncLightDirectionHandleToActiveLight(entityId: activeEntity) refreshInspector() consumeMouseDragDelta() @@ -217,13 +223,7 @@ extension UntoldRenderer { viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, viewportSize: renderInfo.viewPort) - if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { - // The engine takes the axis as which components of the scale to change. - handleLightScaleInput(projectedAmount: amt, axis: worldDirection(for: .x)) - } else { - // The engine takes the axis as the entity's parent sees it. - applyWorldSpaceScaleDelta(entityId: activeEntity, worldAxis: localAxis(ofWorld: axis, for: activeEntity), projectedAmount: amt) - } + applyGizmoScale(axis: axis, amount: amt, handle: .x) refreshInspector() consumeMouseDragDelta() @@ -236,13 +236,7 @@ extension UntoldRenderer { viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, viewportSize: renderInfo.viewPort) - if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { - // The engine takes the axis as which components of the scale to change. - handleLightScaleInput(projectedAmount: amt, axis: worldDirection(for: .y)) - } else { - // The engine takes the axis as the entity's parent sees it. - applyWorldSpaceScaleDelta(entityId: activeEntity, worldAxis: localAxis(ofWorld: axis, for: activeEntity), projectedAmount: amt) - } + applyGizmoScale(axis: axis, amount: amt, handle: .y) refreshInspector() consumeMouseDragDelta() @@ -255,13 +249,7 @@ extension UntoldRenderer { viewMatrix: cameraComponent.viewSpace, projectionMatrix: renderInfo.perspectiveSpace, viewportSize: renderInfo.viewPort) - if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { - // The engine takes the axis as which components of the scale to change. - handleLightScaleInput(projectedAmount: amt, axis: worldDirection(for: .z)) - } else { - // The engine takes the axis as the entity's parent sees it. - applyWorldSpaceScaleDelta(entityId: activeEntity, worldAxis: localAxis(ofWorld: axis, for: activeEntity), projectedAmount: amt) - } + applyGizmoScale(axis: axis, amount: amt, handle: .z) refreshInspector() consumeMouseDragDelta() diff --git a/Sources/UntoldEditor/Renderer/SelectionHighlightPass.swift b/Sources/UntoldEditor/Renderer/SelectionHighlightPass.swift new file mode 100644 index 0000000..f08451d --- /dev/null +++ b/Sources/UntoldEditor/Renderer/SelectionHighlightPass.swift @@ -0,0 +1,52 @@ +// +// SelectionHighlightPass.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import MetalKit +import simd +import UntoldEngine + +extension RenderPasses { + /// The lines of the unit box on the GPU, made when first drawn. + private static var selectionBoxLines: MTLBuffer? + + private static func selectionBoxLineBuffer() -> MTLBuffer? { + if let selectionBoxLines { + return selectionBoxLines + } + let lines = SelectionHighlights.unitBoxLines + selectionBoxLines = renderInfo.device?.makeBuffer( + bytes: lines, + length: MemoryLayout.stride * lines.count, + options: .storageModeShared + ) + selectionBoxLines?.label = "Selection box lines" + return selectionBoxLines + } + + /// Draws the box of every entity of a selection of several, in the + /// highlight pass: the unit box, carried onto each entity's own. + static func drawSelectionBoxes(_ boxes: [SelectionHighlightBox], with renderEncoder: MTLRenderCommandEncoder) { + guard let lines = selectionBoxLineBuffer() else { + return + } + renderEncoder.setVertexBuffer(lines, offset: 0, index: 0) + + for box in boxes { + guard let placement = box.placement() else { + continue + } + var model = placement.model + var size = placement.size + renderEncoder.setVertexBytes(&model, length: MemoryLayout.stride, index: 3) + renderEncoder.setVertexBytes(&size, length: MemoryLayout.stride, index: 4) + renderEncoder.drawPrimitives(type: .line, vertexStart: 0, vertexCount: SelectionHighlights.unitBoxLines.count) + } + } +} diff --git a/Sources/UntoldEditor/Renderer/SelectionHighlights.swift b/Sources/UntoldEditor/Renderer/SelectionHighlights.swift new file mode 100644 index 0000000..32c33fe --- /dev/null +++ b/Sources/UntoldEditor/Renderer/SelectionHighlights.swift @@ -0,0 +1,93 @@ +// +// SelectionHighlights.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import Foundation +import simd +import UntoldEngine + +/// The box the highlight pass draws around one entity of a selection of +/// several. It is given in the entity's own space, so it follows the entity +/// as the gizmo moves it, with no need to be taken again. +struct SelectionHighlightBox: Equatable { + /// The side of the box drawn where an entity that draws nothing stands. + static let pointExtent: Float = 0.5 + + let entityId: EntityID + let minimum: simd_float3 + let maximum: simd_float3 + /// True for an entity that draws nothing, such as a light: its box stands + /// where the entity does, along the world's axes and always the same size. + let isPoint: Bool + + static func point(entityId: EntityID) -> SelectionHighlightBox { + let half = simd_float3(repeating: pointExtent / 2) + return SelectionHighlightBox(entityId: entityId, minimum: -half, maximum: half, isPoint: true) + } + + /// The matrix and the size that carry the unit box, from zero to one + /// along each axis, onto this one; nil once the entity has left the scene. + func placement() -> (model: simd_float4x4, size: simd_float3)? { + guard let world = scene.get(component: WorldTransformComponent.self, for: entityId)?.space else { + return nil + } + let size = maximum - minimum + if isPoint { + let position = simd_float3(world.columns.3.x, world.columns.3.y, world.columns.3.z) + minimum + return (matrix4x4Translation(position.x, position.y, position.z), size) + } + return (simd_mul(world, matrix4x4Translation(minimum.x, minimum.y, minimum.z)), size) + } +} + +/// The boxes of a selection of several entities. The selection writes them +/// and the highlight pass reads them at every frame; with one entity selected +/// there are none, and the pass draws that entity's box as it always did. +final class SelectionHighlights { + static let shared = SelectionHighlights() + + private let lock = NSLock() + private var stored: [SelectionHighlightBox] = [] + + var boxes: [SelectionHighlightBox] { + get { + lock.lock() + defer { lock.unlock() } + return stored + } + set { + lock.lock() + defer { lock.unlock() } + stored = newValue + } + } + + /// The edges of the unit box, from zero to one along each axis, as the + /// twelve lines the highlight pass draws. + static let unitBoxLines: [simd_float4] = { + let corner: (Float, Float, Float) -> simd_float4 = { simd_float4($0, $1, $2, 1) } + return [ + // The bottom face + corner(0, 0, 0), corner(1, 0, 0), + corner(1, 0, 0), corner(1, 0, 1), + corner(1, 0, 1), corner(0, 0, 1), + corner(0, 0, 1), corner(0, 0, 0), + // The top face + corner(0, 1, 0), corner(1, 1, 0), + corner(1, 1, 0), corner(1, 1, 1), + corner(1, 1, 1), corner(0, 1, 1), + corner(0, 1, 1), corner(0, 1, 0), + // The edges between them + corner(0, 0, 0), corner(0, 1, 0), + corner(1, 0, 0), corner(1, 1, 0), + corner(1, 0, 1), corner(1, 1, 1), + corner(0, 0, 1), corner(0, 1, 1), + ] + }() +} diff --git a/Sources/UntoldEditor/Renderer/SelectionVisibilityPass.swift b/Sources/UntoldEditor/Renderer/SelectionVisibilityPass.swift new file mode 100644 index 0000000..1e1270f --- /dev/null +++ b/Sources/UntoldEditor/Renderer/SelectionVisibilityPass.swift @@ -0,0 +1,357 @@ +// +// SelectionVisibilityPass.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import CShaderTypes +import MetalKit +import simd +import UntoldEngine + +/// Tells which entities show in a rectangle of the viewport. +/// +/// Every mesh the scene draws that may show in the rectangle is drawn once +/// more, into a texture the size of the rectangle: each with the number of +/// its entity, the nearer over the farther. The numbers left in the texture +/// are then gathered, on the GPU too, so an entity behind a wall or under a +/// floor is not among them. It runs once, when the rectangle is released, on +/// the editor's own pipelines: the engine's passes are not touched. +enum SelectionVisibilityPass { + /// What the shader takes for each mesh: where to draw it and the number + /// to draw it with. + struct Uniforms { + var modelViewProjection: simd_float4x4 + var identifier: UInt32 + } + + /// The most pixels the texture may have. A larger rectangle is drawn at + /// a coarser scale. + static let maximumPixels: CGFloat = 4_000_000 + + /// The depth is written as the distance from the camera over this, so + /// it does not depend on how the engine's projection keeps depth. + static let farthestDistance: Float = 1_000_000 + + static let shaderSource = """ + #include + using namespace metal; + + struct SelectionVisibilityUniforms { + float4x4 modelViewProjection; + uint identifier; + }; + + struct SelectionVisibilityVertexIn { + float3 position [[attribute(0)]]; + }; + + struct SelectionVisibilityVertexOut { + float4 position [[position]]; + float distance; + }; + + struct SelectionVisibilityFragmentOut { + uint identifier [[color(0)]]; + float depth [[depth(any)]]; + }; + + vertex SelectionVisibilityVertexOut selectionVisibilityVertex( + SelectionVisibilityVertexIn in [[stage_in]], + constant SelectionVisibilityUniforms &uniforms [[buffer(1)]]) + { + SelectionVisibilityVertexOut out; + out.position = uniforms.modelViewProjection * float4(in.position, 1.0); + out.distance = out.position.w; + return out; + } + + fragment SelectionVisibilityFragmentOut selectionVisibilityFragment( + SelectionVisibilityVertexOut in [[stage_in]], + constant SelectionVisibilityUniforms &uniforms [[buffer(1)]]) + { + SelectionVisibilityFragmentOut out; + out.identifier = uniforms.identifier; + out.depth = saturate(in.distance / \(farthestDistance)); + return out; + } + + // Marks every number that is in the texture. Many pixels mark the same + // one at once, and all of them write the same value. + kernel void selectionVisibilityGather( + texture2d identifiers [[texture(0)]], + device atomic_uint *seen [[buffer(0)]], + uint2 pixel [[thread_position_in_grid]]) + { + if (pixel.x >= identifiers.get_width() || pixel.y >= identifiers.get_height()) { + return; + } + uint identifier = identifiers.read(pixel).r; + if (identifier != 0) { + atomic_store_explicit(&seen[identifier], 1, memory_order_relaxed); + } + } + """ + + private struct Pipeline { + let state: MTLRenderPipelineState + let depth: MTLDepthStencilState + let gather: MTLComputePipelineState + } + + private static var pipeline: Pipeline? + private static var pipelineCouldNotBeMade = false + + /// The entities among `drawn` that show in `rect`, or nil when that + /// cannot be told: without a device, or when the pipeline cannot be made. + /// + /// - Parameters: + /// - rect: the rectangle in points from the bottom left of the viewport. + /// - view: how the camera sees the world, and the viewport's size in points. + /// - scale: pixels per point of the viewport. + /// - drawn: the entities whose meshes may show in the rectangle; those not selectable hide what is behind them all the same. + static func entitiesSeen( + in rect: CGRect, + view: MarqueeGeometry.View, + scale: CGFloat, + drawn: [EntityID] + ) -> Set? { + guard drawn.isEmpty == false else { + return [] + } + guard let device = renderInfo.device, + let commandQueue = renderInfo.commandQueue, + let pipeline = makePipelineIfNeeded(device: device), + let region = PixelRegion(rect: rect, viewSize: view.size, scale: scale) + else { + return nil + } + + let colorDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .r32Uint, width: region.width, height: region.height, mipmapped: false + ) + colorDescriptor.usage = [.renderTarget, .shaderRead] + colorDescriptor.storageMode = .private + let depthDescriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .depth32Float, width: region.width, height: region.height, mipmapped: false + ) + depthDescriptor.usage = [.renderTarget] + depthDescriptor.storageMode = .private + + // One mark for each entity drawn, and one for no entity; a new buffer is all zeros. + guard let color = device.makeTexture(descriptor: colorDescriptor), + let depth = device.makeTexture(descriptor: depthDescriptor), + let seen = device.makeBuffer(length: (drawn.count + 1) * MemoryLayout.stride, options: .storageModeShared), + let commandBuffer = commandQueue.makeCommandBuffer() + else { + return nil + } + + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = color + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + pass.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) + pass.depthAttachment.texture = depth + pass.depthAttachment.loadAction = .clear + pass.depthAttachment.storeAction = .dontCare + pass.depthAttachment.clearDepth = 1 + + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { + return nil + } + encoder.label = "Selection Visibility Pass" + encoder.setRenderPipelineState(pipeline.state) + encoder.setDepthStencilState(pipeline.depth) + // Both faces of a triangle are drawn, as the engine draws its models. + encoder.setCullMode(.none) + // The whole viewport, placed so that the rectangle falls on the texture. + encoder.setViewport(MTLViewport( + originX: -Double(region.x), + originY: -Double(region.y), + width: Double(region.viewWidth), + height: Double(region.viewHeight), + znear: 0, + zfar: 1 + )) + + let viewProjection = simd_mul(view.perspectiveSpace, view.viewSpace) + let skipsTransparents = isIgnoringRayIntersectionWithTransparents() + let positions = Int(modelPassVerticesIndex.rawValue) + + for (index, entityId) in drawn.enumerated() { + guard let render = scene.get(component: RenderComponent.self, for: entityId), + let world = scene.get(component: WorldTransformComponent.self, for: entityId)?.space + else { + continue + } + for mesh in render.mesh where hasThePositionsThePipelineReads(mesh) { + var uniforms = Uniforms( + modelViewProjection: simd_mul(viewProjection, simd_mul(world, mesh.localSpace)), + identifier: UInt32(index + 1) + ) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1) + encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 1) + let vertices = mesh.metalKitMesh.vertexBuffers[positions] + encoder.setVertexBuffer(vertices.buffer, offset: vertices.offset, index: 0) + + for subMesh in mesh.submeshes { + // What a click passes through, the rectangle sees through. + if skipsTransparents, subMesh.material?.hasTransparency == true { + continue + } + let part = subMesh.metalKitSubmesh + encoder.drawIndexedPrimitives( + type: part.primitiveType, + indexCount: part.indexCount, + indexType: part.indexType, + indexBuffer: part.indexBuffer.buffer, + indexBufferOffset: part.indexBuffer.offset + ) + } + } + } + encoder.endEncoding() + + // The numbers in the texture are gathered where they are: reading + // millions of pixels back to count them here would take far longer. + guard let gather = commandBuffer.makeComputeCommandEncoder() else { + return nil + } + gather.label = "Selection Visibility Gather" + gather.setComputePipelineState(pipeline.gather) + gather.setTexture(color, index: 0) + gather.setBuffer(seen, offset: 0, index: 0) + let across = pipeline.gather.threadExecutionWidth + let down = max(1, pipeline.gather.maxTotalThreadsPerThreadgroup / across) + gather.dispatchThreads( + MTLSize(width: region.width, height: region.height, depth: 1), + threadsPerThreadgroup: MTLSize(width: across, height: down, depth: 1) + ) + gather.endEncoding() + // The answer is waited for, on the mouse-up that asks for it. The + // wait stays short because only what may show in the rectangle was + // drawn, into no more than `maximumPixels`. + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + guard commandBuffer.error == nil else { + return nil + } + + let marks = seen.contents().bindMemory(to: UInt32.self, capacity: drawn.count + 1) + return Set(drawn.enumerated().compactMap { marks[$0.offset + 1] != 0 ? $0.element : nil }) + } + + /// Whether a mesh keeps its positions as the pipeline reads them: four + /// floats to a vertex in a buffer of their own, as the engine's models do. + static func hasThePositionsThePipelineReads(_ mesh: Mesh) -> Bool { + let positions = Int(modelPassVerticesIndex.rawValue) + guard mesh.metalKitMesh.vertexBuffers.count > positions, + let layout = mesh.metalKitMesh.vertexDescriptor.layouts[positions] as? MDLVertexBufferLayout + else { + return false + } + return layout.stride == MemoryLayout.stride + } + + private static func makePipelineIfNeeded(device: MTLDevice) -> Pipeline? { + if let pipeline { + return pipeline + } + guard pipelineCouldNotBeMade == false else { + return nil + } + do { + let library = try device.makeLibrary(source: shaderSource, options: nil) + let descriptor = MTLRenderPipelineDescriptor() + descriptor.label = "Selection Visibility Pipeline" + descriptor.vertexFunction = library.makeFunction(name: "selectionVisibilityVertex") + descriptor.fragmentFunction = library.makeFunction(name: "selectionVisibilityFragment") + descriptor.colorAttachments[0].pixelFormat = .r32Uint + descriptor.depthAttachmentPixelFormat = .depth32Float + + // The first three of the four floats the engine keeps for a position. + let vertexDescriptor = MTLVertexDescriptor() + vertexDescriptor.attributes[0].format = .float3 + vertexDescriptor.attributes[0].offset = 0 + vertexDescriptor.attributes[0].bufferIndex = 0 + vertexDescriptor.layouts[0].stride = MemoryLayout.stride + vertexDescriptor.layouts[0].stepFunction = .perVertex + descriptor.vertexDescriptor = vertexDescriptor + + let depthDescriptor = MTLDepthStencilDescriptor() + depthDescriptor.depthCompareFunction = .lessEqual + depthDescriptor.isDepthWriteEnabled = true + + guard let depth = device.makeDepthStencilState(descriptor: depthDescriptor) else { + pipelineCouldNotBeMade = true + return nil + } + guard let gatherFunction = library.makeFunction(name: "selectionVisibilityGather") else { + pipelineCouldNotBeMade = true + return nil + } + let made = try Pipeline( + state: device.makeRenderPipelineState(descriptor: descriptor), + depth: depth, + gather: device.makeComputePipelineState(function: gatherFunction) + ) + pipeline = made + return made + } catch { + pipelineCouldNotBeMade = true + Logger.log(message: "The rectangle selects by the entities' boxes: the pass that tells what is seen could not be made (\(error.localizedDescription)).") + return nil + } + } + + /// The pixels of the viewport the rectangle covers, from the viewport's + /// top left as a texture is laid out, and the viewport's own size in the + /// same pixels. + struct PixelRegion: Equatable { + let x: Int + let y: Int + let width: Int + let height: Int + let viewWidth: Int + let viewHeight: Int + + /// Nil for a viewport without a size and for a rectangle that is + /// wholly outside it. A rectangle without a width or a height, which + /// a drag straight along one axis draws, covers one pixel that way. + init?(rect: CGRect, viewSize: CGSize, scale: CGFloat) { + guard viewSize.width > 0, viewSize.height > 0, scale > 0, rect.isNull == false, rect.isInfinite == false else { + return nil + } + let bounds = CGRect(origin: .zero, size: viewSize) + let inside = rect.standardized.intersection(bounds) + guard inside.isNull == false else { + return nil + } + + // A rectangle of very many pixels is drawn coarser. + var pixelsPerPoint = scale + let pixels = max(inside.width, 1) * max(inside.height, 1) * scale * scale + if pixels > SelectionVisibilityPass.maximumPixels { + pixelsPerPoint = scale * (SelectionVisibilityPass.maximumPixels / pixels).squareRoot() + } + + viewWidth = max(1, Int((viewSize.width * pixelsPerPoint).rounded(.up))) + viewHeight = max(1, Int((viewSize.height * pixelsPerPoint).rounded(.up))) + let left = Int((inside.minX * pixelsPerPoint).rounded(.down)) + let right = Int((inside.maxX * pixelsPerPoint).rounded(.up)) + // The rectangle is measured from the bottom, a texture from the top. + let top = Int(((viewSize.height - inside.maxY) * pixelsPerPoint).rounded(.down)) + let bottom = Int(((viewSize.height - inside.minY) * pixelsPerPoint).rounded(.up)) + + x = min(max(left, 0), viewWidth - 1) + y = min(max(top, 0), viewHeight - 1) + width = max(1, min(right, viewWidth) - x) + height = max(1, min(bottom, viewHeight) - y) + } + } +} diff --git a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift index 05be242..bd061f6 100644 --- a/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift +++ b/Sources/UntoldEditor/Systems/EditorInputSystemAppKit.swift @@ -34,6 +34,15 @@ /// Set when a left-button drag began as a ⇧-drag with a selection, which /// moves that entity from the mouse deltas and takes no gizmo handle. var isEntityDragReserved = false + /// Where a left-button drag that took no gizmo handle began: it draws + /// the rectangle that selects what is inside it. Nil while none is drawn. + var marqueeStart: NSPoint? + /// The rectangle as the drag last left it, in the canvas's points from + /// its bottom left, the size of the canvas it is measured in, and the + /// canvas's pixels to a point. + var marqueeRect: CGRect = .zero + var marqueeViewSize: CGSize = .zero + var marqueeScale: CGFloat = 1 /// Whether the keyboard itself holds a key down, asked of the system and /// not of the events. Tests replace it. var isKeyPhysicallyDown: (UInt16) -> Bool = { keyCode in @@ -323,6 +332,8 @@ keyState.rightMousePressed = false mouseActive = false + // A rectangle left half drawn selects nothing. + cancelMarquee() if editorInputTargetViewRef.isObjectDragActive { endObjectDrag() } @@ -811,9 +822,10 @@ } /// A left click: selects what is under the pointer, or clears the - /// selection when nothing is there, so the Inspector empties. A click on - /// a gizmo handle is left to the drag that moves it. While the game plays - /// a click is the game's and selects nothing. + /// selection when nothing is there, so the Inspector empties. With ⇧ + /// held what is under the pointer joins the selection, or leaves it. A + /// click on a gizmo handle is left to the drag that moves it. While the + /// game plays a click is the game's and selects nothing. func selectEntity(at currentLocation: NSPoint, in view: NSView) { guard editorController?.isEnabled == true, ViewportCameras.isPlaying == false else { return @@ -824,6 +836,11 @@ return } + if keyState.shiftPressed { + toggleEntity(at: currentLocation, in: view) + return + } + let rayContext = raycastContext(currentLocation: currentLocation, view: view) let (entityId, hit) = entityForClick(at: currentLocation, in: view) @@ -946,6 +963,25 @@ return (.invalid, false) } + /// A ⇧ click: the entity under the pointer joins the selection, or + /// leaves it when it was selected; with ⌘ held too, the asset it + /// belongs to. On a gizmo handle and on empty space nothing changes. + func toggleEntity(at location: NSPoint, in view: NSView) { + let (entityId, hit) = getRaycastedEntity(currentLocation: location, view: view) + guard hit, entityId != .invalid, + hasComponent(entityId: entityId, componentType: GizmoComponent.self) == false + else { + return + } + let toggled = keyState.commandPressed ? editableAssetRootEntity(for: entityId) : entityId + guard canEditSceneTransform(entityId: sceneTransformEntity(for: toggled)) else { + return + } + EditorRepresentationHandles.select(nil) + selectionDelegate?.didToggleEntity(toggled) + selectionDelegate?.resetActiveAxis() + } + // MARK: - The scene on the left button /// True while the editor is there, enabled and editing; it only gates @@ -982,9 +1018,10 @@ currentPanGestureState = .began // Editor-only: hit-test gizmo if editor/gizmo mode is active + activeHitGizmoEntity = .invalid if gizmoActive, isEditorEnabled { let (hitEntityId, hit) = getRaycastedEntity(currentLocation: currentLocation, view: view) - if hit { + if hit, hitGizmoToolAxis(entityId: hitEntityId) { activeHitGizmoEntity = hitEntityId processGizmoAction(entityId: activeHitGizmoEntity) if let rayContext = raycastContext(currentLocation: currentLocation, view: view) { @@ -996,7 +1033,7 @@ ) } if activeEntity != .invalid { - EditorUndoManager.shared.beginTransformEdit(entityId: activeEntity) + EditorUndoManager.shared.beginTransformEdit(entityIds: gizmoTransformTargets()) } EditorRepresentationHandles.dragDidBegin() } else { @@ -1005,6 +1042,12 @@ editorController?.activeAxis = .none } } + + // A drag that took no handle draws the rectangle that selects + // what is inside it. + if isEditorEnabled, activeHitGizmoEntity == .invalid, canvasTakesThePointer { + beginMarquee(at: currentLocation, in: view) + } } /// The drag moved to `currentLocation`; `currentPanLocation` is how far it @@ -1017,6 +1060,11 @@ handleError(.noActiveCamera) return } + if editorInputTargetViewRef.marqueeStart != nil { + // While the rectangle is drawn the drag is the rectangle's alone + continueMarquee(to: currentLocation, in: view) + return + } let isEditorEnabled = isEditorEnabled // Editor-only: process gizmo if we hit one @@ -1071,12 +1119,13 @@ return } let isEditorEnabled = isEditorEnabled + endMarquee() if isEditorEnabled, activeHitGizmoEntity != .invalid, activeEntity != .invalid { - EditorUndoManager.shared.commitTransformEdit(entityId: activeEntity) + EditorUndoManager.shared.commitTransformEdit(entityIds: gizmoTransformTargets()) EditorRepresentationHandles.dragDidEnd() } @@ -1087,6 +1136,88 @@ endGizmoDrag() } + // MARK: - The rectangle on the left button + + /// True while a drag of the left button draws the rectangle. + internal var isMarqueeActive: Bool { + editorInputTargetViewRef.marqueeStart != nil + } + + /// A drag began where no gizmo handle is: it draws a rectangle. + func beginMarquee(at location: NSPoint, in view: NSView) { + editorInputTargetViewRef.marqueeStart = location + editorInputTargetViewRef.marqueeRect = CGRect(origin: location, size: .zero) + editorInputTargetViewRef.marqueeViewSize = view.bounds.size + editorInputTargetViewRef.marqueeScale = view.window?.backingScaleFactor ?? 1 + } + + /// The drag went on to `location`: the rectangle follows it, and the + /// viewport draws it. + func continueMarquee(to location: NSPoint, in view: NSView) { + guard let start = editorInputTargetViewRef.marqueeStart else { + return + } + let rect = MarqueeGeometry.rect(from: start, to: location) + editorInputTargetViewRef.marqueeRect = rect + editorInputTargetViewRef.marqueeViewSize = view.bounds.size + ViewportMarqueeStore.shared.show(MarqueeGeometry.flipped(rect, inHeight: view.bounds.height)) + } + + /// The button was released: what stands inside the rectangle is the + /// selection, with ⌘ held the assets it belongs to, and nothing when + /// nothing is inside. What reaches out of the rectangle, as the floor + /// under it does, is left out, and so is what is hidden behind + /// something else. + func endMarquee() { + guard editorInputTargetViewRef.marqueeStart != nil else { + return + } + let rect = editorInputTargetViewRef.marqueeRect + let size = editorInputTargetViewRef.marqueeViewSize + let scale = editorInputTargetViewRef.marqueeScale + cancelMarquee() + + guard editorController?.isEnabled == true, ViewportCameras.isPlaying == false, + let cameraComponent = scene.get(component: CameraComponent.self, for: findSceneCamera()) + else { + return + } + + let inside = MarqueeSelection.entities( + inside: rect, + view: MarqueeGeometry.View( + viewSpace: cameraComponent.viewSpace, + perspectiveSpace: renderInfo.perspectiveSpace, + size: size + ), + selectionManager: editorController?.selectionManager, + seen: { rect, view, drawn in + SelectionVisibilityPass.entitiesSeen(in: rect, view: view, scale: scale, drawn: drawn) + } + ) + let chosen = keyState.commandPressed ? MarqueeSelection.assetRoots(of: inside) : inside + + gizmoActive = false + removeGizmo() + editorController?.activeMode = .none + editorController?.activeAxis = .none + activeHitGizmoEntity = .invalid + EditorRepresentationHandles.select(nil) + + guard chosen.isEmpty == false else { + clearViewportSelection() + return + } + selectionDelegate?.didSelectEntities(chosen) + selectionDelegate?.resetActiveAxis() + } + + /// Takes the rectangle away without selecting anything. + func cancelMarquee() { + editorInputTargetViewRef.marqueeStart = nil + ViewportMarqueeStore.shared.hide() + } + // MARK: - The camera on the right button /// Radians the view turns per point of a look drag. diff --git a/Sources/UntoldEditor/Systems/GizmoGroup.swift b/Sources/UntoldEditor/Systems/GizmoGroup.swift new file mode 100644 index 0000000..52f1dbb --- /dev/null +++ b/Sources/UntoldEditor/Systems/GizmoGroup.swift @@ -0,0 +1,243 @@ +// +// GizmoGroup.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import Foundation +import simd +import UntoldEngine + +// With several entities selected one gizmo stands in the middle of them and +// works on all: a move carries each by the same distance, a turn takes them +// round the gizmo, and a scale grows them and their distance from it. + +/// The entities the gizmo moves when several are selected. The selection +/// sets it; empty, the gizmo moves the active entity alone. +var gizmoTargets: [EntityID] = [] + +/// Where an entity stood and how big it was when a drag began. A drag works +/// from there, so what it did so far never adds up with what it does next. +struct GizmoTargetStart { + let entityId: EntityID + let localPosition: simd_float3 + /// Where it stood in the world. A scale of several entities reads it, + /// and those all keep a world transform: see `hasBothTransforms`. + let worldPosition: simd_float3 + let scale: simd_float3 + + init(entityId: EntityID) { + self.entityId = entityId + localPosition = getLocalPosition(entityId: entityId) + worldPosition = getPosition(entityId: entityId) + scale = getScale(entityId: entityId) + } +} + +/// Whether an entity keeps both of its transforms: its place under its +/// parent, which the gizmo writes, and its place in the world, which a turn +/// and a scale of several entities start from. The engine gives an entity the +/// two together and takes them away together, so one without the other is +/// not an entity the gizmo can work on. +func hasBothTransforms(_ entityId: EntityID) -> Bool { + canEditSceneTransform(entityId: entityId) + && hasComponent(entityId: entityId, componentType: WorldTransformComponent.self) +} + +/// The entities a drag of the gizmo works on: the active one, or with several +/// selected every one of them that is still in the scene with both of its +/// transforms. +func gizmoTransformTargets() -> [EntityID] { + guard activeEntity != .invalid else { + return [] + } + let several = gizmoTargets.filter(hasBothTransforms) + guard several.count > 1, several.contains(activeEntity) else { + return [activeEntity] + } + return several +} + +/// Where the gizmo stands for several entities: the middle of the box, along +/// the world's axes, around everything they draw. One that draws nothing +/// counts as the point where it stands. +func gizmoGroupAnchorWorldPosition(of targets: [EntityID]) -> simd_float3? { + var minimum = simd_float3(repeating: .greatestFiniteMagnitude) + var maximum = simd_float3(repeating: -.greatestFiniteMagnitude) + var found = false + + for target in targets { + if let box = worldBoundsOfRenderableHierarchy(entityId: target) { + minimum = simd_min(minimum, box.min) + maximum = simd_max(maximum, box.max) + found = true + } else if hasComponent(entityId: target, componentType: WorldTransformComponent.self) { + let position = getPosition(entityId: target) + minimum = simd_min(minimum, position) + maximum = simd_max(maximum, position) + found = true + } + } + return found ? (minimum + maximum) * 0.5 : nil +} + +/// Puts the gizmo back in the middle of the entities it moves, which a turn +/// or a scale may have shifted. With one entity the gizmo stays where it is. +func reanchorGizmoOnTargets() { + let targets = gizmoTransformTargets() + guard gizmoActive, parentEntityIdGizmo != .invalid, targets.count > 1, + let anchor = gizmoGroupAnchorWorldPosition(of: targets) + else { + return + } + translateTo(entityId: parentEntityIdGizmo, position: anchor) +} + +/// A point of the world as the entity's parent measures it, which is how the +/// entity's position is kept. +func localPosition(ofWorld position: simd_float3, for entityId: EntityID) -> simd_float3 { + guard let parent = parentInSceneGraph(of: entityId), + let parentSpace = scene.get(component: WorldTransformComponent.self, for: parent)?.space + else { + return position + } + + let determinant = simd_determinant(parentSpace) + guard determinant.isFinite, abs(determinant) > 1e-12 else { + // A parent squashed flat has no way back: the entity stays where it is. + return getLocalPosition(entityId: entityId) + } + let local = simd_mul(parentSpace.inverse, simd_float4(position, 1)) + return simd_float3(local.x, local.y, local.z) +} + +// MARK: - Moving + +/// Moves the targets of a drag to where they stood plus `translation`, a +/// movement in world space that each takes in its parent's measure. +func translateGizmoTargets(_ starts: [GizmoTargetStart], byWorld translation: simd_float3) { + for start in starts { + let local = localTranslation(ofWorld: translation, for: start.entityId) + translateTo(entityId: start.entityId, position: start.localPosition + local) + } +} + +/// Moves what the gizmo works on by `translation`, from where it is now. +func applyGizmoTranslation(byWorld translation: simd_float3) { + for target in gizmoTransformTargets() { + translateBy(entityId: target, position: localTranslation(ofWorld: translation, for: target)) + } +} + +// MARK: - Turning + +/// Turns the targets together by `degrees` about `axis`, the world's, through +/// `pivot`: each goes round the pivot and turns by the same angle itself. +func rotateGizmoTargets(_ targets: [EntityID], around pivot: simd_float3, axis: simd_float3, degrees: Float) { + guard degrees.isFinite, degrees != 0, simd_length_squared(axis) > 0.0001 else { + return + } + let turn = simd_quatf(angle: degreesToRadians(degrees: degrees), axis: simd_normalize(axis)) + + // Where each one goes is taken before any of them moves. + let destinations = targets.map { pivot + simd_act(turn, getPosition(entityId: $0) - pivot) } + for (target, destination) in zip(targets, destinations) { + applyGizmoRotationDelta(entityId: target, axis: axis, degrees: degrees) + translateTo(entityId: target, position: localPosition(ofWorld: destination, for: target)) + } +} + +/// Turns what the gizmo works on: the active entity about itself, as ever, +/// or several entities round the gizmo. +func applyGizmoRotation(axis: simd_float3, degrees: Float) { + let targets = gizmoTransformTargets() + guard targets.count > 1 else { + applyGizmoRotationDelta(entityId: activeEntity, axis: axis, degrees: degrees) + return + } + rotateGizmoTargets(targets, around: gizmoRootWorldPosition(), axis: axis, degrees: degrees) +} + +// MARK: - Scaling + +/// The smallest a scale may get, as the engine keeps it for one entity. +let gizmoMinimumScale: Float = 0.01 + +/// How much several entities grow for a drag of `amount` along the gizmo's +/// axis: a drag of one unit doubles them, and one back by half halves them. +func gizmoGroupScaleFactor(forAmount amount: Float) -> Float { + guard amount.isFinite else { + return 1 + } + return max(gizmoMinimumScale, 1 + amount) +} + +/// The scale of an entity grown by `factor` along `axis`, the world's. An +/// entity keeps its scale along its own axes, so each of them takes the +/// share of the growth that the world's axis has along it. +func gizmoScale(_ scale: simd_float3, of entityId: EntityID, grownBy factor: Float, along axis: simd_float3) -> simd_float3 { + guard let local = scene.get(component: LocalTransformComponent.self, for: entityId) else { + return scale + } + let axisForParent = localAxis(ofWorld: axis, for: entityId) + let toOwnSpace = simd_transpose(transformQuaternionToMatrix3x3(q: local.rotation)) + let own = simd_mul(toOwnSpace, axisForParent) + let length = simd_length(own) + guard length.isFinite, length > 0.0001 else { + return scale + } + + let share = simd_abs(own / length) + let grown = scale * (simd_float3(repeating: 1) + share * (factor - 1)) + return simd_max(grown, simd_float3(repeating: gizmoMinimumScale)) +} + +/// Scales the targets together by `factor` along `axis`, the world's, from +/// `pivot`: each grows along the axis, and so does its distance from the +/// pivot. A light keeps its size and only moves. +func scaleGizmoTargets(_ starts: [GizmoTargetStart], from pivot: simd_float3, axis: simd_float3, factor: Float) { + guard factor.isFinite, simd_length_squared(axis) > 0.0001 else { + return + } + let direction = simd_normalize(axis) + + for start in starts { + let offset = start.worldPosition - pivot + let destination = start.worldPosition + direction * ((factor - 1) * simd_dot(offset, direction)) + translateTo(entityId: start.entityId, position: localPosition(ofWorld: destination, for: start.entityId)) + + if hasComponent(entityId: start.entityId, componentType: LightComponent.self) == false { + scaleTo( + entityId: start.entityId, + scale: gizmoScale(start.scale, of: start.entityId, grownBy: factor, along: direction) + ) + } + } +} + +/// Scales what the gizmo works on by `amount` more along `axis`, the world +/// direction of the `handle` dragged: the active entity as ever, or several +/// entities from the gizmo. +func applyGizmoScale(axis: simd_float3, amount: Float, handle: TransformAxis) { + let targets = gizmoTransformTargets() + guard targets.count > 1 else { + if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { + // The engine takes the axis as which components of the scale to change. + handleLightScaleInput(projectedAmount: amount, axis: worldDirection(for: handle)) + } else { + // The engine takes the axis as the entity's parent sees it. + applyWorldSpaceScaleDelta(entityId: activeEntity, worldAxis: localAxis(ofWorld: axis, for: activeEntity), projectedAmount: amount) + } + return + } + scaleGizmoTargets( + targets.map(GizmoTargetStart.init(entityId:)), + from: gizmoRootWorldPosition(), + axis: axis, + factor: gizmoGroupScaleFactor(forAmount: amount) + ) +} diff --git a/Sources/UntoldEditor/Systems/GizmoSystem.swift b/Sources/UntoldEditor/Systems/GizmoSystem.swift index b6e117b..0b289bb 100644 --- a/Sources/UntoldEditor/Systems/GizmoSystem.swift +++ b/Sources/UntoldEditor/Systems/GizmoSystem.swift @@ -78,7 +78,9 @@ private struct GizmoDragState { let axis: TransformAxis let axisWorldDirection: simd_float3 let startAxisParameter: Float - let startActiveLocalPosition: simd_float3 + /// What the drag works on, as it stood when the drag began: the active + /// entity, or with several selected every one the gizmo moves. + let targets: [GizmoTargetStart] let startGizmoWorldPosition: simd_float3 /// When the gizmo sits on a handle of an entity written in code, the drag moves the /// handle (writes its property) instead of the entity. @@ -186,7 +188,7 @@ func beginGizmoDrag(ray: GizmoDragRay) { axis: handleComponent.axis, axisWorldDirection: axisDirection, startAxisParameter: startParameter, - startActiveLocalPosition: getLocalPosition(entityId: activeEntity), + targets: gizmoTransformTargets().map(GizmoTargetStart.init(entityId:)), startGizmoWorldPosition: gizmoRootWorldPosition(), handle: handleComponent.mode == .translate ? EditorRepresentationHandles.active : nil ) @@ -222,14 +224,21 @@ func updateGizmoDrag(ray: GizmoDragRay) { if let handle = state.handle { EditorRepresentationHandles.move(handle, toWorld: state.startGizmoWorldPosition + translation) } else { - // The gizmo moves in world space; the entity's position is its parent's. - let localTranslation = localTranslation(ofWorld: translation, for: activeEntity) - translateTo(entityId: activeEntity, position: state.startActiveLocalPosition + localTranslation) + // The gizmo moves in world space; an entity's position is its parent's. + translateGizmoTargets(state.targets, byWorld: translation) } translateTo(entityId: parentEntityIdGizmo, position: state.startGizmoWorldPosition + translation) case .scale: - if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { + if state.targets.count > 1 { + // Several entities grow together, from where they stood. + scaleGizmoTargets( + state.targets, + from: state.startGizmoWorldPosition, + axis: state.axisWorldDirection, + factor: gizmoGroupScaleFactor(forAmount: axisAmount) + ) + } else if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { // The engine takes the axis as which components of the scale to change. handleLightScaleInput(projectedAmount: incrementalAmount, axis: worldDirection(for: state.axis)) } else { @@ -268,6 +277,7 @@ func endGizmoDrag() { gizmoDragState = nil pendingGizmoDragRay = nil gizmoRotationDrag = nil + reanchorGizmoOnTargets() } func hasActiveAxisGizmoDrag() -> Bool { @@ -317,7 +327,7 @@ private func syncStoredAxisRotationsFromQuaternion(entityId: EntityID) { /// The entity's parent, or nil for a root. An entity outside the scene graph /// has none, which the engine's `getEntityParent` would report as an error. -private func parentInSceneGraph(of entityId: EntityID) -> EntityID? { +func parentInSceneGraph(of entityId: EntityID) -> EntityID? { guard hasComponent(entityId: entityId, componentType: ScenegraphComponent.self), let parent = getEntityParent(entityId: entityId), parent != .invalid @@ -1049,7 +1059,11 @@ func createGizmo(mode: GizmoMode) { registerSceneGraphComponent(entityId: parentEntityIdGizmo) registerComponent(entityId: parentEntityIdGizmo, componentType: GizmoComponent.self) - let anchor = EditorRepresentationHandles.active.flatMap(EditorRepresentationHandles.worldPosition) ?? gizmoAnchorWorldPosition(entityId: activeEntity) + // With several selected the gizmo stands in the middle of them. + let targets = gizmoTransformTargets() + let anchor = EditorRepresentationHandles.active.flatMap(EditorRepresentationHandles.worldPosition) + ?? (targets.count > 1 ? gizmoGroupAnchorWorldPosition(of: targets) : nil) + ?? gizmoAnchorWorldPosition(entityId: activeEntity) translateTo(entityId: parentEntityIdGizmo, position: anchor) switch mode { @@ -1062,7 +1076,8 @@ func createGizmo(mode: GizmoMode) { } makeGizmoCenter() - if hasComponent(entityId: activeEntity, componentType: LightComponent.self) { + // The handle that aims a light is for that light alone. + if targets.count <= 1, hasComponent(entityId: activeEntity, componentType: LightComponent.self) { directionHandleEntityId = makeDirectionHandle() } diff --git a/Sources/UntoldEditor/Systems/MarqueeSelection.swift b/Sources/UntoldEditor/Systems/MarqueeSelection.swift new file mode 100644 index 0000000..4a3c1be --- /dev/null +++ b/Sources/UntoldEditor/Systems/MarqueeSelection.swift @@ -0,0 +1,167 @@ +// +// MarqueeSelection.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import CoreGraphics +import simd +import UntoldEngine + +/// What the rectangle dragged over the viewport selects: every entity that +/// stands inside it and that a click could select. +/// +/// An entity is inside when all of its box is, so the floor under what the +/// rectangle is drawn around, or the wall behind it, is left out: it reaches +/// out of the rectangle. What has a place and no box, such as a light, is +/// inside where it stands. Of what draws meshes, only what is seen counts: +/// what stands behind a wall or under a floor is left out though it is +/// inside. Telling what is seen takes a pass of the renderer, handed in as +/// `seen`; without one, being inside is enough. The pass is handed only what +/// reaches into the rectangle: what is outside it neither shows in it nor +/// hides anything in it, so the cost follows what the rectangle covers and +/// not the size of the scene. +enum MarqueeSelection { + /// Tells which of `drawn`, the entities whose meshes may show in a + /// rectangle of the viewport, do show in it, or nil when it cannot tell. + typealias Seen = (_ rect: CGRect, _ view: MarqueeGeometry.View, _ drawn: [EntityID]) -> Set? + + /// The entities inside the rectangle, the farthest from the camera first + /// and the nearest last. `rect` is in points from the bottom left of the + /// viewport, as the canvas measures the pointer. + static func entities( + inside rect: CGRect, + view: MarqueeGeometry.View, + selectionManager: SelectionManager?, + seen: Seen? = nil + ) -> [EntityID] { + guard let frustum = MarqueeGeometry.Frustum(rect, view: view) else { + return [] + } + let eye = simd_mul(simd_inverse(view.viewSpace), simd_float4(0, 0, 0, 1)) + let camera = simd_float3(eye.x, eye.y, eye.z) + var inside: [(entityId: EntityID, distance: Float, drawsMeshes: Bool)] = [] + // What draws meshes that may show in the rectangle, selectable or + // not: all the pass has to draw. + var mayShow: [EntityID] = [] + + // Where an entity stands is asked first, and the rest only of what + // the rectangle reaches: in a scene of many thousands of entities + // that leaves nearly all of them out at once. + for entityId in getAllGameEntities() { + guard let world = scene.get(component: WorldTransformComponent.self, for: entityId)?.space else { + continue + } + let position = simd_float3(world.columns.3.x, world.columns.3.y, world.columns.3.z) + + var drawsItsMeshes = false + if draws(entityId), let local = scene.get(component: LocalTransformComponent.self, for: entityId) { + // The box the engine culls by, so what is outside by it is + // not drawn for the rectangle either. + let place = frustum.place( + ofBoxMinimum: local.boundingBox.min, + boxMaximum: local.boundingBox.max, + modelSpace: world + ) + guard place != .outside else { + continue + } + drawsItsMeshes = drawsMeshes(entityId) + if drawsItsMeshes { + mayShow.append(entityId) + } + guard place == .inside else { + continue + } + } else { + guard showsWhereItStands(entityId), frustum.contains(position) else { + continue + } + } + + if canBeSelected(entityId, selectionManager: selectionManager) { + inside.append((entityId, simd_distance(position, camera), drawsItsMeshes)) + } + } + + // Whatever draws a mesh hides what is behind it, selectable or not: a + // locked wall is not selected, and neither is what it covers. The pass + // is only asked when something it can tell about is inside. + if inside.contains(where: \.drawsMeshes), let shown = seen?(rect, view, mayShow) { + inside.removeAll { $0.drawsMeshes && shown.contains($0.entityId) == false } + } + return inside.sorted { $0.distance > $1.distance }.map(\.entityId) + } + + /// The asset each entity belongs to, in the same order and each once: what + /// the rectangle selects with ⌘ held, as a ⌘ click selects the asset of + /// what it hits. + static func assetRoots(of entityIds: [EntityID]) -> [EntityID] { + var seen = Set() + // The nearest entity decides where its asset stands in the order. + let roots = entityIds.reversed().map(editableAssetRootEntity(for:)).filter { seen.insert($0).inserted } + return roots.reversed() + } + + /// Whether a click could select the entity: it is the scene's, the + /// picking takes it, and it is neither hidden nor locked. + static func canBeSelected(_ entityId: EntityID, selectionManager: SelectionManager?) -> Bool { + guard hasComponent(entityId: entityId, componentType: GizmoComponent.self) == false, + hasComponent(entityId: entityId, componentType: CameraComponent.self) == false, + hasComponent(entityId: entityId, componentType: SceneCameraComponent.self) == false + else { + return false + } + if let picking = scene.get(component: PickInteractionComponent.self, for: entityId), + picking.participatesInPicking == false || picking.hitRepresentationMode == .none + { + return false + } + guard isSceneEntityPickableByChannel(entityId: entityId) else { + return false + } + if let selectionManager, + selectionManager.isEffectivelyHidden(entityId) || selectionManager.isEffectivelyLocked(entityId) + { + return false + } + return true + } + + /// Whether the scene draws meshes of this entity, which the pass that + /// tells what is seen can draw too. + static func drawsMeshes(_ entityId: EntityID) -> Bool { + guard let render = scene.get(component: RenderComponent.self, for: entityId), + render.isVisible, + hasComponent(entityId: entityId, componentType: LightComponent.self) == false, + hasComponent(entityId: entityId, componentType: CameraComponent.self) == false, + hasComponent(entityId: entityId, componentType: GizmoComponent.self) == false, + shouldHideSceneEntity(entityId: entityId) == false + else { + return false + } + return render.mesh.contains(where: SelectionVisibilityPass.hasThePositionsThePipelineReads) + } + + /// Whether the entity itself draws something with a box around it. + static func draws(_ entityId: EntityID) -> Bool { + if let render = scene.get(component: RenderComponent.self, for: entityId) { + return render.isVisible + } + return hasComponent(entityId: entityId, componentType: GaussianComponent.self) + } + + /// Whether the editor shows where an entity that draws nothing stands: a + /// light by its icon, an entity written in code by its representation. + static func showsWhereItStands(_ entityId: EntityID) -> Bool { + guard hasComponent(entityId: entityId, componentType: RenderComponent.self) == false else { + return false + } + return hasComponent(entityId: entityId, componentType: LightComponent.self) + || EditorRepresentationRenderer.drawing(for: entityId) != nil + } +} diff --git a/Sources/UntoldEditor/Systems/ScriptProjectManager.swift b/Sources/UntoldEditor/Systems/ScriptProjectManager.swift index 15f9158..01f7a28 100644 --- a/Sources/UntoldEditor/Systems/ScriptProjectManager.swift +++ b/Sources/UntoldEditor/Systems/ScriptProjectManager.swift @@ -57,7 +57,7 @@ class ScriptProjectManager { // MARK: - Initialize Project func initializeProject() throws { - guard let basePath = EditorAssetBasePath.shared.basePath else { + guard EditorAssetBasePath.shared.basePath != nil else { throw ScriptProjectError.noAssetBasePath } diff --git a/Sources/UntoldEditor/UntoldEditorApp.swift b/Sources/UntoldEditor/UntoldEditorApp.swift index cc79d09..1e898ff 100644 --- a/Sources/UntoldEditor/UntoldEditorApp.swift +++ b/Sources/UntoldEditor/UntoldEditorApp.swift @@ -20,7 +20,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var window: NSWindow! private let appName = "Untold Engine Editor" - static let editorVersion = "0.21.0" + static let editorVersion = "0.22.0" private var projectTitleSubscription: AnyCancellable? diff --git a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift index 37ba4eb..a2b4cbe 100644 --- a/Tests/UntoldEditorTests/AssetBrowserViewTests.swift +++ b/Tests/UntoldEditorTests/AssetBrowserViewTests.swift @@ -967,12 +967,9 @@ final class AssetBrowserViewTests: XCTestCase { XCTAssertTrue(item.replacePaths.isEmpty) } - func test_defaultAssetPackCatalog_onlyShowsStarterPackWithThumbnail() { + func test_defaultAssetPackCatalog_isEmpty() { let catalog = defaultAssetPackCatalog() - XCTAssertEqual(catalog.assets.map(\.id), ["starter"]) - XCTAssertEqual(catalog.assets.first?.name, "StarterPack") - XCTAssertEqual(catalog.assets.first?.thumbnailName, "starterpack") - XCTAssertEqual(catalog.assets.first?.replacePaths, ["Models/starterpack"]) + XCTAssertTrue(catalog.assets.isEmpty) } func test_assetPackCategoryNames_sortsUniqueCategories() { diff --git a/Tests/UntoldEditorTests/AssetImportCopyTests.swift b/Tests/UntoldEditorTests/AssetImportCopyTests.swift index 81dc0e3..5d802a0 100644 --- a/Tests/UntoldEditorTests/AssetImportCopyTests.swift +++ b/Tests/UntoldEditorTests/AssetImportCopyTests.swift @@ -188,7 +188,7 @@ final class AssetImportCopyTests: XCTestCase { let destination = base.appendingPathComponent("copy.ply") var reports: [AssetCopyProgress] = [] - try copyAssetItem(from: source, to: destination, allowClone: false) { reports.append($0) } + try copyAssetItem(from: source, to: destination, allowClone: false, progress: { reports.append($0) }) XCTAssertEqual(try Data(contentsOf: destination), payload) XCTAssertEqual(reports.last, AssetCopyProgress(copied: Int64(payload.count), total: Int64(payload.count))) @@ -212,7 +212,7 @@ final class AssetImportCopyTests: XCTestCase { let destination = base.appendingPathComponent("copy", isDirectory: true) var last: AssetCopyProgress? - try copyAssetItem(from: source, to: destination, allowClone: false) { last = $0 } + try copyAssetItem(from: source, to: destination, allowClone: false, progress: { last = $0 }) XCTAssertEqual(try Data(contentsOf: destination.appendingPathComponent("Textures/albedo.png")), Data("bb".utf8)) XCTAssertEqual(try Data(contentsOf: destination.appendingPathComponent("material.json")), Data("a".utf8)) diff --git a/Tests/UntoldEditorTests/CodeComponentsTestSupport.swift b/Tests/UntoldEditorTests/CodeComponentsTestSupport.swift index 335ffab..4baeaa1 100644 --- a/Tests/UntoldEditorTests/CodeComponentsTestSupport.swift +++ b/Tests/UntoldEditorTests/CodeComponentsTestSupport.swift @@ -45,10 +45,15 @@ final class ScratchDirectory { } } -func makeTestSDK(providedModules: [String] = ["UntoldComponentKit", "UntoldEngine"]) -> ComponentSDK { +func makeTestSDK( + providedModules: [String] = ["UntoldComponentKit", "UntoldEngine"], + cModuleMaps: [String] = ["/sdk/CShaderTypes/module.modulemap"], + cModulesWithoutModuleMap: [String] = [] +) -> ComponentSDK { ComponentSDK( modulesDirectory: URL(fileURLWithPath: "/sdk/Modules"), - cShaderTypesModuleMap: URL(fileURLWithPath: "/sdk/CShaderTypes/module.modulemap"), + cModuleMaps: cModuleMaps.map { URL(fileURLWithPath: $0) }, + cModulesWithoutModuleMap: cModulesWithoutModuleMap, providedModules: providedModules, targetTriple: "arm64-apple-macosx14.0", recordedCompilerVersion: nil, diff --git a/Tests/UntoldEditorTests/ComponentCompilerTests.swift b/Tests/UntoldEditorTests/ComponentCompilerTests.swift index 0ba53b0..14ece0c 100644 --- a/Tests/UntoldEditorTests/ComponentCompilerTests.swift +++ b/Tests/UntoldEditorTests/ComponentCompilerTests.swift @@ -16,7 +16,13 @@ import XCTest final class ComponentCompilerTests: XCTestCase { private let toolchain = ComponentToolchain(swiftcPath: "/toolchain/swiftc", macOSSDKPath: "/sdks/MacOSX.sdk", compilerVersion: "Apple Swift version 6.4") - private func request(role: ComponentSourceUnit.Role, module: String, imports: [String] = [], revision: Int = 7) -> ComponentCompileRequest { + private func request( + role: ComponentSourceUnit.Role, + module: String, + imports: [String] = [], + revision: Int = 7, + sdk: ComponentSDK = makeTestSDK() + ) -> ComponentCompileRequest { let unit = ComponentSourceUnit( role: role, moduleBaseName: module, @@ -24,7 +30,7 @@ final class ComponentCompilerTests: XCTestCase { sources: [URL(fileURLWithPath: "/project/Sources/\(module)/A.swift"), URL(fileURLWithPath: "/project/Sources/\(module)/B.swift")], reloadableImports: imports ) - return ComponentCompileRequest(unit: unit, revision: revision, outputDirectory: URL(fileURLWithPath: "/cache"), sdk: makeTestSDK(), toolchain: toolchain) + return ComponentCompileRequest(unit: unit, revision: revision, outputDirectory: URL(fileURLWithPath: "/cache"), sdk: sdk, toolchain: toolchain) } func test_projectUnit_exactArguments() { @@ -46,6 +52,24 @@ final class ComponentCompilerTests: XCTestCase { ]) } + func test_everyCModuleOfTheSDK_getsItsModuleMapInOrder() { + let sdk = makeTestSDK(cModuleMaps: [ + "/sdk/CShaderTypes/module.modulemap", + "/sdk/CEngineAtomics/module.modulemap", + "/sdk/_AtomicsShims/module.modulemap", + ]) + + let arguments = ComponentCompiler.arguments(for: request(role: .project, module: "Game", sdk: sdk)) + + let clangOptions = zip(arguments, arguments.dropFirst()).filter { $0.0 == "-Xcc" }.map(\.1) + XCTAssertEqual(clangOptions, [ + "-fmodule-map-file=/sdk/CShaderTypes/module.modulemap", + "-fmodule-map-file=/sdk/CEngineAtomics/module.modulemap", + "-fmodule-map-file=/sdk/_AtomicsShims/module.modulemap", + ]) + XCTAssertEqual(arguments.firstIndex(of: "-Xlinker"), arguments.lastIndex(of: "-Xcc").map { $0 + 2 }, "the link options follow as before") + } + func test_moduleNameIsUniquePerRevision() { XCTAssertEqual(request(role: .project, module: "Game", revision: 1).moduleName, "Game_r1") XCTAssertEqual(request(role: .project, module: "Game", revision: 2).moduleName, "Game_r2") diff --git a/Tests/UntoldEditorTests/ComponentSDKBuildFolders.swift b/Tests/UntoldEditorTests/ComponentSDKBuildFolders.swift new file mode 100644 index 0000000..2f7a705 --- /dev/null +++ b/Tests/UntoldEditorTests/ComponentSDKBuildFolders.swift @@ -0,0 +1,126 @@ +// +// ComponentSDKBuildFolders.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import Foundation +@testable import UntoldEditor + +/// Build folders shaped like the ones SwiftPM and Xcode leave, for the tests of what the +/// Component SDK finds in one and of what the bundle script packages from one. +extension ScratchDirectory { + static let atomicsShimsModuleMap = """ + module _AtomicsShims { + header "_AtomicsShims.h" + } + """ + + /// A C target of the engine, in `engine/Sources`, and the module map a build system generates + /// for it: its headers folder as an umbrella, named by absolute path. CShaderTypes keeps its + /// headers beside its sources, as the engine's does. + func generatedModuleMap(ofEngineCTarget target: String) throws -> String { + var headers = "engine/Sources/\(target)" + try write("// \(target).c", to: "\(headers)/\(target).c") + if target != ComponentSDK.cShaderTypesModule { + headers += "/include" + } + try write("// \(target).h", to: "\(headers)/\(target).h") + return "module \(target) {\numbrella \"\(url.appendingPathComponent(headers).path)\"\nexport *\n}\n" + } + + /// What a build system generates for a Swift target, for Objective-C to import it. + private func swiftHeaderModuleMap(_ target: String) -> String { + "module \(target) {\nheader \"\(target)-Swift.h\"\nexport *\n}\n" + } + + /// `/out/Products/Debug` as Swift Build leaves it, where `root` is SwiftPM's scratch + /// folder. `otherCTargets` are built and get no generated module map: they bring their own. + func makeSwiftBuildProducts( + root: String = "build", + products: String = "out/Products/Debug", + intermediates: String = "out/Intermediates.noindex", + swiftTargets: [String], + generatedCTargets: [String], + otherCTargets: [String] = [] + ) throws -> URL { + let productsDirectory = try directory("\(root)/\(products)") + for target in swiftTargets { + try write("", to: "\(root)/\(products)/\(target).swiftmodule") + try write(swiftHeaderModuleMap(target), to: "\(root)/\(intermediates)/GeneratedModuleMaps/\(target).modulemap") + } + for target in generatedCTargets { + try write(generatedModuleMap(ofEngineCTarget: target), to: "\(root)/\(intermediates)/GeneratedModuleMaps/\(target).modulemap") + } + for target in swiftTargets + generatedCTargets + otherCTargets { + try write("", to: "\(root)/\(products)/\(target).o") + } + // What else sits beside the products. + try write("", to: "\(root)/\(products)/UntoldEditor") + _ = try directory("\(root)/\(products)/PackageFrameworks") + return productsDirectory + } + + /// `/arm64-apple-macosx/debug` as SwiftPM's native build system leaves it. + /// `declaredOnlyCTargets` are targets of a package that the editor does not use: they get a + /// generated module map and are not compiled. + func makeNativeProducts( + root: String = "build", + swiftTargets: [String], + generatedCTargets: [String], + otherCTargets: [String] = [], + declaredOnlyCTargets: [String] = [] + ) throws -> URL { + let products = "\(root)/arm64-apple-macosx/debug" + for target in swiftTargets { + try write("", to: "\(products)/Modules/\(target).swiftmodule") + try write("", to: "\(products)/\(target).build/Source.swift.o") + try write(swiftHeaderModuleMap(target), to: "\(products)/\(target).build/include/module.modulemap") + } + for target in generatedCTargets { + try write(generatedModuleMap(ofEngineCTarget: target), to: "\(products)/\(target).build/module.modulemap") + try write("", to: "\(products)/\(target).build/\(target).c.o") + } + for target in otherCTargets { + try write("", to: "\(products)/\(target).build/src/\(target).c.o") + } + for target in declaredOnlyCTargets { + try write(generatedModuleMap(ofEngineCTarget: target), to: "\(products)/\(target).build/module.modulemap") + } + return url.appendingPathComponent(products) + } + + /// swift-atomics as SwiftPM clones it: a C target with its own module map. + @discardableResult + func addAtomicsPackage(at path: String = "build/checkouts/swift-atomics") throws -> URL { + try write("// _AtomicsShims.h", to: "\(path)/Sources/_AtomicsShims/include/_AtomicsShims.h") + try write("// _AtomicsShims.c", to: "\(path)/Sources/_AtomicsShims/src/_AtomicsShims.c") + return try write(Self.atomicsShimsModuleMap, to: "\(path)/Sources/_AtomicsShims/include/module.modulemap") + } + + /// SwiftPM's record of the packages of a build, in its scratch folder `root`: the engine + /// cloned from its URL, and each of `packagesUsedByPath` where it is. + func writeWorkspaceState(root: String = "build", packagesUsedByPath: [String]) throws { + let cloned = """ + { "basedOn": null, "subpath": "UntoldEngine", + "packageRef": { "identity": "untoldengine", "kind": "remoteSourceControl", "location": "https://github.com/untoldengine/UntoldEngine.git", "name": "UntoldEngine" }, + "state": { "name": "sourceControlCheckout", "checkoutState": { "branch": "develop", "revision": "abc" } } } + """ + let byPath = packagesUsedByPath.map { path -> String in + let location = url.appendingPathComponent(path).path + let name = (path as NSString).lastPathComponent + return """ + { "basedOn": null, "subpath": "\(name)", + "packageRef": { "identity": "\(name)", "kind": "fileSystem", "location": "\(location)", "name": "\(name)" }, + "state": { "name": "fileSystem", "path": "\(location)" } } + """ + } + let dependencies = ([cloned] + byPath).joined(separator: ",\n") + try write("{ \"version\": 7, \"object\": { \"artifacts\": [], \"dependencies\": [\n\(dependencies)\n] } }\n", to: "\(root)/workspace-state.json") + } +} diff --git a/Tests/UntoldEditorTests/ComponentSDKBuildLayoutTests.swift b/Tests/UntoldEditorTests/ComponentSDKBuildLayoutTests.swift new file mode 100644 index 0000000..cc4c1dd --- /dev/null +++ b/Tests/UntoldEditorTests/ComponentSDKBuildLayoutTests.swift @@ -0,0 +1,206 @@ +// +// ComponentSDKBuildLayoutTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import Foundation +@testable import UntoldEditor +import XCTest + +/// The C modules an editor run from source compiles components with: the build folders here are +/// shaped like the ones SwiftPM and Xcode leave, for the engine as it is and for an engine with a +/// C target of its own (`CEngineAtomics`) and a package that brings one (`swift-atomics`). +final class ComponentSDKBuildLayoutTests: XCTestCase { + private func moduleMapPaths(productsDirectory: URL) throws -> [String] { + try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: productsDirectory)).cModuleMaps.map(\.path) + } + + // MARK: The engine as it is + + func test_swiftBuild_theEngineAsItIs_passesTheCShaderTypesModuleMapAndNothingElse() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit"], + generatedCTargets: ["CShaderTypes"] + ) + + XCTAssertEqual(try moduleMapPaths(productsDirectory: products), [ + scratch.url.appendingPathComponent("build/out/Intermediates.noindex/GeneratedModuleMaps/CShaderTypes.modulemap").path, + ]) + } + + func test_native_theEngineAsItIs_passesTheCShaderTypesModuleMapAndNothingElse() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeNativeProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "UntoldEditor"], + generatedCTargets: ["CShaderTypes"], + // The engine declares it, the editor does not use it: a module map and no objects. + declaredOnlyCTargets: ["UntoldEngineShaderSupport"] + ) + + XCTAssertEqual(try moduleMapPaths(productsDirectory: products), [ + products.appendingPathComponent("CShaderTypes.build/module.modulemap").path, + ]) + } + + // MARK: An engine with more C modules + + func test_swiftBuild_anEngineCTargetAndAPackageWithItsOwnModuleMap_areBothPassed() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics"], + generatedCTargets: ["CShaderTypes", "CEngineAtomics"], + otherCTargets: ["_AtomicsShims"] + ) + let ownModuleMap = try scratch.addAtomicsPackage() + + let sdk = try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: products)) + + let generated = scratch.url.appendingPathComponent("build/out/Intermediates.noindex/GeneratedModuleMaps") + XCTAssertEqual(sdk.cModuleMaps.map(\.path), [ + generated.appendingPathComponent("CShaderTypes.modulemap").path, + generated.appendingPathComponent("CEngineAtomics.modulemap").path, + ownModuleMap.path, + ], "CShaderTypes first, and never the module map of a Swift module's generated header") + XCTAssertEqual(sdk.providedModules, ["Atomics", "UntoldComponentKit", "UntoldEngine"]) + } + + func test_native_anEngineCTargetAndAPackageWithItsOwnModuleMap_areBothPassed() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeNativeProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics", "UntoldEditor"], + generatedCTargets: ["CShaderTypes", "CEngineAtomics"], + otherCTargets: ["_AtomicsShims"], + declaredOnlyCTargets: ["UntoldEngineShaderSupport"] + ) + let ownModuleMap = try scratch.addAtomicsPackage() + + XCTAssertEqual(try moduleMapPaths(productsDirectory: products), [ + products.appendingPathComponent("CShaderTypes.build/module.modulemap").path, + products.appendingPathComponent("CEngineAtomics.build/module.modulemap").path, + ownModuleMap.path, + ]) + } + + func test_xcode_findsThePackagesInTheDerivedDataFolder() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + root: "DerivedData/UntoldEditor-abc", + products: "Build/Products/Debug", + intermediates: "Build/Intermediates.noindex", + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics"], + generatedCTargets: ["CShaderTypes"], + otherCTargets: ["_AtomicsShims"] + ) + let ownModuleMap = try scratch.addAtomicsPackage(at: "DerivedData/UntoldEditor-abc/SourcePackages/checkouts/swift-atomics") + + XCTAssertEqual(try moduleMapPaths(productsDirectory: products), [ + scratch.url.appendingPathComponent("DerivedData/UntoldEditor-abc/Build/Intermediates.noindex/GeneratedModuleMaps/CShaderTypes.modulemap").path, + ownModuleMap.path, + ]) + } + + func test_aPackageUsedByPath_isFoundThroughTheWorkspaceState() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics"], + generatedCTargets: ["CShaderTypes"], + otherCTargets: ["_AtomicsShims"] + ) + let ownModuleMap = try scratch.addAtomicsPackage(at: "Elsewhere/swift-atomics") + // The clone from before the package was used by path: SwiftPM leaves it, and no longer lists it. + try scratch.addAtomicsPackage() + try scratch.writeWorkspaceState(packagesUsedByPath: ["Elsewhere/swift-atomics"]) + + XCTAssertEqual(try moduleMapPaths(productsDirectory: products).last, ownModuleMap.path) + } + + func test_aBuiltCTargetWithoutAnyModuleMap_isLeftOutAndReported() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit"], + generatedCTargets: ["CShaderTypes", "CEngineAtomics"], + otherCTargets: ["CNowhere"] + ) + + let sdk = try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: products)) + + XCTAssertEqual(sdk.cModuleMaps.map(\.lastPathComponent), ["CShaderTypes.modulemap", "CEngineAtomics.modulemap"]) + XCTAssertEqual(sdk.cModulesWithoutModuleMap, ["CNowhere"]) + + // The Plugins tab and the console say so: the compiler would only name a missing module. + let basePath = try scratch.directory("Game/Sources/Game/GameData") + let problems = ComponentSourceLocator.layout(forAssetBasePath: basePath, sdk: sdk).problems + XCTAssertEqual(problems.count, 1) + XCTAssertTrue(problems.first?.contains("CNowhere") == true, "\(problems)") + } + + func test_aTargetThatIsGoneFromItsPackage_andLeftItsObjectsBehind_isLeftOut() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit"], + generatedCTargets: ["CShaderTypes", "CEngineAtomics", "CStale"] + ) + // The build folder was used with an engine that had CStale; this one does not. + try FileManager.default.removeItem(at: scratch.url.appendingPathComponent("engine/Sources/CStale")) + + let sdk = try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: products)) + + XCTAssertEqual( + sdk.cModuleMaps.map(\.lastPathComponent), ["CShaderTypes.modulemap", "CEngineAtomics.modulemap"], + "the compiler warns about a module map whose headers are missing, in every build" + ) + XCTAssertEqual(sdk.cModulesWithoutModuleMap, [], "nothing to report: the target is not part of this engine") + } + + // MARK: A target's own module map + + func test_ownModuleMap_outsideTheDefaultFolder_isTheOneNearestThePackageRootThatDeclaresTheModule() throws { + let scratch = try ScratchDirectory() + let package = try scratch.directory("checkouts/vendor-kit") + // Declares the module only as a part of another. + try scratch.write("module Umbrella {\n module CVendor { header \"a.h\" }\n}", to: "checkouts/vendor-kit/module.modulemap") + // A copy for the package's benchmarks. + try scratch.write("module CVendor { header \"a.h\" }", to: "checkouts/vendor-kit/Benchmarks/Sources/CVendor/include/module.modulemap") + try scratch.write("module CVendorOther { header \"b.h\" }", to: "checkouts/vendor-kit/Vendor/other/module.modulemap") + let expected = try scratch.write("module CVendor [system] { header \"a.h\" }", to: "checkouts/vendor-kit/Vendor/shims/module.modulemap") + + XCTAssertEqual(ComponentSDKBuildLayout.ownModuleMap(of: "CVendor", inPackages: [package])?.path, expected.path) + XCTAssertNil(ComponentSDKBuildLayout.ownModuleMap(of: "CAbsent", inPackages: [package])) + } + + func test_ownModuleMap_theDefaultFolderWinsOverAnyOtherPackage() throws { + let scratch = try ScratchDirectory() + let first = try scratch.directory("checkouts/a-kit") + let second = try scratch.directory("checkouts/b-kit") + try scratch.write("module CShim { header \"a.h\" }", to: "checkouts/a-kit/Fixtures/module.modulemap") + let expected = try scratch.write("module CShim { header \"a.h\" }", to: "checkouts/b-kit/Sources/CShim/include/module.modulemap") + + XCTAssertEqual(ComponentSDKBuildLayout.ownModuleMap(of: "CShim", inPackages: [first, second])?.path, expected.path) + } + + func test_topLevelModules_readsNamesAndSkipsCommentsStringsAndSubmodules() { + let moduleMap = """ + // module Commented {} + /* module AlsoCommented { + } */ + framework module Alpha [system] { + umbrella header "module Quoted.h" // { not a brace + explicit module Part { header "part.h" } + module * { export * } + } + module Beta { link "beta" } + extern module Gamma "Gamma.modulemap" + module "Delta-Kit" {} + """ + + XCTAssertEqual(ComponentSDKBuildLayout.topLevelModules(inModuleMap: moduleMap), ["Alpha", "Beta", "Gamma", "Delta-Kit"]) + XCTAssertEqual(ComponentSDKBuildLayout.topLevelModules(inModuleMap: ""), []) + } +} diff --git a/Tests/UntoldEditorTests/ComponentSDKPackagingTests.swift b/Tests/UntoldEditorTests/ComponentSDKPackagingTests.swift new file mode 100644 index 0000000..8aca3f1 --- /dev/null +++ b/Tests/UntoldEditorTests/ComponentSDKPackagingTests.swift @@ -0,0 +1,212 @@ +// +// ComponentSDKPackagingTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +import Foundation +@testable import UntoldEditor +import XCTest + +/// `scripts/copy-component-sdk-c-modules.py` puts the C modules into the app bundle. It is run +/// here on build folders shaped like the real ones: what it ships has to be what a packaged +/// editor then passes to the compiler, and the same modules `ComponentSDKBuildLayout` finds for +/// an editor run from source. +/// +/// Skipped, never failed, where python3 is not installed. +final class ComponentSDKPackagingTests: XCTestCase { + private struct Packaged { + let sdkRoot: URL + /// What the script printed: the names that go into `sdk.json`. + let modules: [String] + let warnings: String + + func files() throws -> [String] { + try FileManager.default.subpathsOfDirectory(atPath: sdkRoot.path) + .filter { path in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: sdkRoot.appendingPathComponent(path).path, isDirectory: &isDirectory) + && isDirectory.boolValue == false + } + .sorted() + } + + func moduleMap(of module: String) throws -> String { + try String(contentsOf: sdkRoot.appendingPathComponent("\(module)/module.modulemap"), encoding: .utf8) + } + } + + private func package(products: URL, in scratch: ScratchDirectory) throws -> Packaged { + let script = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("scripts/copy-component-sdk-c-modules.py") + let sdkRoot = try scratch.directory("App.app/Contents/Resources/ComponentSDK") + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", script.path, products.path, sdkRoot.path] + let output = Pipe() + let errors = Pipe() + process.standardOutput = output + process.standardError = errors + do { + try process.run() + } catch { + throw XCTSkip("python3 could not be started: \(error.localizedDescription)") + } + let printed = String(data: output.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let warnings = String(data: errors.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + process.waitUntilExit() + if process.terminationStatus == 127 { + throw XCTSkip("python3 is not installed") + } + XCTAssertEqual(process.terminationStatus, 0, warnings) + return Packaged(sdkRoot: sdkRoot, modules: printed.split(separator: "\n").map(String.init), warnings: warnings) + } + + /// The modules an editor run from this build folder passes a module map for, by the name + /// each module map declares. + private func modulesOfSourceBuild(products: URL) throws -> [String] { + let sdk = try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: products)) + return try sdk.cModuleMaps.map { + try XCTUnwrap(ComponentSDKBuildLayout.topLevelModules(inModuleMap: String(contentsOf: $0, encoding: .utf8)).first) + } + } + + /// The modules a packaged editor passes a module map for, once `sdk.json` lists `modules`. + private func modulesOfBundledSDK(_ packaged: Packaged) throws -> [String] { + let modules = packaged.sdkRoot.appendingPathComponent("Modules", isDirectory: true) + try FileManager.default.createDirectory(at: modules, withIntermediateDirectories: true) + for module in ["UntoldEngine", "UntoldComponentKit"] { + try Data().write(to: modules.appendingPathComponent("\(module).swiftmodule")) + } + let manifest = ComponentSDK.Manifest( + swiftCompilerVersion: "Apple Swift version 6.4", + target: "arm64-apple-macosx14.0", + languageMode: "5", + providedModules: ["UntoldComponentKit", "UntoldEngine"], + cModules: packaged.modules + ) + try JSONEncoder().encode(manifest).write(to: packaged.sdkRoot.appendingPathComponent("sdk.json")) + let sdk = try XCTUnwrap(ComponentSDK.resolveBundled(at: packaged.sdkRoot)) + return sdk.cModuleMaps.map { $0.deletingLastPathComponent().lastPathComponent } + } + + func test_theEngineAsItIs_shipsCShaderTypesAndNothingElse() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit"], + generatedCTargets: ["CShaderTypes"] + ) + + let packaged = try package(products: products, in: scratch) + + XCTAssertEqual(packaged.modules, ["CShaderTypes"]) + XCTAssertEqual(try packaged.files(), ["CShaderTypes/CShaderTypes.h", "CShaderTypes/module.modulemap"], "the headers, without the sources beside them") + XCTAssertEqual(try packaged.moduleMap(of: "CShaderTypes"), "module CShaderTypes {\n umbrella \".\"\n export *\n}\n") + XCTAssertEqual(packaged.warnings, "") + } + + func test_swiftBuild_everyCModuleOfTheBuildIsShippedWithARelocatableModuleMap() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics"], + generatedCTargets: ["CShaderTypes", "CEngineAtomics"], + otherCTargets: ["_AtomicsShims", "CNIOAtomics"] + ) + try scratch.addAtomicsPackage() + // The other module map SwiftPM generates: an umbrella header instead of a folder. + let umbrellaHeader = try scratch.write("// CNIOAtomics.h", to: "build/checkouts/swift-nio/Sources/CNIOAtomics/include/CNIOAtomics.h") + try scratch.write("// cpp_magic.h", to: "build/checkouts/swift-nio/Sources/CNIOAtomics/include/cpp_magic.h") + try scratch.write( + "module CNIOAtomics {\numbrella header \"\(umbrellaHeader.path)\"\nexport *\n}\n", + to: "build/out/Intermediates.noindex/GeneratedModuleMaps/CNIOAtomics.modulemap" + ) + + let packaged = try package(products: products, in: scratch) + + XCTAssertEqual(packaged.modules, ["CShaderTypes", "CEngineAtomics", "CNIOAtomics", "_AtomicsShims"]) + XCTAssertEqual(try packaged.files(), [ + "CEngineAtomics/CEngineAtomics.h", "CEngineAtomics/module.modulemap", + "CNIOAtomics/CNIOAtomics.h", "CNIOAtomics/cpp_magic.h", "CNIOAtomics/module.modulemap", + "CShaderTypes/CShaderTypes.h", "CShaderTypes/module.modulemap", + "_AtomicsShims/_AtomicsShims.h", "_AtomicsShims/module.modulemap", + ]) + XCTAssertEqual(try packaged.moduleMap(of: "CEngineAtomics"), "module CEngineAtomics {\n umbrella \".\"\n export *\n}\n") + XCTAssertEqual(try packaged.moduleMap(of: "CNIOAtomics"), "module CNIOAtomics {\n umbrella header \"CNIOAtomics.h\"\n export *\n}\n") + XCTAssertEqual(try packaged.moduleMap(of: "_AtomicsShims"), ScratchDirectory.atomicsShimsModuleMap, "a target's own module map is shipped as it is") + for module in packaged.modules { + XCTAssertFalse(try packaged.moduleMap(of: module).contains("\"/"), "\(module) must not name a path of the build machine") + } + + XCTAssertEqual(try modulesOfSourceBuild(products: products), packaged.modules, "the editor run from source finds the same modules") + XCTAssertEqual(try modulesOfBundledSDK(packaged), packaged.modules, "and the packaged editor passes every one of them") + } + + func test_native_shipsTheSameModules_andNotATargetTheEditorDoesNotUse() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeNativeProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics", "UntoldEditor"], + generatedCTargets: ["CShaderTypes", "CEngineAtomics"], + otherCTargets: ["_AtomicsShims"], + declaredOnlyCTargets: ["UntoldEngineShaderSupport"] + ) + try scratch.addAtomicsPackage() + + let packaged = try package(products: products, in: scratch) + + XCTAssertEqual(packaged.modules, ["CShaderTypes", "CEngineAtomics", "_AtomicsShims"]) + XCTAssertEqual(try modulesOfSourceBuild(products: products), packaged.modules) + XCTAssertEqual(try modulesOfBundledSDK(packaged), packaged.modules) + } + + func test_aPackageUsedByPath_isShippedToo() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit", "Atomics"], + generatedCTargets: ["CShaderTypes"], + otherCTargets: ["_AtomicsShims"] + ) + try scratch.write("// _AtomicsShims.h, as the package used by path has it", to: "Elsewhere/swift-atomics/Sources/_AtomicsShims/include/_AtomicsShims.h") + try scratch.write(ScratchDirectory.atomicsShimsModuleMap, to: "Elsewhere/swift-atomics/Sources/_AtomicsShims/include/module.modulemap") + // The clone from before the package was used by path: SwiftPM leaves it, and no longer lists it. + try scratch.addAtomicsPackage() + try scratch.writeWorkspaceState(packagesUsedByPath: ["Elsewhere/swift-atomics"]) + + let packaged = try package(products: products, in: scratch) + + XCTAssertEqual(packaged.modules, ["CShaderTypes", "_AtomicsShims"]) + XCTAssertEqual( + try String(contentsOf: packaged.sdkRoot.appendingPathComponent("_AtomicsShims/_AtomicsShims.h"), encoding: .utf8), + "// _AtomicsShims.h, as the package used by path has it" + ) + XCTAssertEqual(try modulesOfSourceBuild(products: products), packaged.modules) + } + + func test_aBuiltCTargetWithoutAnyModuleMap_isLeftOut_andABuildWithoutCShaderTypesIsReported() throws { + let scratch = try ScratchDirectory() + let products = try scratch.makeSwiftBuildProducts( + swiftTargets: ["UntoldEngine", "UntoldComponentKit"], + generatedCTargets: ["CShaderTypes", "CStale"], + otherCTargets: ["CNowhere"] + ) + // The build folder was used with an engine that had CStale; this one does not. + try FileManager.default.removeItem(at: scratch.url.appendingPathComponent("engine/Sources/CStale")) + + let leftOut = try package(products: products, in: scratch) + XCTAssertEqual(leftOut.modules, ["CShaderTypes"]) + XCTAssertTrue(leftOut.warnings.contains("CStale is not shipped"), leftOut.warnings) + XCTAssertTrue(leftOut.warnings.contains("no module map found for the C target CNowhere"), leftOut.warnings) + XCTAssertEqual(try modulesOfSourceBuild(products: products), leftOut.modules) + + let empty = try ScratchDirectory() + let packaged = try package(products: empty.directory("build/out/Products/Debug"), in: empty) + XCTAssertEqual(packaged.modules, []) + XCTAssertTrue(packaged.warnings.contains("CShaderTypes module map not found"), packaged.warnings) + } +} diff --git a/Tests/UntoldEditorTests/ComponentSDKTests.swift b/Tests/UntoldEditorTests/ComponentSDKTests.swift index 97bdb8a..fe61093 100644 --- a/Tests/UntoldEditorTests/ComponentSDKTests.swift +++ b/Tests/UntoldEditorTests/ComponentSDKTests.swift @@ -30,7 +30,7 @@ final class ComponentSDKTests: XCTestCase { let sdk = try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: products)) XCTAssertEqual(sdk.modulesDirectory.path, products.path) - XCTAssertEqual(sdk.cShaderTypesModuleMap.path, moduleMap.path) + XCTAssertEqual(sdk.cModuleMaps.map(\.path), [moduleMap.path]) XCTAssertEqual(sdk.providedModules, ["UntoldComponentKit", "UntoldEngine", "UntoldGaussianTwins"]) XCTAssertNil(sdk.recordedCompilerVersion, "a source build never needs the compiler check") XCTAssertFalse(sdk.isBundled) @@ -45,7 +45,7 @@ final class ComponentSDKTests: XCTestCase { let sdk = try XCTUnwrap(ComponentSDK.resolveFromBuildProducts(productsDirectory: products)) XCTAssertEqual(sdk.modulesDirectory.path, products.appendingPathComponent("Modules").path) - XCTAssertEqual(sdk.cShaderTypesModuleMap.path, moduleMap.path) + XCTAssertEqual(sdk.cModuleMaps.map(\.path), [moduleMap.path]) } func test_executableReachedThroughTheDebugSymlink_stillFindsTheModuleMap() throws { @@ -66,7 +66,7 @@ final class ComponentSDKTests: XCTestCase { let scratch = try ScratchDirectory() let resources = try scratch.directory("App.app/Contents/Resources") try addModules(["UntoldEngine", "UntoldComponentKit"], to: resources.appendingPathComponent("ComponentSDK/Modules")) - try scratch.write("module CShaderTypes {}", to: "App.app/Contents/Resources/ComponentSDK/CShaderTypes/module.modulemap") + let moduleMap = try scratch.write("module CShaderTypes {}", to: "App.app/Contents/Resources/ComponentSDK/CShaderTypes/module.modulemap") let manifest = ComponentSDK.Manifest( swiftCompilerVersion: "Apple Swift version 6.4", engineURL: "https://github.com/miolabs/UntoldEngine.git", @@ -84,6 +84,35 @@ final class ComponentSDKTests: XCTestCase { XCTAssertEqual(sdk.engineRevision, "abc123") XCTAssertEqual(sdk.engineURL, "https://github.com/miolabs/UntoldEngine.git") XCTAssertEqual(sdk.providedModules, ["UntoldComponentKit", "UntoldEngine"]) + XCTAssertEqual(sdk.cModuleMaps.map(\.path), [moduleMap.path], "an sdk.json without a list of C modules has CShaderTypes alone") + } + + func test_bundledSDK_passesTheModuleMapOfEveryCModuleItsManifestLists() throws { + let scratch = try ScratchDirectory() + let sdkRoot = try scratch.directory("App.app/Contents/Resources/ComponentSDK") + try addModules(["UntoldEngine", "UntoldComponentKit", "Atomics"], to: sdkRoot.appendingPathComponent("Modules")) + let moduleMaps = try ["CShaderTypes", "CEngineAtomics", "_AtomicsShims"].map { + try scratch.write("module \($0) {}", to: "App.app/Contents/Resources/ComponentSDK/\($0)/module.modulemap") + } + // Shipped, and not listed: not passed. + try scratch.write("module Stray {}", to: "App.app/Contents/Resources/ComponentSDK/Stray/module.modulemap") + var manifest = ComponentSDK.Manifest( + swiftCompilerVersion: "Apple Swift version 6.4", + target: "arm64-apple-macosx14.0", + languageMode: "5", + providedModules: ["Atomics", "UntoldComponentKit", "UntoldEngine"], + cModules: ["CShaderTypes", "CEngineAtomics", "_AtomicsShims"] + ) + try JSONEncoder().encode(manifest).write(to: sdkRoot.appendingPathComponent("sdk.json")) + + let sdk = try XCTUnwrap(ComponentSDK.resolveBundled(at: sdkRoot)) + + XCTAssertEqual(sdk.cModuleMaps.map(\.path), moduleMaps.map(\.path)) + + // A listed module that was not shipped would only fail later, in every compile. + manifest.cModules?.append("CMissing") + try JSONEncoder().encode(manifest).write(to: sdkRoot.appendingPathComponent("sdk.json")) + XCTAssertNil(ComponentSDK.resolveBundled(at: sdkRoot)) } func test_missingKitModule_isNotAnSDK() throws { diff --git a/Tests/UntoldEditorTests/CreateProjectViewTests.swift b/Tests/UntoldEditorTests/CreateProjectViewTests.swift index 48aa722..b56127d 100644 --- a/Tests/UntoldEditorTests/CreateProjectViewTests.swift +++ b/Tests/UntoldEditorTests/CreateProjectViewTests.swift @@ -18,6 +18,22 @@ import SwiftUI import XCTest final class CreateProjectViewTests: XCTestCase { + // MARK: - Helper mirroring CreateProjectView's target-index mapping + + /// Mirrors the `selectedTarget` → `BuildTarget` switch in `CreateProjectView.createBuildSettings()`. + /// Takes the index as a parameter (rather than inlining the switch on a literal in each test) + /// so the compiler can't prove branches unreachable and warn about dead code. + private func buildTarget(forSelectedTarget selectedTarget: Int) -> BuildTarget { + switch selectedTarget { + case 0: return .macOS(deployment: .v15) + case 1: return .iOS(deployment: .v17) + case 2: return .iOS(deployment: .v17) + case 3: return .visionOS(deployment: .v26) + case 4: return .multi(macOS: .v15, iOS: .v17, visionOS: .v26) + default: return .macOS(deployment: .v15) + } + } + // MARK: - Helper for reading @State defaults /// Reads the initial value of one of the view's `@State` properties using reflection. @@ -512,11 +528,7 @@ final class CreateProjectViewTests: XCTestCase { let selectedTarget = 0 // Act - let target: BuildTarget - switch selectedTarget { - case 0: target = .macOS(deployment: .v15) - default: target = .macOS(deployment: .v15) - } + let target = buildTarget(forSelectedTarget: selectedTarget) // Assert if case .macOS = target { @@ -531,11 +543,7 @@ final class CreateProjectViewTests: XCTestCase { let selectedTarget = 1 // Act - let target: BuildTarget - switch selectedTarget { - case 1: target = .iOS(deployment: .v17) - default: target = .macOS(deployment: .v15) - } + let target = buildTarget(forSelectedTarget: selectedTarget) // Assert if case .iOS = target { @@ -551,11 +559,7 @@ final class CreateProjectViewTests: XCTestCase { let isIOSAR = (selectedTarget == 2) // Act - let target: BuildTarget - switch selectedTarget { - case 2: target = .iOS(deployment: .v17) - default: target = .macOS(deployment: .v15) - } + let target = buildTarget(forSelectedTarget: selectedTarget) // Assert if case .iOS = target { @@ -570,11 +574,7 @@ final class CreateProjectViewTests: XCTestCase { let selectedTarget = 3 // Act - let target: BuildTarget - switch selectedTarget { - case 3: target = .visionOS(deployment: .v26) - default: target = .macOS(deployment: .v15) - } + let target = buildTarget(forSelectedTarget: selectedTarget) // Assert if case .visionOS = target { @@ -589,11 +589,7 @@ final class CreateProjectViewTests: XCTestCase { let selectedTarget = 4 // Act - let target: BuildTarget - switch selectedTarget { - case 4: target = .multi(macOS: .v15, iOS: .v17, visionOS: .v26) - default: target = .macOS(deployment: .v15) - } + let target = buildTarget(forSelectedTarget: selectedTarget) // Assert if case .multi = target { @@ -608,15 +604,7 @@ final class CreateProjectViewTests: XCTestCase { let selectedTarget = 999 // Invalid index // Act - let target: BuildTarget - switch selectedTarget { - case 0: target = .macOS(deployment: .v15) - case 1: target = .iOS(deployment: .v17) - case 2: target = .iOS(deployment: .v17) - case 3: target = .visionOS(deployment: .v26) - case 4: target = .multi(macOS: .v15, iOS: .v17, visionOS: .v26) - default: target = .macOS(deployment: .v15) - } + let target = buildTarget(forSelectedTarget: selectedTarget) // Assert if case .macOS = target { diff --git a/Tests/UntoldEditorTests/DemoGalleryViewTests.swift b/Tests/UntoldEditorTests/DemoGalleryViewTests.swift index eebf89d..4bcece9 100644 --- a/Tests/UntoldEditorTests/DemoGalleryViewTests.swift +++ b/Tests/UntoldEditorTests/DemoGalleryViewTests.swift @@ -36,8 +36,18 @@ import XCTest } } - func test_demoGalleryView_wiresCallbacks() { - let firstDemo = demoSceneCatalog[0] + func test_demoGalleryView_wiresCallbacks() throws { + let firstDemo = try DemoSceneCatalogItem( + id: "sample", + title: "Sample", + subtitle: "A sample demo scene.", + thumbnailName: "sample", + systemImageName: "cube", + tags: ["Demo"], + source: .remoteManifest(XCTUnwrap(URL(string: "https://example.com/sample.json"))), + cameraFrame: nil, + loadsSceneAuthoredPayload: false + ) var selectedDemoId: String? var didTryOwnScene = false var didCreateProject = false @@ -45,7 +55,7 @@ import XCTest var didOpenFullEditor = false let sut = DemoGalleryView( - demos: demoSceneCatalog, + demos: [firstDemo], onDemoSelected: { selectedDemoId = $0.id }, onTryOwnScene: { didTryOwnScene = true }, onCreateProject: { didCreateProject = true }, diff --git a/Tests/UntoldEditorTests/EditorControllerTests.swift b/Tests/UntoldEditorTests/EditorControllerTests.swift index cb23e3a..92c261d 100644 --- a/Tests/UntoldEditorTests/EditorControllerTests.swift +++ b/Tests/UntoldEditorTests/EditorControllerTests.swift @@ -125,7 +125,8 @@ final class EditorControllerTests: XCTestCase { func test_editorController_implementsSelectionDelegate() { // Assert - XCTAssertNotNil(controller as? SelectionDelegate, "Should implement SelectionDelegate protocol") + let delegate: SelectionDelegate? = controller + XCTAssertNotNil(delegate, "Should implement SelectionDelegate protocol") } func test_editorController_didSelectEntity_updatesSelectionManager() { diff --git a/Tests/UntoldEditorTests/EditorEnginePackageTests.swift b/Tests/UntoldEditorTests/EditorEnginePackageTests.swift index cb6260e..807fc56 100644 --- a/Tests/UntoldEditorTests/EditorEnginePackageTests.swift +++ b/Tests/UntoldEditorTests/EditorEnginePackageTests.swift @@ -25,7 +25,8 @@ final class EditorEnginePackageTests: XCTestCase { func test_packagedEditor_readsItsEngineFromTheSDK() { let sdk = ComponentSDK( modulesDirectory: URL(fileURLWithPath: "/sdk/Modules"), - cShaderTypesModuleMap: URL(fileURLWithPath: "/sdk/CShaderTypes/module.modulemap"), + cModuleMaps: [URL(fileURLWithPath: "/sdk/CShaderTypes/module.modulemap")], + cModulesWithoutModuleMap: [], providedModules: [], targetTriple: "arm64-apple-macosx14.0", recordedCompilerVersion: "Apple Swift version 6.4", diff --git a/Tests/UntoldEditorTests/EnvironmentViewTests.swift b/Tests/UntoldEditorTests/EnvironmentViewTests.swift index c0de821..780872b 100644 --- a/Tests/UntoldEditorTests/EnvironmentViewTests.swift +++ b/Tests/UntoldEditorTests/EnvironmentViewTests.swift @@ -97,8 +97,6 @@ final class EnvironmentViewTests: XCTestCase { let hdrFilePath = hdr.appendingPathComponent("test_environment.hdr") FileManager.default.createFile(atPath: hdrFilePath.path, contents: Data()) - let hdrAsset = Asset(name: "test_environment.hdr", category: "HDR", path: hdrFilePath, isFolder: false) - // Verify the HDR file exists XCTAssertTrue(FileManager.default.fileExists(atPath: hdrFilePath.path), "HDR file should exist") diff --git a/Tests/UntoldEditorTests/GizmoGroupTests.swift b/Tests/UntoldEditorTests/GizmoGroupTests.swift new file mode 100644 index 0000000..ed9a7a6 --- /dev/null +++ b/Tests/UntoldEditorTests/GizmoGroupTests.swift @@ -0,0 +1,510 @@ +// +// GizmoGroupTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import ModelIO +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// One gizmo on several entities: it moves them by the same distance, turns +/// them round itself and scales them from itself, and one undo puts them back. +final class GizmoGroupTests: XCTestCase { + private var originalScene: Scene! + private var originalActiveEntity: EntityID! + private var originalTool: TransformTool! + private var originalBoxBuffer: MTLBuffer? + private var originalSpace: TransformSpace! + private var originalSnap: EditorSnapSettings! + private var originalController: EditorController? + private var selectionManager: SelectionManager! + + override func setUp() { + super.setUp() + guard let device = MTLCreateSystemDefaultDevice() else { + XCTFail("Metal device is not available.") + return + } + renderInfo.device = device + vertexDescriptor.model = MDLVertexDescriptor() + // One entity selected alone writes its box where the renderer keeps it. + originalBoxBuffer = bufferResources.boundingBoxBuffer + bufferResources.boundingBoxBuffer = device.makeBuffer( + length: MemoryLayout.stride * boundingBoxVertexCount, + options: .storageModeShared + ) + + originalScene = scene + originalActiveEntity = activeEntity + originalTool = EditorViewportSettings.shared.tool + originalSpace = EditorViewportSettings.shared.transformSpace + originalSnap = gizmoSnapSettings + originalController = editorController + + scene = Scene() + activeEntity = .invalid + gizmoTargets = [] + gizmoSnapSettings = EditorSnapSettings(defaults: nil) + EditorViewportSettings.shared.tool = .move + EditorViewportSettings.shared.transformSpace = .world + selectionManager = SelectionManager() + editorController = EditorController(selectionManager: selectionManager) + EditorUndoManager.shared.clear() + } + + override func tearDown() { + endGizmoDrag() + selectionManager.clearSelection() + selectionManager = nil + gizmoTargets = [] + SelectionHighlights.shared.boxes = [] + activeHitGizmoEntity = .invalid + EditorUndoManager.shared.clear() + editorController = originalController + gizmoSnapSettings = originalSnap + EditorViewportSettings.shared.tool = originalTool + EditorViewportSettings.shared.transformSpace = originalSpace + activeEntity = originalActiveEntity + bufferResources.boundingBoxBuffer = originalBoxBuffer + scene = originalScene + originalScene = nil + super.tearDown() + } + + // MARK: - Helpers + + private func makeBox(_ name: String, at position: simd_float3 = .zero, halfExtent: Float = 0.5) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entity)?.boundingBox = ( + min: simd_float3(repeating: -halfExtent), + max: simd_float3(repeating: halfExtent) + ) + translateTo(entityId: entity, position: position) + return entity + } + + /// A box under a parent turned half around Y and moved away, so that the + /// parent's X and Z point the other way. + private func makeBoxUnderATurnedParent(_ name: String, atWorld position: simd_float3) -> EntityID { + let parent = createEntity() + registerTransformComponent(entityId: parent) + registerSceneGraphComponent(entityId: parent) + translateTo(entityId: parent, position: simd_float3(5, 0, 0)) + rotateTo(entityId: parent, rotation: simd_quatf(angle: .pi, axis: simd_float3(0, 1, 0))) + + let child = makeBox(name) + setParent(childId: child, parentId: parent) + translateTo(entityId: child, position: localPosition(ofWorld: position, for: child)) + return child + } + + private func handle(_ mode: TransformManipulationMode, _ axis: TransformAxis) -> EntityID { + getEntityChildren(parentId: parentEntityIdGizmo).first(where: { + guard let handle = scene.get(component: GizmoHandleComponent.self, for: $0) else { return false } + return handle.mode == mode && handle.axis == axis + }) ?? .invalid + } + + /// A ray that meets the X axis through the gizmo at `amount` along it. + private func rayOnX(at amount: Float) -> GizmoDragRay { + GizmoDragRay(origin: simd_float3(amount, 50, 0), direction: simd_float3(0, -1, 0)) + } + + private func assertNearlyEqual(_ lhs: simd_float3, _ rhs: simd_float3, accuracy: Float = 0.001, message: String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(lhs.x, rhs.x, accuracy: accuracy, message, file: file, line: line) + XCTAssertEqual(lhs.y, rhs.y, accuracy: accuracy, message, file: file, line: line) + XCTAssertEqual(lhs.z, rhs.z, accuracy: accuracy, message, file: file, line: line) + } + + /// What an entity's own X axis points along in the world. + private func worldXAxis(of entityId: EntityID) -> simd_float3 { + simd_act(entityWorldRotation(entityId: entityId), simd_float3(1, 0, 0)) + } + + // MARK: - What the gizmo works on + + func test_withOneEntity_theGizmoWorksOnTheActiveOne() { + let box = makeBox("Box") + activeEntity = box + + XCTAssertEqual(gizmoTransformTargets(), [box]) + } + + func test_withNoActiveEntity_itWorksOnNothing() { + gizmoTargets = [makeBox("First"), makeBox("Second")] + activeEntity = .invalid + + XCTAssertEqual(gizmoTransformTargets(), []) + } + + func test_withSeveral_itWorksOnAllOfThem() { + let first = makeBox("First"), second = makeBox("Second") + selectionManager.selectEntities([first, second]) + + XCTAssertEqual(gizmoTransformTargets(), [first, second]) + } + + func test_targetsLeftFromAnotherSelection_areNotMoved() { + let first = makeBox("First"), second = makeBox("Second"), other = makeBox("Other") + gizmoTargets = [first, second] + activeEntity = other + + XCTAssertEqual(gizmoTransformTargets(), [other]) + } + + func test_anEntityThatLeftTheScene_isNoTarget() { + let first = makeBox("First"), second = makeBox("Second"), third = makeBox("Third") + selectionManager.selectEntities([first, second, third]) + + destroyEntity(entityId: first) + finalizePendingDestroys() + + XCTAssertEqual(gizmoTransformTargets(), [second, third]) + } + + func test_anEntityWithoutItsPlaceInTheWorld_isNoTargetAmongSeveral() { + let first = makeBox("First"), second = makeBox("Second"), third = makeBox("Third") + selectionManager.selectEntities([first, second, third]) + + // The engine takes an entity's two transforms away together; a turn + // and a scale of several start from the one taken here. + scene.remove(component: WorldTransformComponent.self, from: first) + + XCTAssertFalse(hasBothTransforms(first)) + XCTAssertEqual(gizmoTransformTargets(), [second, third]) + } + + func test_anEntityTheEngineMade_hasBothTransforms_untilItLeavesTheScene() { + let entity = createEntity() + XCTAssertTrue(hasBothTransforms(entity), "the engine gives an entity the two together") + + destroyEntity(entityId: entity) + finalizePendingDestroys() + + XCTAssertFalse(hasBothTransforms(entity)) + } + + // MARK: - Moving + + func test_aDragOfTheMoveGizmo_carriesEveryEntityTheSameWay() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 1, 0)) + selectionManager.selectEntities([first, second]) + let gizmoStart = getPosition(entityId: parentEntityIdGizmo) + + activeHitGizmoEntity = handle(.translate, .x) + XCTAssertNotEqual(activeHitGizmoEntity, .invalid) + beginGizmoDrag(ray: rayOnX(at: 0)) + updateGizmoDrag(ray: rayOnX(at: 3)) + updateGizmoDrag(ray: rayOnX(at: 1.5)) + + assertNearlyEqual(getPosition(entityId: first), simd_float3(-0.5, 0, 0)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(3.5, 1, 0)) + assertNearlyEqual(getPosition(entityId: parentEntityIdGizmo), gizmoStart + simd_float3(1.5, 0, 0)) + } + + func test_anEntityUnderATurnedParent_goesTheSameWayInTheWorld() { + let root = makeBox("Root", at: simd_float3(-2, 0, 0)) + let child = makeBoxUnderATurnedParent("Child", atWorld: simd_float3(2, 0, 3)) + assertNearlyEqual(getPosition(entityId: child), simd_float3(2, 0, 3)) + selectionManager.selectEntities([root, child]) + + activeHitGizmoEntity = handle(.translate, .x) + beginGizmoDrag(ray: rayOnX(at: 0)) + updateGizmoDrag(ray: rayOnX(at: 2)) + + assertNearlyEqual(getPosition(entityId: root), simd_float3(0, 0, 0)) + assertNearlyEqual(getPosition(entityId: child), simd_float3(4, 0, 3)) + } + + func test_movingWithSnapping_landsAllOfThemOnTheSameSteps() { + gizmoSnapSettings.isEnabled = true + gizmoSnapSettings.snapsMove = true + gizmoSnapSettings.gridStep = 0.5 + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2.2, 0, 0)) + selectionManager.selectEntities([first, second]) + + activeHitGizmoEntity = handle(.translate, .x) + beginGizmoDrag(ray: rayOnX(at: 0)) + updateGizmoDrag(ray: rayOnX(at: 1.3)) + + assertNearlyEqual(getPosition(entityId: first), simd_float3(-0.5, 0, 0), message: "1.3 is nearest to three steps of a half") + assertNearlyEqual(getPosition(entityId: second), simd_float3(3.7, 0, 0)) + } + + func test_aMoveFromTheKeys_carriesAllOfThem() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBoxUnderATurnedParent("Second", atWorld: simd_float3(2, 0, 0)) + selectionManager.selectEntities([first, second]) + + applyGizmoTranslation(byWorld: simd_float3(0, 0, 1.5)) + + assertNearlyEqual(getPosition(entityId: first), simd_float3(-2, 0, 1.5)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(2, 0, 1.5)) + } + + func test_aMoveFromTheKeys_withOneEntity_movesThatOne() { + let box = makeBox("Box", at: simd_float3(1, 0, 0)) + let other = makeBox("Other", at: simd_float3(5, 0, 0)) + selectionManager.inspectEntity(entityId: box) + + applyGizmoTranslation(byWorld: simd_float3(0, 2, 0)) + + assertNearlyEqual(getPosition(entityId: box), simd_float3(1, 2, 0)) + assertNearlyEqual(getPosition(entityId: other), simd_float3(5, 0, 0)) + } + + // MARK: - Turning + + func test_aTurn_takesThemRoundTheGizmo_andTurnsEachTheSame() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + EditorViewportSettings.shared.tool = .rotate + selectionManager.selectEntities([first, second]) + assertNearlyEqual(gizmoRootWorldPosition(), .zero) + + applyGizmoRotation(axis: simd_float3(0, 1, 0), degrees: 90) + + // A quarter turn about Y takes +X to -Z. + assertNearlyEqual(getPosition(entityId: first), simd_float3(0, 0, 2)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(0, 0, -2)) + assertNearlyEqual(worldXAxis(of: first), simd_float3(0, 0, -1)) + assertNearlyEqual(worldXAxis(of: second), simd_float3(0, 0, -1)) + } + + func test_aTurn_underATurnedParent_isTheSameTurnInTheWorld() { + let root = makeBox("Root", at: simd_float3(-2, 0, 0)) + let child = makeBoxUnderATurnedParent("Child", atWorld: simd_float3(2, 0, 0)) + let childAxisBefore = worldXAxis(of: child) + assertNearlyEqual(childAxisBefore, simd_float3(-1, 0, 0)) + EditorViewportSettings.shared.tool = .rotate + selectionManager.selectEntities([root, child]) + + applyGizmoRotation(axis: simd_float3(0, 1, 0), degrees: 90) + + assertNearlyEqual(getPosition(entityId: root), simd_float3(0, 0, 2)) + assertNearlyEqual(getPosition(entityId: child), simd_float3(0, 0, -2)) + assertNearlyEqual(worldXAxis(of: child), simd_float3(0, 0, 1), message: "its own X pointed to -X, and turned a quarter like the rest") + } + + func test_smallTurns_addUpToTheWholeTurn() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + EditorViewportSettings.shared.tool = .rotate + selectionManager.selectEntities([first, second]) + + for _ in 0 ..< 36 { + applyGizmoRotation(axis: simd_float3(0, 0, 1), degrees: 5) + } + + // Half a turn about Z. + assertNearlyEqual(getPosition(entityId: first), simd_float3(2, 0, 0), accuracy: 0.01) + assertNearlyEqual(getPosition(entityId: second), simd_float3(-2, 0, 0), accuracy: 0.01) + XCTAssertEqual(simd_distance(getPosition(entityId: first), getPosition(entityId: second)), 4, accuracy: 0.01, "they keep their distance") + } + + func test_aTurn_withOneEntity_turnsItWhereItStands() { + let box = makeBox("Box", at: simd_float3(3, 0, 0)) + EditorViewportSettings.shared.tool = .rotate + selectionManager.inspectEntity(entityId: box) + + applyGizmoRotation(axis: simd_float3(0, 1, 0), degrees: 90) + + assertNearlyEqual(getPosition(entityId: box), simd_float3(3, 0, 0)) + assertNearlyEqual(worldXAxis(of: box), simd_float3(0, 0, -1)) + } + + // MARK: - Scaling + + func test_theFactor_isOneMoreThanTheDrag_andNeverNothing() { + XCTAssertEqual(gizmoGroupScaleFactor(forAmount: 0), 1) + XCTAssertEqual(gizmoGroupScaleFactor(forAmount: 1), 2) + XCTAssertEqual(gizmoGroupScaleFactor(forAmount: -0.5), 0.5) + XCTAssertEqual(gizmoGroupScaleFactor(forAmount: -3), gizmoMinimumScale) + XCTAssertEqual(gizmoGroupScaleFactor(forAmount: .nan), 1) + } + + func test_aDragOfTheScaleGizmo_growsThem_andTheirDistanceFromIt() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 1, 0)) + EditorViewportSettings.shared.tool = .scale + selectionManager.selectEntities([first, second]) + let pivot = gizmoRootWorldPosition() + assertNearlyEqual(pivot, simd_float3(0, 0.5, 0)) + + activeHitGizmoEntity = handle(.scale, .x) + XCTAssertNotEqual(activeHitGizmoEntity, .invalid) + beginGizmoDrag(ray: rayOnX(at: 0)) + updateGizmoDrag(ray: rayOnX(at: 0.4)) + updateGizmoDrag(ray: rayOnX(at: 1)) + + // Twice the size along X, from where they stood: not 1.4 times and then more. + assertNearlyEqual(getPosition(entityId: first), simd_float3(-4, 0, 0)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(4, 1, 0)) + assertNearlyEqual(getScale(entityId: first), simd_float3(2, 1, 1)) + assertNearlyEqual(getScale(entityId: second), simd_float3(2, 1, 1)) + } + + func test_aTurnedEntity_growsAlongItsOwnAxisThatLiesAlongTheGizmos() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let turned = makeBox("Turned", at: simd_float3(2, 0, 0)) + // A quarter turn about Y: its own Z lies along the world's X. + rotateTo(entityId: turned, rotation: simd_quatf(angle: .pi / 2, axis: simd_float3(0, 1, 0))) + EditorViewportSettings.shared.tool = .scale + selectionManager.selectEntities([first, turned]) + + activeHitGizmoEntity = handle(.scale, .x) + beginGizmoDrag(ray: rayOnX(at: 0)) + updateGizmoDrag(ray: rayOnX(at: 0.5)) + + assertNearlyEqual(getScale(entityId: first), simd_float3(1.5, 1, 1)) + assertNearlyEqual(getScale(entityId: turned), simd_float3(1, 1, 1.5)) + } + + func test_aLight_movesWithTheRest_andKeepsItsSize() { + let box = makeBox("Box", at: simd_float3(-2, 0, 0)) + let light = makeBox("Light", at: simd_float3(2, 0, 0)) + registerComponent(entityId: light, componentType: LightComponent.self) + EditorViewportSettings.shared.tool = .scale + selectionManager.selectEntities([box, light]) + + activeHitGizmoEntity = handle(.scale, .x) + beginGizmoDrag(ray: rayOnX(at: 0)) + updateGizmoDrag(ray: rayOnX(at: 1)) + + assertNearlyEqual(getPosition(entityId: light), simd_float3(4, 0, 0)) + assertNearlyEqual(getScale(entityId: light), simd_float3(1, 1, 1)) + assertNearlyEqual(getScale(entityId: box), simd_float3(2, 1, 1)) + } + + func test_aScaleFromTheKeys_growsThemFromWhereTheyAre() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + EditorViewportSettings.shared.tool = .scale + selectionManager.selectEntities([first, second]) + + applyGizmoScale(axis: simd_float3(1, 0, 0), amount: 0.5, handle: .x) + applyGizmoScale(axis: simd_float3(1, 0, 0), amount: 0.5, handle: .x) + + assertNearlyEqual(getScale(entityId: first), simd_float3(2.25, 1, 1)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(4.5, 0, 0)) + } + + func test_aScaleFromTheKeys_withOneEntity_isTheEnginesOwn() { + let box = makeBox("Box", at: simd_float3(3, 0, 0)) + EditorViewportSettings.shared.tool = .scale + selectionManager.inspectEntity(entityId: box) + + applyGizmoScale(axis: simd_float3(1, 0, 0), amount: 0.5, handle: .x) + + assertNearlyEqual(getScale(entityId: box), simd_float3(1.5, 1, 1)) + assertNearlyEqual(getPosition(entityId: box), simd_float3(3, 0, 0)) + } + + // MARK: - Where the gizmo stands afterwards + + func test_afterADrag_theGizmoIsBackInTheMiddle() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0), halfExtent: 1) + EditorViewportSettings.shared.tool = .rotate + selectionManager.selectEntities([first, second]) + let pivot = gizmoRootWorldPosition() + assertNearlyEqual(pivot, simd_float3(0.25, 0, 0)) + + applyGizmoRotation(axis: simd_float3(0, 1, 0), degrees: 90) + assertNearlyEqual(gizmoRootWorldPosition(), pivot, message: "during the drag it stays where they turn around") + endGizmoDrag() + + let bounds = selectionManager.selectionBounds() + XCTAssertNotNil(bounds) + if let bounds { + assertNearlyEqual(gizmoRootWorldPosition(), (bounds.min + bounds.max) * 0.5) + } + } + + // MARK: - Undo + + func test_aDragOfSeveral_isOneStepToUndo() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 1, 0)) + selectionManager.selectEntities([first, second]) + let undo = EditorUndoManager.shared + + undo.beginTransformEdit(entityIds: gizmoTransformTargets()) + applyGizmoTranslation(byWorld: simd_float3(0, 0, 3)) + undo.commitTransformEdit(entityIds: gizmoTransformTargets()) + + XCTAssertEqual(undo.undoHistory, ["Transform 2 Entities"]) + assertNearlyEqual(getPosition(entityId: second), simd_float3(2, 1, 3)) + + undo.undo() + assertNearlyEqual(getPosition(entityId: first), simd_float3(-2, 0, 0)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(2, 1, 0)) + + undo.redo() + assertNearlyEqual(getPosition(entityId: first), simd_float3(-2, 0, 3)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(2, 1, 3)) + } + + func test_aDragThatMovedOneOfThem_isNamedAsOne() { + let first = makeBox("First"), second = makeBox("Second", at: simd_float3(2, 0, 0)) + let undo = EditorUndoManager.shared + + undo.beginTransformEdit(entityIds: [first, second]) + translateTo(entityId: second, position: simd_float3(2, 5, 0)) + undo.commitTransformEdit(entityIds: [first, second]) + + XCTAssertEqual(undo.undoHistory, ["Transform Entity"]) + } + + func test_aDragThatMovedNothing_leavesNothingToUndo() { + let first = makeBox("First"), second = makeBox("Second") + let undo = EditorUndoManager.shared + + undo.beginTransformEdit(entityIds: [first, second]) + undo.commitTransformEdit(entityIds: [first, second]) + + XCTAssertFalse(undo.canUndo) + } + + func test_undoingATurnOfSeveral_putsPlaceAndTurnBack() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + EditorViewportSettings.shared.tool = .rotate + selectionManager.selectEntities([first, second]) + let undo = EditorUndoManager.shared + + undo.beginTransformEdit(entityIds: gizmoTransformTargets()) + applyGizmoRotation(axis: simd_float3(0, 1, 0), degrees: 90) + undo.commitTransformEdit(entityIds: gizmoTransformTargets()) + undo.undo() + + assertNearlyEqual(getPosition(entityId: first), simd_float3(-2, 0, 0)) + assertNearlyEqual(getPosition(entityId: second), simd_float3(2, 0, 0)) + assertNearlyEqual(worldXAxis(of: first), simd_float3(1, 0, 0)) + } + + // MARK: - A point as the parent measures it + + func test_aPointOfTheWorld_asTheParentMeasuresIt() { + let child = makeBoxUnderATurnedParent("Child", atWorld: simd_float3(4, 0, 0)) + + // The parent stands at (5, 0, 0), turned half around Y. + assertNearlyEqual(localPosition(ofWorld: simd_float3(4, 2, 3), for: child), simd_float3(1, 2, -3)) + + let root = makeBox("Root") + assertNearlyEqual(localPosition(ofWorld: simd_float3(4, 2, 3), for: root), simd_float3(4, 2, 3)) + } +} diff --git a/Tests/UntoldEditorTests/MarqueeGeometryTests.swift b/Tests/UntoldEditorTests/MarqueeGeometryTests.swift new file mode 100644 index 0000000..60cbe9f --- /dev/null +++ b/Tests/UntoldEditorTests/MarqueeGeometryTests.swift @@ -0,0 +1,363 @@ +// +// MarqueeGeometryTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// What stands inside the rectangle dragged over the viewport, through a +/// camera and a projection of the engine. +/// +/// The camera stands five units in front of the origin and sees a quarter +/// turn from top to bottom, so at a distance the viewport is twice that +/// distance high. The viewport is 400 points each way: its middle is at +/// (200, 200), and a unit of the world at the origin is 40 points. A box of +/// one unit at the origin reaches 22 points from the middle, where its near +/// face is. +final class MarqueeGeometryTests: XCTestCase { + private var originalScene: Scene! + private let size = CGSize(width: 400, height: 400) + private let unitBox = (min: simd_float3(repeating: -0.5), max: simd_float3(repeating: 0.5)) + + override func setUp() { + super.setUp() + originalScene = scene + scene = Scene() + } + + override func tearDown() { + scene = originalScene + originalScene = nil + super.tearDown() + } + + private func view(eye: simd_float3 = simd_float3(0, 0, 5), target: simd_float3 = .zero, size: CGSize? = nil) -> MarqueeGeometry.View { + let size = size ?? self.size + let camera = createEntity() + registerComponent(entityId: camera, componentType: CameraComponent.self) + cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0)) + return MarqueeGeometry.View( + viewSpace: scene.get(component: CameraComponent.self, for: camera)?.viewSpace ?? matrix_identity_float4x4, + perspectiveSpace: matrixPerspectiveRightHand( + fovyRadians: .pi / 2, + aspectRatio: Float(size.width / size.height), + nearZ: 0.1, + farZ: 100 + ), + size: size + ) + } + + private func contains( + _ rect: CGRect, + box: (min: simd_float3, max: simd_float3)? = nil, + at position: simd_float3 = .zero, + view: MarqueeGeometry.View? = nil + ) -> Bool { + let box = box ?? unitBox + return MarqueeGeometry.contains( + rect, + boxMinimum: box.min, + boxMaximum: box.max, + modelSpace: matrix4x4Translation(position.x, position.y, position.z), + view: view ?? self.view() + ) + } + + // MARK: - The rectangle + + func test_theRectangle_isBetweenTheTwoPoints_whicheverWayTheDragWent() { + let expected = CGRect(x: 10, y: 20, width: 30, height: 40) + + XCTAssertEqual(MarqueeGeometry.rect(from: CGPoint(x: 10, y: 20), to: CGPoint(x: 40, y: 60)), expected) + XCTAssertEqual(MarqueeGeometry.rect(from: CGPoint(x: 40, y: 60), to: CGPoint(x: 10, y: 20)), expected) + XCTAssertEqual(MarqueeGeometry.rect(from: CGPoint(x: 10, y: 60), to: CGPoint(x: 40, y: 20)), expected) + } + + func test_forAViewThatMeasuresFromItsTop_theRectangleIsTurnedOver() { + let rect = CGRect(x: 10, y: 20, width: 30, height: 40) + + XCTAssertEqual(MarqueeGeometry.flipped(rect, inHeight: 400), CGRect(x: 10, y: 340, width: 30, height: 40)) + } + + func test_deviceCoordinates_runFromMinusOneToOne_withYUp() throws { + let area = try XCTUnwrap(MarqueeGeometry.deviceRect(CGRect(x: 0, y: 300, width: 100, height: 100), in: size)) + + XCTAssertEqual(area.low, simd_float2(-1, 0.5)) + XCTAssertEqual(area.high, simd_float2(-0.5, 1)) + XCTAssertNil(MarqueeGeometry.deviceRect(CGRect(x: 0, y: 0, width: 10, height: 10), in: .zero)) + } + + // MARK: - A box + + func test_aRectangleAroundTheBox_containsIt() { + XCTAssertTrue(contains(CGRect(x: 150, y: 150, width: 100, height: 100))) + XCTAssertTrue(contains(CGRect(x: 175, y: 175, width: 50, height: 50)), "three points to spare each way") + } + + func test_aRectangleThatOnlyTouchesTheBox_doesNotContainIt() { + XCTAssertFalse(contains(CGRect(x: 190, y: 150, width: 100, height: 100)), "the left of the box is out") + XCTAssertFalse(contains(CGRect(x: 150, y: 150, width: 60, height: 100)), "its right is out") + XCTAssertFalse(contains(CGRect(x: 195, y: 195, width: 10, height: 10)), "a rectangle on its face") + XCTAssertFalse(contains(CGRect(x: 180, y: 180, width: 40, height: 40)), "around its far face, short of its near one") + } + + func test_aRectangleAwayFromTheBox_doesNot() { + XCTAssertFalse(contains(CGRect(x: 10, y: 10, width: 50, height: 50))) + } + + func test_aDragStraightAcross_containsNoBox() { + XCTAssertFalse(contains(MarqueeGeometry.rect(from: CGPoint(x: 100, y: 200), to: CGPoint(x: 300, y: 200)))) + } + + func test_theBoxIsTakenWhereItsEntityStands() { + // Two units to the right at five away: 80 points right of the middle. + let position = simd_float3(2, 0, 0) + + XCTAssertTrue(contains(CGRect(x: 250, y: 170, width: 70, height: 60), at: position)) + XCTAssertFalse(contains(CGRect(x: 150, y: 150, width: 100, height: 100), at: position)) + } + + func test_upInTheWorld_isUpInTheRectanglesMeasure() { + let position = simd_float3(0, 2, 0) + + XCTAssertTrue(contains(CGRect(x: 170, y: 250, width: 60, height: 70), at: position)) + XCTAssertFalse(contains(CGRect(x: 170, y: 80, width: 60, height: 70), at: position)) + } + + func test_aTurnedBox_isInsideWhereItsCornersReach() { + // A box two wide and thin, turned a quarter about Z: it stands tall. + let tall = simd_float4x4(simd_quatf(angle: .pi / 2, axis: simd_float3(0, 0, 1))) + let box = (min: simd_float3(-1, -0.1, -0.1), max: simd_float3(1, 0.1, 0.1)) + let upright = CGRect(x: 190, y: 150, width: 20, height: 100) + let lying = CGRect(x: 150, y: 190, width: 100, height: 20) + + XCTAssertTrue(MarqueeGeometry.contains(upright, boxMinimum: box.min, boxMaximum: box.max, modelSpace: tall, view: view())) + XCTAssertFalse(MarqueeGeometry.contains(lying, boxMinimum: box.min, boxMaximum: box.max, modelSpace: tall, view: view())) + XCTAssertTrue(MarqueeGeometry.contains(lying, boxMinimum: box.min, boxMaximum: box.max, modelSpace: matrix_identity_float4x4, view: view())) + XCTAssertFalse(MarqueeGeometry.contains(upright, boxMinimum: box.min, boxMaximum: box.max, modelSpace: matrix_identity_float4x4, view: view())) + } + + func test_inAWideViewport_theMiddleIsWhereTheBoxIs() { + let wide = view(size: CGSize(width: 800, height: 400)) + + XCTAssertTrue(contains(CGRect(x: 370, y: 170, width: 60, height: 60), view: wide)) + XCTAssertFalse(contains(CGRect(x: 170, y: 170, width: 60, height: 60), view: wide)) + } + + func test_aViewportWithNoSize_containsNothing() { + XCTAssertFalse(contains(CGRect(x: 0, y: 0, width: 10, height: 10), view: view(size: CGSize(width: 0, height: 400)))) + } + + // MARK: - What reaches out of the rectangle + + func test_aFloorUnderTheRectangle_isNotInside() { + // A floor twenty units each way, a unit under the origin: it shows + // in every rectangle drawn over the lower half of the viewport. + let floor = (min: simd_float3(-10, -1.1, -10), max: simd_float3(10, -1, 10)) + + XCTAssertFalse(contains(CGRect(x: 150, y: 100, width: 100, height: 100), box: floor)) + XCTAssertFalse(contains(CGRect(x: 0, y: 0, width: 400, height: 400), box: floor), "not even the whole viewport holds it") + } + + func test_aBoxBehindTheCamera_isNotInside() { + XCTAssertFalse(contains(CGRect(x: 0, y: 0, width: 400, height: 400), at: simd_float3(0, 0, 10))) + } + + func test_aBoxThatReachesBehindTheCamera_isNotInside() { + // A long beam along the view, the camera inside it. + let beam = (min: simd_float3(-0.5, -0.5, -20), max: simd_float3(0.5, 0.5, 20)) + + XCTAssertFalse(contains(CGRect(x: 0, y: 0, width: 400, height: 400), box: beam)) + } + + func test_aBoxTheCameraIsInsideOf_isNotInside() { + // A room twenty units each way, the camera in it: a rectangle drawn + // in the room does not select the room. + let room = (min: simd_float3(repeating: -10), max: simd_float3(repeating: 10)) + + XCTAssertFalse(contains(CGRect(x: 190, y: 190, width: 20, height: 20), box: room)) + XCTAssertFalse(contains(CGRect(x: 0, y: 0, width: 400, height: 400), box: room)) + } + + // MARK: - Where a box stands to the rectangle + + private func place( + _ rect: CGRect, + box: (min: simd_float3, max: simd_float3)? = nil, + at position: simd_float3 = .zero, + view: MarqueeGeometry.View? = nil + ) -> MarqueeGeometry.Place { + let box = box ?? unitBox + return MarqueeGeometry.place( + ofBoxMinimum: box.min, + boxMaximum: box.max, + modelSpace: matrix4x4Translation(position.x, position.y, position.z), + in: rect, + view: view ?? self.view() + ) + } + + func test_aBoxAllInTheRectangle_isInside_andOneTheRectangleCuts_isAcross() { + XCTAssertEqual(place(CGRect(x: 150, y: 150, width: 100, height: 100)), .inside) + XCTAssertEqual(place(CGRect(x: 190, y: 150, width: 100, height: 100)), .across, "the left of the box is out") + XCTAssertEqual(place(CGRect(x: 195, y: 195, width: 10, height: 10)), .across, "a rectangle on its face") + XCTAssertEqual(place(CGRect(x: 180, y: 180, width: 40, height: 40)), .across, "around its far face, short of its near one") + } + + func test_aBoxBeyondOneSideOfTheRectangle_isOutside() { + // The box shows from 178 to 222 points each way. + XCTAssertEqual(place(CGRect(x: 230, y: 150, width: 100, height: 100)), .outside, "the rectangle is right of it") + XCTAssertEqual(place(CGRect(x: 70, y: 150, width: 100, height: 100)), .outside, "left of it") + XCTAssertEqual(place(CGRect(x: 150, y: 230, width: 100, height: 100)), .outside, "above it") + XCTAssertEqual(place(CGRect(x: 150, y: 70, width: 100, height: 100)), .outside, "below it") + XCTAssertEqual(place(CGRect(x: 10, y: 10, width: 50, height: 50)), .outside) + } + + func test_aBoxThatTouchesTheBorder_isNotOutside() { + // The near face ends 22.2 points right of the middle. + XCTAssertEqual(place(CGRect(x: 222, y: 150, width: 100, height: 100)), .across) + XCTAssertEqual(place(CGRect(x: 223, y: 150, width: 100, height: 100)), .outside) + } + + func test_aBoxBehindTheCamera_isOutside_andOneThatReachesBehindIt_isNot() { + let everything = CGRect(x: 0, y: 0, width: 400, height: 400) + let beam = (min: simd_float3(-0.5, -0.5, -20), max: simd_float3(0.5, 0.5, 20)) + // A wall five units behind the camera, far wider than the view: it is + // beyond no side of the rectangle with all of it. + let wallBehind = (min: simd_float3(-10, -10, 9.9), max: simd_float3(10, 10, 10.1)) + + XCTAssertEqual(place(everything, at: simd_float3(0, 0, 10)), .outside) + XCTAssertEqual(place(everything, box: wallBehind), .outside) + XCTAssertEqual(place(CGRect(x: 190, y: 190, width: 20, height: 20), box: wallBehind), .outside) + XCTAssertEqual(place(everything, box: beam), .across, "the camera is in the beam") + XCTAssertEqual(place(CGRect(x: 10, y: 10, width: 50, height: 50), box: beam), .across, "and its walls are all around the view") + } + + func test_theRoomTheCameraIsIn_isAcrossEveryRectangle() { + let room = (min: simd_float3(repeating: -10), max: simd_float3(repeating: 10)) + + XCTAssertEqual(place(CGRect(x: 190, y: 190, width: 20, height: 20), box: room), .across) + XCTAssertEqual(place(CGRect(x: 0, y: 0, width: 10, height: 10), box: room), .across) + XCTAssertEqual(place(CGRect(x: 0, y: 0, width: 400, height: 400), box: room), .across) + } + + func test_aFloor_isAcrossARectangleOverIt_andOutsideOneAboveTheHorizon() { + // A floor a unit under the camera's height, that reaches behind the + // camera: all that shows of it is below the middle of the view. + let floor = (min: simd_float3(-10, -1.1, -10), max: simd_float3(10, -1, 10)) + + XCTAssertEqual(place(CGRect(x: 150, y: 100, width: 100, height: 100), box: floor), .across) + XCTAssertEqual(place(CGRect(x: 150, y: 220, width: 100, height: 100), box: floor), .outside) + } + + func test_aViewportWithNoSize_hasEveryBoxOutside() { + XCTAssertEqual(place(CGRect(x: 0, y: 0, width: 10, height: 10), view: view(size: CGSize(width: 0, height: 400))), .outside) + } + + func test_aBoxThatIsNowhere_isAcross_asThereIsNoTelling() { + let nowhere = MarqueeGeometry.place( + ofBoxMinimum: unitBox.min, + boxMaximum: unitBox.max, + modelSpace: matrix4x4Translation(.nan, 0, 0), + in: CGRect(x: 150, y: 150, width: 100, height: 100), + view: view() + ) + + XCTAssertEqual(nowhere, .across) + } + + /// The answer `outside` leaves an entity out of what is drawn to tell + /// what is seen, so it must never be given for a box that shows in the + /// rectangle: turned boxes of every size, around the camera and behind it. + func test_noBoxToldOutside_hasAPointThatShowsInTheRectangle() { + var generator = SeededGenerator(state: 7) + let view = view() + var outside = 0 + var across = 0 + var inside = 0 + + for _ in 0 ..< 3000 { + let half = simd_float3(generator.next(in: 0.05 ... 3), generator.next(in: 0.05 ... 3), generator.next(in: 0.05 ... 3)) + let turn = simd_quatf( + angle: generator.next(in: 0 ... 2 * .pi), + axis: simd_normalize(simd_float3(generator.next(in: -1 ... 1), generator.next(in: -1 ... 1), generator.next(in: 0.1 ... 1))) + ) + let position = simd_float3(generator.next(in: -8 ... 8), generator.next(in: -8 ... 8), generator.next(in: -12 ... 9)) + let modelSpace = simd_mul(matrix4x4Translation(position.x, position.y, position.z), simd_float4x4(turn)) + let origin = CGPoint(x: CGFloat(generator.next(in: 0 ... 360)), y: CGFloat(generator.next(in: 0 ... 360))) + let rect = CGRect( + origin: origin, + size: CGSize(width: CGFloat(generator.next(in: 1 ... 200)), height: CGFloat(generator.next(in: 1 ... 200))) + ) + + let place = MarqueeGeometry.place(ofBoxMinimum: -half, boxMaximum: half, modelSpace: modelSpace, in: rect, view: view) + + // Points all through the box, its corners among them. + var shows = 0 + var points = 0 + let steps: [Float] = [-1, -0.5, 0, 0.5, 1] + for x in steps { + for y in steps { + for z in steps { + let world = simd_mul(modelSpace, simd_float4(half * simd_float3(x, y, z), 1)) + points += 1 + shows += MarqueeGeometry.contains(rect, point: simd_float3(world.x, world.y, world.z), view: view) ? 1 : 0 + } + } + } + + switch place { + case .outside: + outside += 1 + XCTAssertEqual(shows, 0, "a box told outside shows in \(rect)") + case .inside: + inside += 1 + XCTAssertEqual(shows, points, "a box told inside reaches out of \(rect)") + case .across: + across += 1 + } + } + + // The three answers all came up, so each of them was put to the test. + XCTAssertGreaterThan(outside, 300) + XCTAssertGreaterThan(across, 300) + XCTAssertGreaterThan(inside, 3) + } + + // MARK: - A point + + func test_aPoint_isInsideTheRectangleItIsIn() { + let view = view() + + XCTAssertTrue(MarqueeGeometry.contains(CGRect(x: 190, y: 190, width: 20, height: 20), point: .zero, view: view)) + XCTAssertFalse(MarqueeGeometry.contains(CGRect(x: 210, y: 190, width: 20, height: 20), point: .zero, view: view)) + XCTAssertTrue(MarqueeGeometry.contains(CGRect(x: 200, y: 190, width: 20, height: 20), point: .zero, view: view), "the border counts") + } + + func test_aPointBehindTheCamera_isNotInside() { + XCTAssertFalse(MarqueeGeometry.contains( + CGRect(x: 0, y: 0, width: 400, height: 400), + point: simd_float3(0, 0, 10), + view: view() + )) + } + + func test_inDeviceCoordinates_aPlaceIsInsideUpToTheBorder_andOnlyInFrontOfTheCamera() { + let area = MarqueeGeometry.DeviceRect(low: simd_float2(-0.5, -0.5), high: simd_float2(0.5, 0.5)) + + XCTAssertTrue(area.contains(simd_float4(0.2, -0.2, 0, 1))) + XCTAssertTrue(area.contains(simd_float4(1, 1, 0, 2)), "on the corner, once divided by its depth") + XCTAssertFalse(area.contains(simd_float4(0.6, 0, 0, 1)), "beside it") + XCTAssertFalse(area.contains(simd_float4(0.2, 0.2, 0, -1)), "behind the camera") + XCTAssertFalse(area.contains(simd_float4(.nan, 0, 0, 1))) + } +} diff --git a/Tests/UntoldEditorTests/MarqueeInputTests.swift b/Tests/UntoldEditorTests/MarqueeInputTests.swift new file mode 100644 index 0000000..33e0e7e --- /dev/null +++ b/Tests/UntoldEditorTests/MarqueeInputTests.swift @@ -0,0 +1,412 @@ +// +// MarqueeInputTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import AppKit +import ModelIO +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +private final class SelectionRecorder: SelectionDelegate { + var clearCount = 0 + var selectedOne: [EntityID] = [] + var inspectedMeshes: [EntityID] = [] + var selectedSeveral: [[EntityID]] = [] + var toggled: [EntityID] = [] + + func didSelectEntity(_ entityId: EntityID) { + selectedOne.append(entityId) + } + + func didInspectEntity(_: EntityID) {} + func didInspectMesh(_ entityId: EntityID, meshIndex _: Int) { + inspectedMeshes.append(entityId) + } + + func didClearSelection() { + clearCount += 1 + } + + func resetActiveAxis() {} + + func didSelectEntities(_ entityIds: [EntityID]) { + selectedSeveral.append(entityIds) + } + + func didToggleEntity(_ entityId: EntityID) { + toggled.append(entityId) + } +} + +/// The left button on the canvas: a drag draws the rectangle and selects what +/// is inside it, a ⇧ click adds to the selection, and a click on another +/// entity selects it though a gizmo shows. +/// +/// The camera stands five units in front of the origin over a canvas of 400 +/// by 300 points: a unit of the world at the origin is 30 points, and the +/// middle is (200, 150), measured from the bottom left. +final class MarqueeInputTests: XCTestCase { + private var originalScene: Scene! + private var originalActiveCamera: EntityID? + private var originalGameMode = false + private var originalActiveEntity: EntityID = .invalid + private var originalPlayback: EditorPlaybackSettings! + private var originalController: EditorController? + private var originalDelegate: SelectionDelegate? + private var originalKeyState = KeyState() + private var originalPerspective = matrix_identity_float4x4 + private var originalOctree = false + private var originalVisible: [EntityID] = [] + private var originalTool: TransformTool! + private var originalBoxBuffer: MTLBuffer? + + private let recorder = SelectionRecorder() + private var playback: EditorPlaybackSettings! + private var selectionManager: SelectionManager! + private var view: NSView! + private let input = InputSystem.shared + + override func setUp() { + super.setUp() + guard let device = MTLCreateSystemDefaultDevice() else { + XCTFail("Metal device is not available.") + return + } + renderInfo.device = device + vertexDescriptor.model = MDLVertexDescriptor() + originalBoxBuffer = bufferResources.boundingBoxBuffer + bufferResources.boundingBoxBuffer = device.makeBuffer( + length: MemoryLayout.stride * boundingBoxVertexCount, + options: .storageModeShared + ) + + originalScene = scene + originalActiveCamera = CameraSystem.shared.activeCamera + originalGameMode = gameMode + originalActiveEntity = activeEntity + originalPlayback = ViewportCameras.playback + originalController = editorController + originalDelegate = selectionDelegate + originalKeyState = input.keyState + originalPerspective = renderInfo.perspectiveSpace + originalOctree = OctreeSystem.shared.enabled + originalVisible = visibleEntityIds + originalTool = EditorViewportSettings.shared.tool + + scene = Scene() + gameMode = false + activeEntity = .invalid + gizmoTargets = [] + input.keyState = KeyState() + EditorViewportSettings.shared.tool = .move + playback = EditorPlaybackSettings(defaults: nil) + ViewportCameras.playback = playback + selectionManager = SelectionManager() + editorController = EditorController(selectionManager: selectionManager) + editorController?.isEnabled = true + selectionDelegate = recorder + + let camera = findSceneCamera() + cameraLookAt(entityId: camera, eye: simd_float3(0, 0, 5), target: .zero, up: simd_float3(0, 1, 0)) + CameraSystem.shared.activeCamera = camera + renderInfo.perspectiveSpace = matrixPerspectiveRightHand(fovyRadians: .pi / 2, aspectRatio: 4.0 / 3.0, nearZ: 0.1, farZ: 100) + // A click picks by the boxes here: the renderer that fills the tree + // and the list of what is seen does not run in a test. + OctreeSystem.shared.enabled = false + view = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + } + + override func tearDown() { + input.canvasLostTheKeyboard() + input.cameraControlMode = .idle + input.keyState = originalKeyState + selectionManager.clearSelection() + selectionManager = nil + removeGizmo() + gizmoTargets = [] + SelectionHighlights.shared.boxes = [] + ViewportMarqueeStore.shared.hide() + EditorViewportSettings.shared.tool = originalTool + OctreeSystem.shared.enabled = originalOctree + visibleEntityIds = originalVisible + renderInfo.perspectiveSpace = originalPerspective + ViewportCameras.playback = originalPlayback + selectionDelegate = originalDelegate + editorController = originalController + activeEntity = originalActiveEntity + gameMode = originalGameMode + CameraSystem.shared.activeCamera = originalActiveCamera + bufferResources.boundingBoxBuffer = originalBoxBuffer + scene = originalScene + originalScene = nil + view = nil + super.tearDown() + } + + // MARK: - Helpers + + private func makeBox(_ name: String, at position: simd_float3, halfExtent: Float = 0.5) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entity)?.boundingBox = ( + min: simd_float3(repeating: -halfExtent), + max: simd_float3(repeating: halfExtent) + ) + translateTo(entityId: entity, position: position) + visibleEntityIds.append(entity) + return entity + } + + /// A drag of the left button from one point of the canvas to another, + /// through a point between them, and its release. + private func drag(from start: NSPoint, to end: NSPoint, release: Bool = true) { + let between = NSPoint(x: (start.x + end.x) / 2, y: (start.y + end.y) / 2) + input.beginObjectDrag(at: start, in: view) + input.continueObjectDrag(to: between, translation: NSPoint(x: between.x - start.x, y: between.y - start.y), in: view) + input.continueObjectDrag(to: end, translation: NSPoint(x: end.x - start.x, y: end.y - start.y), in: view) + if release { + input.endObjectDrag() + } + } + + // MARK: - The rectangle + + func test_aDrag_drawsTheRectangle_asTheViewportMeasuresIt() { + drag(from: NSPoint(x: 100, y: 100), to: NSPoint(x: 300, y: 250), release: false) + + XCTAssertTrue(input.isMarqueeActive) + // From the bottom left it is 100 to 250 high; from the top, 50 to 200. + XCTAssertEqual(ViewportMarqueeStore.shared.rect, CGRect(x: 100, y: 50, width: 200, height: 150)) + } + + func test_theRectangle_followsADragThatGoesBackOverItsStart() { + drag(from: NSPoint(x: 200, y: 150), to: NSPoint(x: 120, y: 200), release: false) + + XCTAssertEqual(ViewportMarqueeStore.shared.rect, CGRect(x: 120, y: 100, width: 80, height: 50)) + } + + func test_releasing_selectsWhatIsInsideTheRectangle_andTakesItAway() { + let left = makeBox("Left", at: simd_float3(-2, 0, 0)) + let middle = makeBox("Middle", at: .zero) + _ = makeBox("Right", at: simd_float3(3, 0, 0)) + + // The left one reaches from 117 to 159 points, the middle one to 217. + drag(from: NSPoint(x: 105, y: 125), to: NSPoint(x: 230, y: 175)) + + XCTAssertEqual(recorder.selectedSeveral.count, 1) + XCTAssertEqual(Set(recorder.selectedSeveral.first ?? []), [left, middle]) + XCTAssertNil(ViewportMarqueeStore.shared.rect) + XCTAssertFalse(input.isMarqueeActive) + XCTAssertEqual(recorder.clearCount, 0) + } + + func test_aRectangleThatOnlyTouchesAnEntity_selectsNothing() { + let box = makeBox("Box", at: .zero) + _ = makeBox("Floor", at: simd_float3(0, -1, 0), halfExtent: 4) + selectionManager.inspectEntity(entityId: box) + + // Over the right half of the box, and over the floor under it. + drag(from: NSPoint(x: 200, y: 100), to: NSPoint(x: 300, y: 200)) + + XCTAssertTrue(recorder.selectedSeveral.isEmpty, "neither the box it cuts nor the floor that reaches out of it") + XCTAssertEqual(recorder.clearCount, 1, "with nothing inside, the selection is cleared") + } + + func test_aRectangleOverNothing_clearsTheSelection() { + let box = makeBox("Box", at: .zero) + selectionManager.inspectEntity(entityId: box) + XCTAssertEqual(activeEntity, box) + + drag(from: NSPoint(x: 10, y: 10), to: NSPoint(x: 60, y: 60)) + + XCTAssertEqual(recorder.clearCount, 1) + XCTAssertTrue(recorder.selectedSeveral.isEmpty) + XCTAssertEqual(activeEntity, .invalid) + XCTAssertFalse(gizmoActive) + } + + func test_withCommandHeld_theRectangleSelectsTheAssets() { + let chair = createEntity() + let seat = makeBox("Seat", at: simd_float3(-0.5, 0, 0)) + let back = makeBox("Back", at: simd_float3(0.5, 0, 0)) + for node in [seat, back] { + registerComponent(entityId: node, componentType: DerivedAssetNodeComponent.self) + scene.get(component: DerivedAssetNodeComponent.self, for: node)?.assetRootEntityId = chair + } + input.keyState.commandPressed = true + + drag(from: NSPoint(x: 150, y: 130), to: NSPoint(x: 250, y: 170)) + + XCTAssertEqual(recorder.selectedSeveral, [[chair]]) + } + + func test_aLockedEntity_isLeftOutOfTheRectangle() { + let free = makeBox("Free", at: simd_float3(-1, 0, 0)) + let locked = makeBox("Locked", at: simd_float3(1, 0, 0)) + selectionManager.setLocked(locked, true) + + drag(from: NSPoint(x: 100, y: 100), to: NSPoint(x: 300, y: 200)) + + XCTAssertEqual(recorder.selectedSeveral, [[free]]) + } + + // MARK: - When no rectangle is drawn + + func test_whilePlaying_aDragDrawsNoRectangle() { + _ = makeBox("Box", at: .zero) + playback.isSessionActive = true + + drag(from: NSPoint(x: 100, y: 100), to: NSPoint(x: 300, y: 200), release: false) + XCTAssertFalse(input.isMarqueeActive) + XCTAssertNil(ViewportMarqueeStore.shared.rect) + + input.endObjectDrag() + XCTAssertTrue(recorder.selectedSeveral.isEmpty) + XCTAssertEqual(recorder.clearCount, 0) + } + + func test_whileTheEditorIsDisabled_aDragDrawsNoRectangle() { + _ = makeBox("Box", at: .zero) + editorController?.isEnabled = false + + drag(from: NSPoint(x: 100, y: 100), to: NSPoint(x: 300, y: 200)) + + XCTAssertNil(ViewportMarqueeStore.shared.rect) + XCTAssertTrue(recorder.selectedSeveral.isEmpty) + XCTAssertEqual(recorder.clearCount, 0) + } + + func test_overALockedPreview_aDragDrawsNoRectangle() { + _ = makeBox("Box", at: .zero) + let gameCamera = createEntity() + registerComponent(entityId: gameCamera, componentType: CameraComponent.self) + XCTAssertTrue(ViewportCameras.show(.game(gameCamera))) + + // The canvas hands a drag over only while it takes the pointer. + let event = NSEvent() + input.canvasLeftMouseDown(event, at: NSPoint(x: 100, y: 100)) + input.canvasLeftMouseDragged(event, to: NSPoint(x: 300, y: 200), in: view) + + XCTAssertFalse(input.isMarqueeActive) + XCTAssertNil(ViewportMarqueeStore.shared.rect) + } + + func test_shiftWithASelection_movesThatEntity_andDrawsNoRectangle() { + let box = makeBox("Box", at: .zero) + selectionManager.inspectEntity(entityId: box) + input.keyState.shiftPressed = true + + drag(from: NSPoint(x: 10, y: 10), to: NSPoint(x: 60, y: 60)) + + XCTAssertNil(ViewportMarqueeStore.shared.rect) + XCTAssertEqual(recorder.clearCount, 0) + XCTAssertEqual(activeEntity, box) + } + + func test_losingTheKeyboard_takesTheRectangleAway_andSelectsNothing() { + _ = makeBox("Box", at: .zero) + let event = NSEvent() + input.canvasLeftMouseDown(event, at: NSPoint(x: 100, y: 100)) + input.canvasLeftMouseDragged(event, to: NSPoint(x: 300, y: 200), in: view) + XCTAssertNotNil(ViewportMarqueeStore.shared.rect) + + input.canvasLostTheKeyboard() + + XCTAssertNil(ViewportMarqueeStore.shared.rect) + XCTAssertFalse(input.isMarqueeActive) + XCTAssertTrue(recorder.selectedSeveral.isEmpty) + XCTAssertEqual(recorder.clearCount, 0) + } + + // MARK: - Through the editor's controller + + func test_theControllerSelectsWhatStoodInsideTheRectangle() { + let first = makeBox("First", at: simd_float3(-1, 0, 0)) + let second = makeBox("Second", at: simd_float3(1, 0, 0)) + selectionDelegate = editorController + + drag(from: NSPoint(x: 100, y: 100), to: NSPoint(x: 300, y: 200)) + + let selected = expectation(description: "the selection follows on the main queue") + DispatchQueue.main.async { selected.fulfill() } + wait(for: [selected], timeout: 1) + XCTAssertEqual(Set(selectionManager.selectedEntities), [first, second]) + XCTAssertEqual(Set(gizmoTargets), [first, second]) + XCTAssertTrue(gizmoActive) + } + + // MARK: - Clicks + + func test_aShiftClick_togglesWhatIsUnderThePointer() { + let box = makeBox("Box", at: .zero) + input.keyState.shiftPressed = true + + input.selectEntity(at: NSPoint(x: 200, y: 150), in: view) + + XCTAssertEqual(recorder.toggled, [box]) + XCTAssertTrue(recorder.selectedOne.isEmpty) + XCTAssertEqual(recorder.clearCount, 0) + } + + func test_aShiftClick_onEmptySpace_changesNothing() { + let box = makeBox("Box", at: .zero) + selectionManager.inspectEntity(entityId: box) + input.keyState.shiftPressed = true + + input.selectEntity(at: NSPoint(x: 20, y: 20), in: view) + + XCTAssertTrue(recorder.toggled.isEmpty) + XCTAssertEqual(recorder.clearCount, 0) + XCTAssertEqual(activeEntity, box) + XCTAssertTrue(gizmoActive, "the gizmo stays where it is") + } + + func test_aShiftCommandClick_togglesTheAsset() { + let chair = createEntity() + registerTransformComponent(entityId: chair) + let seat = makeBox("Seat", at: .zero) + registerComponent(entityId: seat, componentType: DerivedAssetNodeComponent.self) + scene.get(component: DerivedAssetNodeComponent.self, for: seat)?.assetRootEntityId = chair + input.keyState.shiftPressed = true + input.keyState.commandPressed = true + + input.selectEntity(at: NSPoint(x: 200, y: 150), in: view) + + XCTAssertEqual(recorder.toggled, [chair]) + } + + func test_aClickOnAnotherEntity_selectsIt_thoughAGizmoShows() { + let selected = makeBox("Selected", at: simd_float3(-2, 0, 0)) + let other = makeBox("Other", at: simd_float3(2, 0, 0)) + selectionManager.inspectEntity(entityId: selected) + XCTAssertTrue(gizmoActive) + + // Two units to the right of the middle: 60 points. + input.selectEntity(at: NSPoint(x: 260, y: 150), in: view) + + XCTAssertEqual(recorder.clearCount, 0, "it used to find nothing and clear the selection") + XCTAssertEqual(recorder.selectedOne + recorder.inspectedMeshes, [other]) + XCTAssertEqual(activeEntity, other) + } + + func test_aClickOnEmptySpace_stillClearsTheSelection_whileAGizmoShows() { + let selected = makeBox("Selected", at: simd_float3(-2, 0, 0)) + selectionManager.inspectEntity(entityId: selected) + + input.selectEntity(at: NSPoint(x: 380, y: 280), in: view) + + XCTAssertEqual(recorder.clearCount, 1) + XCTAssertEqual(activeEntity, .invalid) + } +} diff --git a/Tests/UntoldEditorTests/MarqueeSelectionTests.swift b/Tests/UntoldEditorTests/MarqueeSelectionTests.swift new file mode 100644 index 0000000..dcd700f --- /dev/null +++ b/Tests/UntoldEditorTests/MarqueeSelectionTests.swift @@ -0,0 +1,220 @@ +// +// MarqueeSelectionTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// Which entities of a scene the rectangle selects. The camera stands five +/// units in front of the origin over a viewport of 400 points each way, so a +/// unit of the world at the origin is 40 points and the middle is (200, 200). +/// A box of one unit at the origin reaches 22 points from the middle. +final class MarqueeSelectionTests: XCTestCase { + private var originalScene: Scene! + private var selectionManager: SelectionManager! + private var view: MarqueeGeometry.View! + + /// The whole viewport. + private let everything = CGRect(x: 0, y: 0, width: 400, height: 400) + + override func setUp() { + super.setUp() + originalScene = scene + scene = Scene() + selectionManager = SelectionManager() + + let camera = createEntity() + registerComponent(entityId: camera, componentType: CameraComponent.self) + cameraLookAt(entityId: camera, eye: simd_float3(0, 0, 5), target: .zero, up: simd_float3(0, 1, 0)) + view = MarqueeGeometry.View( + viewSpace: scene.get(component: CameraComponent.self, for: camera)?.viewSpace ?? matrix_identity_float4x4, + perspectiveSpace: matrixPerspectiveRightHand(fovyRadians: .pi / 2, aspectRatio: 1, nearZ: 0.1, farZ: 100), + size: CGSize(width: 400, height: 400) + ) + } + + override func tearDown() { + selectionManager = nil + view = nil + SelectionHighlights.shared.boxes = [] + gizmoTargets = [] + scene = originalScene + originalScene = nil + super.tearDown() + } + + private func makeBox(_ name: String, at position: simd_float3, halfExtent: Float = 0.5) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entity)?.boundingBox = ( + min: simd_float3(repeating: -halfExtent), + max: simd_float3(repeating: halfExtent) + ) + translateTo(entityId: entity, position: position) + return entity + } + + private func makeLight(_ name: String, at position: simd_float3) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: LightComponent.self) + translateTo(entityId: entity, position: position) + return entity + } + + private func inside(_ rect: CGRect) -> [EntityID] { + MarqueeSelection.entities(inside: rect, view: view, selectionManager: selectionManager) + } + + // MARK: - What is inside + + func test_theRectangle_selectsWhatIsInsideIt_andLeavesTheRest() { + let left = makeBox("Left", at: simd_float3(-2, 0, 0)) + let middle = makeBox("Middle", at: .zero) + _ = makeBox("Right", at: simd_float3(2, 0, 0)) + + // From left of the left one to right of the middle one. + let selected = inside(CGRect(x: 80, y: 170, width: 150, height: 60)) + + XCTAssertEqual(Set(selected), [left, middle]) + } + + func test_anEntityOnlyPartlyInTheRectangle_isLeftOut() { + let box = makeBox("Box", at: .zero) + + XCTAssertEqual(inside(CGRect(x: 215, y: 215, width: 100, height: 100)), [], "one corner of it") + XCTAssertEqual(inside(CGRect(x: 100, y: 180, width: 100, height: 40)), [], "half of it") + XCTAssertEqual(inside(CGRect(x: 150, y: 150, width: 100, height: 100)), [box], "all of it") + } + + func test_theFloorUnderWhatIsSelected_isLeftOut() { + let crate = makeBox("Crate", at: .zero) + let floor = makeBox("Floor", at: simd_float3(0, -0.6, 0)) + scene.get(component: LocalTransformComponent.self, for: floor)?.boundingBox = ( + min: simd_float3(-10, -0.05, -10), max: simd_float3(10, 0.05, 10) + ) + + // The floor shows in the rectangle, under the crate, and reaches out of it. + XCTAssertEqual(inside(CGRect(x: 150, y: 150, width: 100, height: 100)), [crate]) + XCTAssertEqual(inside(everything), [crate], "not even the whole viewport holds the floor") + } + + func test_theNearestToTheCamera_comesLast() { + let far = makeBox("Far", at: simd_float3(0, 0, -6)) + let near = makeBox("Near", at: simd_float3(0, 0, 2)) + let between = makeBox("Between", at: simd_float3(0, 0, -1)) + + XCTAssertEqual(inside(everything), [far, between, near]) + } + + func test_withoutThePassThatTellsWhatIsSeen_whatStandsBehindAnotherIsSelectedToo() { + let wall = makeBox("Wall", at: simd_float3(0, 0, 1), halfExtent: 1) + let behind = makeBox("Behind", at: simd_float3(0, 0, -3), halfExtent: 0.25) + + // The wall reaches 67 points from the middle. + XCTAssertEqual(Set(inside(CGRect(x: 120, y: 120, width: 160, height: 160))), [wall, behind]) + XCTAssertEqual(inside(CGRect(x: 190, y: 190, width: 20, height: 20)), [behind], "a rectangle on the wall holds what is behind it, not the wall") + } + + func test_aRectangleOverNothing_selectsNothing() { + _ = makeBox("Box", at: .zero) + + XCTAssertEqual(inside(CGRect(x: 10, y: 10, width: 40, height: 40)), []) + } + + func test_aChildIsInsideWhereItStandsInTheWorld() { + let parent = makeBox("Parent", at: simd_float3(-2, 0, 0)) + let child = makeBox("Child", at: .zero) + setParent(childId: child, parentId: parent) + translateTo(entityId: child, position: simd_float3(4, 0, 0)) + + // The child is two units right of the origin: 80 points right of the middle. + XCTAssertEqual(inside(CGRect(x: 250, y: 170, width: 70, height: 60)), [child]) + } + + // MARK: - What is left out + + func test_aHiddenEntity_isNotSelected() { + let shown = makeBox("Shown", at: simd_float3(-1, 0, 0)) + let hidden = makeBox("Hidden", at: simd_float3(1, 0, 0)) + selectionManager.setHidden(hidden, true) + + XCTAssertEqual(inside(everything), [shown]) + } + + func test_aLockedEntity_isNotSelected_norWhatIsUnderIt() { + let free = makeBox("Free", at: simd_float3(-1, 0, 0)) + let locked = makeBox("Locked", at: simd_float3(1, 0, 0)) + let under = makeBox("Under", at: .zero) + setParent(childId: under, parentId: locked) + selectionManager.setLocked(locked, true) + + XCTAssertEqual(inside(everything), [free]) + } + + func test_anEntityThePickingLeavesOut_isNotSelected() { + let picked = makeBox("Picked", at: simd_float3(-1, 0, 0)) + let left = makeBox("Left out", at: simd_float3(1, 0, 0)) + setEntityPickParticipation(entityId: left, enabled: false) + + XCTAssertEqual(inside(everything), [picked]) + } + + func test_camerasAndTheGizmo_areNotSelected() { + let box = makeBox("Box", at: .zero) + let gameCamera = makeBox("Camera", at: simd_float3(1, 0, 0)) + registerComponent(entityId: gameCamera, componentType: CameraComponent.self) + let handle = makeBox("Handle", at: simd_float3(-1, 0, 0)) + registerComponent(entityId: handle, componentType: GizmoComponent.self) + + XCTAssertEqual(inside(everything), [box]) + } + + func test_anEntityThatDrawsNothing_andShowsNowhere_isNotSelected() { + let empty = createEntity() + registerTransformComponent(entityId: empty) + registerSceneGraphComponent(entityId: empty) + + XCTAssertEqual(inside(everything), []) + } + + // MARK: - Lights + + func test_aLight_isInsideWhereItStands() { + let light = makeLight("Light", at: simd_float3(2, 0, 0)) + + XCTAssertEqual(inside(CGRect(x: 270, y: 190, width: 20, height: 20)), [light]) + XCTAssertEqual(inside(CGRect(x: 286, y: 190, width: 20, height: 20)), [], "six points beside it is not on it") + } + + // MARK: - With ⌘, the assets + + func test_theAssetsOfWhatIsInside_eachOnce_theNearestLast() { + let chair = createEntity(), table = createEntity() + let seat = makeBox("Seat", at: simd_float3(0, 0, -2)) + let back = makeBox("Back", at: simd_float3(0, 0, 1)) + let top = makeBox("Top", at: .zero) + let alone = makeBox("Alone", at: simd_float3(0, 0, 2)) + for (node, root) in [(seat, chair), (back, chair), (top, table)] { + registerComponent(entityId: node, componentType: DerivedAssetNodeComponent.self) + scene.get(component: DerivedAssetNodeComponent.self, for: node)?.assetRootEntityId = root + } + + let roots = MarqueeSelection.assetRoots(of: [seat, top, back, alone]) + + XCTAssertEqual(roots, [table, chair, alone], "the chair stands where its nearest part does") + } +} diff --git a/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift b/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift index 8af792c..c9a5819 100644 --- a/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift +++ b/Tests/UntoldEditorTests/SceneHierarchyViewTests.swift @@ -205,8 +205,8 @@ final class SceneHierarchyViewTests: XCTestCase { func test_sceneGraphModel_returnsRootEntitiesForNilParent() { // Arrange - createEntity() - createEntity() + _ = createEntity() + _ = createEntity() // Refresh to cache the hierarchy sceneGraphModel.refreshHierarchy() @@ -358,17 +358,17 @@ final class SceneHierarchyViewTests: XCTestCase { // MARK: - Entity Hierarchy Tests - func test_sceneGraphModel_refreshHierarchy() { + func test_sceneGraphModel_refreshHierarchy() throws { // Arrange: Create some entities - createEntity() - createEntity() - createEntity() + _ = createEntity() + _ = createEntity() + _ = createEntity() // Act: Refresh hierarchy sceneGraphModel.refreshHierarchy() // Assert: The childrenMap should be populated - let mirror = Mirror(reflecting: sceneGraphModel) + let mirror = try Mirror(reflecting: XCTUnwrap(sceneGraphModel)) if let childrenMap = mirror.descendant("childrenMap") as? [EntityID: [EntityID]] { // The map should exist (though it might be empty or contain root entities) XCTAssertNotNil(childrenMap, "Children map should be populated after refresh") @@ -393,7 +393,7 @@ final class SceneHierarchyViewTests: XCTestCase { // Arrange let entity = createEntity() - let row = EntityRow( + _ = EntityRow( entityid: entity, entityName: "TestEntity", selectionManager: selectionManager diff --git a/Tests/UntoldEditorTests/SeededGenerator.swift b/Tests/UntoldEditorTests/SeededGenerator.swift new file mode 100644 index 0000000..03b0156 --- /dev/null +++ b/Tests/UntoldEditorTests/SeededGenerator.swift @@ -0,0 +1,23 @@ +// +// SeededGenerator.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// + +/// Numbers that look random and come out the same on every run, for tests +/// that try many cases and must fail the same way twice. +struct SeededGenerator { + var state: UInt64 + + /// The next number, from `range.lowerBound` up to `range.upperBound`. + mutating func next(in range: ClosedRange) -> Float { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + let unit = Float(state >> 40) / Float(1 << 24) + return range.lowerBound + (range.upperBound - range.lowerBound) * unit + } +} diff --git a/Tests/UntoldEditorTests/SelectionHighlightPassTests.swift b/Tests/UntoldEditorTests/SelectionHighlightPassTests.swift new file mode 100644 index 0000000..69102ef --- /dev/null +++ b/Tests/UntoldEditorTests/SelectionHighlightPassTests.swift @@ -0,0 +1,238 @@ +// +// SelectionHighlightPassTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import MetalKit +import ModelIO +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// The highlight pass with several entities selected, drawn by the renderer +/// into its own texture and read back: every selected entity has its box, +/// where the camera sees it. +/// +/// The camera stands five units in front of the origin and sees a quarter +/// turn from top to bottom. +@MainActor +final class SelectionHighlightPassTests: XCTestCase { + private var originalScene: Scene! + private var originalActiveEntity: EntityID! + private var originalActiveCamera: EntityID? + private var originalPerspective = matrix_identity_float4x4 + private var originalTool: TransformTool! + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var selectionManager: SelectionManager! + + override func setUp() { + super.setUp() + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + guard let created = UntoldRenderer.create() else { + XCTFail("Failed to initialize the renderer") + return + } + renderer = created + window.contentView = created.metalView + created.initResources() + + originalScene = scene + originalActiveEntity = activeEntity + originalActiveCamera = CameraSystem.shared.activeCamera + originalPerspective = renderInfo.perspectiveSpace + originalTool = EditorViewportSettings.shared.tool + + scene = Scene() + activeEntity = .invalid + gizmoTargets = [] + // No gizmo here: the pass under test draws the boxes alone. + EditorViewportSettings.shared.tool = .select + selectionManager = SelectionManager() + + let camera = findSceneCamera() + cameraLookAt(entityId: camera, eye: simd_float3(0, 0, 5), target: .zero, up: simd_float3(0, 1, 0)) + CameraSystem.shared.activeCamera = camera + renderInfo.perspectiveSpace = matrixPerspectiveRightHand(fovyRadians: .pi / 2, aspectRatio: aspect, nearZ: 0.1, farZ: 100) + } + + override func tearDown() { + selectionManager?.clearSelection() + selectionManager = nil + gizmoTargets = [] + SelectionHighlights.shared.boxes = [] + EditorViewportSettings.shared.tool = originalTool + renderInfo.perspectiveSpace = originalPerspective + CameraSystem.shared.activeCamera = originalActiveCamera + activeEntity = originalActiveEntity + scene = originalScene + originalScene = nil + renderer = nil + window = nil + super.tearDown() + } + + // MARK: - Helpers + + private var target: MTLTexture? { + textureResources.gizmoColorTexture + } + + private var aspect: Float { + guard let target, target.height > 0 else { return 1 } + return Float(target.width) / Float(target.height) + } + + private func makeBox(_ name: String, at position: simd_float3, halfExtent: Float = 0.5) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entity)?.boundingBox = ( + min: simd_float3(repeating: -halfExtent), + max: simd_float3(repeating: halfExtent) + ) + translateTo(entityId: entity, position: position) + return entity + } + + /// Runs the highlight pass and hands back what it drew: how bright each + /// pixel is, row by row from the top. + private func drawHighlights() throws -> (brightness: [Float], width: Int, height: Int) { + let texture = try XCTUnwrap(target) + XCTAssertEqual(texture.pixelFormat, .rgba16Float) + let commandBuffer = try XCTUnwrap(renderInfo.commandQueue.makeCommandBuffer()) + + RenderPasses.highlightExecution(commandBuffer) + + let bytesPerRow = texture.width * 8 + let readBack = try XCTUnwrap(renderInfo.device.makeBuffer(length: bytesPerRow * texture.height, options: .storageModeShared)) + let blit = try XCTUnwrap(commandBuffer.makeBlitCommandEncoder()) + blit.copy( + from: texture, + sourceSlice: 0, + sourceLevel: 0, + sourceOrigin: MTLOrigin(x: 0, y: 0, z: 0), + sourceSize: MTLSize(width: texture.width, height: texture.height, depth: 1), + to: readBack, + destinationOffset: 0, + destinationBytesPerRow: bytesPerRow, + destinationBytesPerImage: bytesPerRow * texture.height + ) + blit.endEncoding() + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + XCTAssertNil(commandBuffer.error) + + let channels = readBack.contents().bindMemory(to: UInt16.self, capacity: texture.width * texture.height * 4) + let brightness = (0 ..< texture.width * texture.height).map { pixel in + Float(Float16(bitPattern: channels[pixel * 4])) + } + return (brightness, texture.width, texture.height) + } + + /// The pixel a point of the world lands on. + private func pixel(of point: simd_float3, width: Int, height: Int) throws -> (x: Int, y: Int) { + let camera = try XCTUnwrap(scene.get(component: CameraComponent.self, for: findSceneCamera())) + let clip = simd_mul(simd_mul(renderInfo.perspectiveSpace, camera.viewSpace), simd_float4(point, 1)) + let x = (clip.x / clip.w + 1) / 2 * Float(width) + let y = (1 - clip.y / clip.w) / 2 * Float(height) + return (Int(x.rounded()), Int(y.rounded())) + } + + /// Whether something was drawn within `reach` pixels of a pixel. + private func isDrawn(near pixel: (x: Int, y: Int), in image: (brightness: [Float], width: Int, height: Int), reach: Int = 2) -> Bool { + for y in max(0, pixel.y - reach) ... min(image.height - 1, pixel.y + reach) { + for x in max(0, pixel.x - reach) ... min(image.width - 1, pixel.x + reach) where image.brightness[y * image.width + x] > 0.5 { + return true + } + } + return false + } + + // MARK: - Tests + + func test_everySelectedEntity_hasItsBoxDrawn_whereTheCameraSeesIt() throws { + let left = makeBox("Left", at: simd_float3(-2, 0, 0)) + let right = makeBox("Right", at: simd_float3(2, 1, 0)) + _ = makeBox("Not selected", at: simd_float3(0, -2, 0)) + selectionManager.selectEntities([left, right]) + XCTAssertEqual(SelectionHighlights.shared.boxes.count, 2) + + let image = try drawHighlights() + + // The middle of an edge of each box's near face, and a corner of it. + for point in [ + simd_float3(-2.5, 0, 0.5), simd_float3(-1.5, 0, 0.5), simd_float3(-2, 0.5, 0.5), simd_float3(-2.5, -0.5, 0.5), + simd_float3(1.5, 1, 0.5), simd_float3(2.5, 1, 0.5), simd_float3(2, 1.5, 0.5), simd_float3(2.5, 0.5, 0.5), + ] { + XCTAssertTrue(try isDrawn(near: pixel(of: point, width: image.width, height: image.height), in: image), "an edge at \(point)") + } + // Inside a box's face, between the boxes, and around the one not selected. + for point in [ + simd_float3(-2, 0, 0.5), simd_float3(2, 1, 0.5), simd_float3(0, 0, 0), + simd_float3(-0.5, -2, 0.5), simd_float3(0, -1.5, 0.5), + ] { + XCTAssertFalse(try isDrawn(near: pixel(of: point, width: image.width, height: image.height), in: image), "nothing at \(point)") + } + } + + func test_aBoxIsDrawnWhereItsEntityWasMovedTo() throws { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + selectionManager.selectEntities([first, second]) + + translateTo(entityId: first, position: simd_float3(-2, 2, 0)) + let image = try drawHighlights() + + XCTAssertTrue(try isDrawn(near: pixel(of: simd_float3(-2.5, 2, 0.5), width: image.width, height: image.height), in: image)) + XCTAssertFalse(try isDrawn(near: pixel(of: simd_float3(-2.5, 0, 0.5), width: image.width, height: image.height), in: image), "it left where it stood") + } + + func test_withNothingSelected_nothingIsDrawn() throws { + _ = makeBox("Box", at: .zero) + + let image = try drawHighlights() + + XCTAssertEqual(image.brightness.max() ?? 0, 0) + } + + func test_anEntityThatDrawsNothing_hasItsBoxWhereItStands() throws { + let box = makeBox("Box", at: simd_float3(-2, 0, 0)) + let empty = createEntity() + registerTransformComponent(entityId: empty) + registerSceneGraphComponent(entityId: empty) + translateTo(entityId: empty, position: simd_float3(2, 0, 0)) + selectionManager.selectEntities([box, empty]) + + let image = try drawHighlights() + + let half = SelectionHighlightBox.pointExtent / 2 + let edge = try pixel(of: simd_float3(2 - half, 0, half), width: image.width, height: image.height) + XCTAssertTrue(isDrawn(near: edge, in: image)) + + // Lines, not a filled box: nothing is drawn inside its near face. The + // box stands to the right of the view, so its far face shows through + // the near one a little to the left: the middle of the near face is + // next to the far face's right edge, two pixels from it where a point + // of the window is one pixel. The place looked at is further right, + // between that edge and the near face's own, and clear of both. + let inside = try pixel(of: simd_float3(2 + half * 0.6, 0, half), width: image.width, height: image.height) + let nearEdge = try pixel(of: simd_float3(2 + half, 0, half), width: image.width, height: image.height) + let farEdge = try pixel(of: simd_float3(2 + half, 0, -half), width: image.width, height: image.height) + try XCTSkipIf(min(nearEdge.x - inside.x, inside.x - farEdge.x) < 3, "the box is too few pixels wide here to tell lines from a fill") + XCTAssertFalse(isDrawn(near: inside, in: image, reach: 1), "lines, not a filled box") + } +} diff --git a/Tests/UntoldEditorTests/SelectionSeveralPanelsTests.swift b/Tests/UntoldEditorTests/SelectionSeveralPanelsTests.swift new file mode 100644 index 0000000..89af79c --- /dev/null +++ b/Tests/UntoldEditorTests/SelectionSeveralPanelsTests.swift @@ -0,0 +1,233 @@ +// +// SelectionSeveralPanelsTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import AppKit +import ModelIO +import simd +import SwiftUI +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// Several selected entities in the panels: the hierarchy's rows and footer, +/// the Inspector, and the keys that act on the selection. +final class SelectionSeveralPanelsTests: XCTestCase { + private var originalScene: UntoldEngine.Scene! + private var originalActiveEntity: EntityID! + private var originalTool: TransformTool! + private var originalBoxBuffer: MTLBuffer? + private var selectionManager: SelectionManager! + private var sceneGraphModel: SceneGraphModel! + private var selectedAsset: Asset? + + override func setUp() { + super.setUp() + guard let device = MTLCreateSystemDefaultDevice() else { + XCTFail("Metal device is not available.") + return + } + renderInfo.device = device + vertexDescriptor.model = MDLVertexDescriptor() + originalBoxBuffer = bufferResources.boundingBoxBuffer + bufferResources.boundingBoxBuffer = device.makeBuffer( + length: MemoryLayout.stride * boundingBoxVertexCount, + options: .storageModeShared + ) + + originalScene = scene + originalActiveEntity = activeEntity + originalTool = EditorViewportSettings.shared.tool + scene = UntoldEngine.Scene() + activeEntity = .invalid + gizmoTargets = [] + EditorViewportSettings.shared.tool = .move + selectionManager = SelectionManager() + sceneGraphModel = SceneGraphModel() + } + + override func tearDown() { + selectionManager.clearSelection() + selectionManager = nil + sceneGraphModel = nil + gizmoTargets = [] + SelectionHighlights.shared.boxes = [] + EditorViewportSettings.shared.tool = originalTool + activeEntity = originalActiveEntity + bufferResources.boundingBoxBuffer = originalBoxBuffer + scene = originalScene + originalScene = nil + super.tearDown() + } + + private func makeBox(_ name: String, at position: simd_float3 = .zero) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entity)?.boundingBox = ( + min: simd_float3(repeating: -0.5), + max: simd_float3(repeating: 0.5) + ) + translateTo(entityId: entity, position: position) + return entity + } + + private func makeInspector() -> InspectorView { + InspectorView( + selectionManager: selectionManager, + sceneGraphModel: sceneGraphModel, + onAddName_Editor: {}, + selectedAsset: Binding(get: { self.selectedAsset }, set: { self.selectedAsset = $0 }) + ) + } + + // MARK: - A click on a row of the hierarchy + + func test_aClickOnARow_selectsItAlone() { + XCTAssertEqual(HierarchyRowClick.click(isPlaying: false, modifiers: []), .select) + XCTAssertEqual(HierarchyRowClick.click(isPlaying: false, modifiers: [.option]), .select) + } + + func test_aClickWithShiftOrCommand_addsTheRowOrTakesItOut() { + XCTAssertEqual(HierarchyRowClick.click(isPlaying: false, modifiers: [.shift]), .toggle) + XCTAssertEqual(HierarchyRowClick.click(isPlaying: false, modifiers: [.command]), .toggle) + XCTAssertEqual(HierarchyRowClick.click(isPlaying: false, modifiers: [.command, .shift]), .toggle) + } + + func test_fromPlayToStop_aClickOnARowDoesNothing() { + XCTAssertEqual(HierarchyRowClick.click(isPlaying: true, modifiers: []), .none) + XCTAssertEqual(HierarchyRowClick.click(isPlaying: true, modifiers: [.shift]), .none) + XCTAssertEqual(HierarchyRowClick.click(isPlaying: true, modifiers: [.command]), .none) + } + + // MARK: - The footer + + func test_theFooter_countsEverySelectedEntity() { + XCTAssertEqual(HierarchyFooterView.summary(entityCount: 12, selectedCount: 3), "12 entities · 3 selected") + } + + func test_theFooter_saysWhyNothingIsSelectedWhilePlaying() { + XCTAssertEqual( + HierarchyFooterView.summary(entityCount: 12, selectedCount: 3, isPlaying: true), + "12 entities · no selecting while playing" + ) + } + + // MARK: - The Inspector + + func test_theInspector_showsTheOneSelectedEntity() { + let box = makeBox("Box") + selectionManager.inspectEntity(entityId: box) + + XCTAssertEqual(makeInspector().inspectedEntity, box) + } + + func test_withSeveralSelected_theInspectorShowsNoneOfThem() { + selectionManager.selectEntities([makeBox("First"), makeBox("Second")]) + + XCTAssertNil(makeInspector().inspectedEntity) + XCTAssertEqual(InspectorSeveralSelectedView.title(count: 2), "2 entities selected") + } + + func test_thePinnedEntity_staysInTheInspector_withSeveralSelected() { + let pinned = makeBox("Pinned") + sceneGraphModel.refreshHierarchy() + selectionManager.togglePinnedInspection(pinned) + selectionManager.selectEntities([makeBox("First"), makeBox("Second")]) + + XCTAssertEqual(makeInspector().inspectedEntity, pinned) + } + + func test_backToOneEntity_theInspectorShowsIt() { + let first = makeBox("First"), second = makeBox("Second") + selectionManager.selectEntities([first, second]) + + selectionManager.toggleSelection(of: second) + + XCTAssertEqual(makeInspector().inspectedEntity, first) + } + + // MARK: - H + + func test_hidingTheSelection_hidesEveryOneOfThem() { + let first = makeBox("First"), second = makeBox("Second"), other = makeBox("Other") + selectionManager.selectEntities([first, second]) + + selectionManager.hideSelection() + + XCTAssertTrue(selectionManager.isHidden(first)) + XCTAssertTrue(selectionManager.isHidden(second)) + XCTAssertFalse(selectionManager.isHidden(other)) + XCTAssertEqual(scene.get(component: RenderComponent.self, for: first)?.isVisible, false) + XCTAssertEqual(scene.get(component: RenderComponent.self, for: second)?.isVisible, false) + XCTAssertEqual(scene.get(component: RenderComponent.self, for: other)?.isVisible, true) + XCTAssertEqual(selectionManager.selectedEntities, [first, second], "hidden, they stay selected") + XCTAssertFalse(gizmoActive, "and the gizmo leaves them") + XCTAssertTrue(SelectionHighlights.shared.boxes.isEmpty) + } + + func test_hidingTheSelection_ofOneEntity_hidesIt() { + let box = makeBox("Box") + selectionManager.inspectEntity(entityId: box) + + selectionManager.hideSelection() + + XCTAssertTrue(selectionManager.isHidden(box)) + XCTAssertEqual(selectionManager.selectedEntity, box) + XCTAssertFalse(gizmoActive) + } + + func test_hidingTheSelection_withNothingSelected_hidesNothing() { + let box = makeBox("Box") + + selectionManager.hideSelection() + + XCTAssertFalse(selectionManager.isHidden(box)) + XCTAssertTrue(selectionManager.hiddenEntities.isEmpty) + } + + func test_showingEverything_bringsTheGizmoBackToAllOfThem() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)), second = makeBox("Second", at: simd_float3(2, 0, 0)) + selectionManager.selectEntities([first, second]) + selectionManager.hideSelection() + + selectionManager.showAllEntities() + + XCTAssertTrue(gizmoActive) + XCTAssertEqual(gizmoTargets, [first, second]) + XCTAssertEqual(SelectionHighlights.shared.boxes.map(\.entityId), [first, second]) + } + + // MARK: - The hints + + func test_whereTheViewportSelects_selectingIsHinted() { + let hints = ViewportHints.hints(style: .classic, hasSelection: false, canSelect: true) + + XCTAssertEqual(hints.first, ViewportHints.select) + XCTAssertFalse(hints.contains(ViewportHints.addToSelection), "there is nothing to add to") + XCTAssertEqual(hints.map(\.action), ["Select", "Look", "Fly", "Zoom", "Pan", "Move", "Orbit"]) + } + + func test_withASelection_addingToItIsHinted() { + let hints = ViewportHints.hints(style: .classic, hasSelection: true, canSelect: true) + + XCTAssertEqual(Array(hints.prefix(3)), [ViewportHints.frameSelection, ViewportHints.select, ViewportHints.addToSelection]) + XCTAssertEqual(ViewportHints.addToSelection.keys, "⇧ Click") + XCTAssertEqual(ViewportHints.select.keys, "Click or drag") + } + + func test_whereTheViewportDoesNotSelect_asInExploreMode_selectingIsNotHinted() { + let hints = ViewportHints.hints(style: .classic, hasSelection: false) + + XCTAssertFalse(hints.contains(ViewportHints.select)) + XCTAssertFalse(hints.contains(ViewportHints.addToSelection)) + } +} diff --git a/Tests/UntoldEditorTests/SelectionSeveralTests.swift b/Tests/UntoldEditorTests/SelectionSeveralTests.swift new file mode 100644 index 0000000..a56d07a --- /dev/null +++ b/Tests/UntoldEditorTests/SelectionSeveralTests.swift @@ -0,0 +1,480 @@ +// +// SelectionSeveralTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import ModelIO +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// A selection of several entities: what is selected, which of them the +/// gizmo moves, where it stands and which boxes are drawn. +final class SelectionSeveralTests: XCTestCase { + private var originalScene: Scene! + private var originalActiveEntity: EntityID! + private var originalTool: TransformTool! + private var originalBoxBuffer: MTLBuffer? + private var selectionManager: SelectionManager! + + override func setUp() { + super.setUp() + guard let device = MTLCreateSystemDefaultDevice() else { + XCTFail("Metal device is not available.") + return + } + renderInfo.device = device + vertexDescriptor.model = MDLVertexDescriptor() + // One entity selected alone writes its box where the renderer keeps it. + originalBoxBuffer = bufferResources.boundingBoxBuffer + bufferResources.boundingBoxBuffer = device.makeBuffer( + length: MemoryLayout.stride * boundingBoxVertexCount, + options: .storageModeShared + ) + + originalScene = scene + originalActiveEntity = activeEntity + originalTool = EditorViewportSettings.shared.tool + scene = Scene() + activeEntity = .invalid + gizmoTargets = [] + EditorViewportSettings.shared.tool = .move + selectionManager = SelectionManager() + } + + override func tearDown() { + selectionManager.clearSelection() + selectionManager = nil + gizmoTargets = [] + SelectionHighlights.shared.boxes = [] + EditorViewportSettings.shared.tool = originalTool + activeEntity = originalActiveEntity + bufferResources.boundingBoxBuffer = originalBoxBuffer + scene = originalScene + originalScene = nil + super.tearDown() + } + + /// An entity that draws a box of `halfExtent` around its own origin. + private func makeBox(_ name: String, at position: simd_float3 = .zero, halfExtent: Float = 0.5) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + registerComponent(entityId: entity, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: entity)?.boundingBox = ( + min: simd_float3(repeating: -halfExtent), + max: simd_float3(repeating: halfExtent) + ) + translateTo(entityId: entity, position: position) + return entity + } + + /// An entity with a place in the scene that draws nothing, as a light. + private func makeEmpty(_ name: String, at position: simd_float3 = .zero) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + registerTransformComponent(entityId: entity) + registerSceneGraphComponent(entityId: entity) + translateTo(entityId: entity, position: position) + return entity + } + + private func assertNearlyEqual(_ lhs: simd_float3, _ rhs: simd_float3, accuracy: Float = 0.001, message: String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(lhs.x, rhs.x, accuracy: accuracy, message, file: file, line: line) + XCTAssertEqual(lhs.y, rhs.y, accuracy: accuracy, message, file: file, line: line) + XCTAssertEqual(lhs.z, rhs.z, accuracy: accuracy, message, file: file, line: line) + } + + // MARK: - One entity is a selection of one + + func test_anEntitySelectedAlone_isASelectionOfOne() { + let box = makeBox("Box") + + selectionManager.selectedEntity = box + + XCTAssertEqual(selectionManager.selectedEntities, [box]) + XCTAssertFalse(selectionManager.hasSeveralSelected) + XCTAssertTrue(selectionManager.isSelected(box)) + } + + func test_noEntitySelected_isASelectionOfNone() { + selectionManager.selectedEntity = makeBox("Box") + + selectionManager.selectedEntity = nil + XCTAssertEqual(selectionManager.selectedEntities, []) + + selectionManager.selectedEntity = .invalid + XCTAssertEqual(selectionManager.selectedEntities, []) + } + + // MARK: - Several + + func test_severalAreSelected_inTheirOrder_andTheLastIsTheOneNamed() { + let first = makeBox("First"), second = makeBox("Second"), third = makeBox("Third") + + selectionManager.selectEntities([first, second, third]) + + XCTAssertEqual(selectionManager.selectedEntities, [first, second, third]) + XCTAssertEqual(selectionManager.selectedEntity, third) + XCTAssertTrue(selectionManager.hasSeveralSelected) + XCTAssertNil(selectionManager.inspectedMesh) + } + + func test_anEntityCountsOnce_andNoneThatIsInvalid() { + let first = makeBox("First"), second = makeBox("Second") + + selectionManager.selectEntities([first, .invalid, second, first]) + + XCTAssertEqual(selectionManager.selectedEntities, [first, second]) + } + + func test_oneOfSeveral_isSelectedAsOneAlone() { + let box = makeBox("Box") + + selectionManager.selectEntities([box]) + + XCTAssertEqual(selectionManager.selectedEntities, [box]) + XCTAssertEqual(selectionManager.selectedEntity, box) + XCTAssertEqual(activeEntity, box) + XCTAssertTrue(gizmoTargets.isEmpty) + XCTAssertTrue(SelectionHighlights.shared.boxes.isEmpty, "one entity's box is drawn as it always was") + } + + func test_noneOfSeveral_clearsTheSelection() { + selectionManager.selectEntities([makeBox("First"), makeBox("Second")]) + + selectionManager.selectEntities([]) + + XCTAssertEqual(selectionManager.selectedEntities, []) + XCTAssertNil(selectionManager.selectedEntity) + XCTAssertEqual(activeEntity, .invalid) + XCTAssertTrue(gizmoTargets.isEmpty) + XCTAssertTrue(SelectionHighlights.shared.boxes.isEmpty) + } + + func test_selectingTheScene_orTheProject_leavesNoEntitySelected() { + selectionManager.selectEntities([makeBox("First"), makeBox("Second")]) + selectionManager.selectScene() + XCTAssertEqual(selectionManager.selectedEntities, []) + XCTAssertTrue(SelectionHighlights.shared.boxes.isEmpty) + + selectionManager.selectEntities([makeBox("Third"), makeBox("Fourth")]) + XCTAssertFalse(selectionManager.sceneSelected) + selectionManager.selectProject() + XCTAssertEqual(selectionManager.selectedEntities, []) + } + + // MARK: - Adding and taking out + + func test_toggling_addsAnEntity_whichBecomesTheOneNamed() { + let first = makeBox("First"), second = makeBox("Second") + selectionManager.inspectEntity(entityId: first) + + selectionManager.toggleSelection(of: second) + + XCTAssertEqual(selectionManager.selectedEntities, [first, second]) + XCTAssertEqual(selectionManager.selectedEntity, second) + } + + func test_toggling_takesASelectedEntityOut() { + let first = makeBox("First"), second = makeBox("Second"), third = makeBox("Third") + selectionManager.selectEntities([first, second, third]) + + selectionManager.toggleSelection(of: second) + XCTAssertEqual(selectionManager.selectedEntities, [first, third]) + XCTAssertEqual(selectionManager.selectedEntity, third) + + selectionManager.toggleSelection(of: third) + XCTAssertEqual(selectionManager.selectedEntities, [first], "one is left, selected as one alone") + XCTAssertTrue(gizmoTargets.isEmpty) + + selectionManager.toggleSelection(of: first) + XCTAssertEqual(selectionManager.selectedEntities, []) + XCTAssertEqual(activeEntity, .invalid) + } + + func test_toggling_withNothingSelected_selectsTheEntity() { + let box = makeBox("Box") + + selectionManager.toggleSelection(of: box) + + XCTAssertEqual(selectionManager.selectedEntities, [box]) + } + + func test_aPlainSelection_afterSeveral_selectsThatOneAlone() { + let first = makeBox("First"), second = makeBox("Second"), third = makeBox("Third") + selectionManager.selectEntities([first, second, third]) + + selectionManager.inspectEntity(entityId: second) + + XCTAssertEqual(selectionManager.selectedEntities, [second]) + XCTAssertEqual(activeEntity, second) + XCTAssertTrue(gizmoTargets.isEmpty) + XCTAssertTrue(SelectionHighlights.shared.boxes.isEmpty) + } + + // MARK: - Entities that leave the scene + + func test_anEntityThatLeftTheScene_leavesTheSelection() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + let third = makeBox("Third", at: simd_float3(6, 0, 0)) + selectionManager.selectEntities([first, second, third]) + + destroyEntity(entityId: third) + selectionManager.forgetEntitiesThatLeftTheScene() + + XCTAssertEqual(selectionManager.selectedEntities, [first, second]) + XCTAssertEqual(gizmoTargets, [first, second]) + XCTAssertEqual(SelectionHighlights.shared.boxes.map(\.entityId), [first, second]) + assertNearlyEqual(getPosition(entityId: parentEntityIdGizmo), .zero) + } + + func test_whenOneIsLeft_itIsSelectedAlone() { + let first = makeBox("First"), second = makeBox("Second") + selectionManager.selectEntities([first, second]) + + destroyEntity(entityId: second) + selectionManager.forgetEntitiesThatLeftTheScene() + + XCTAssertEqual(selectionManager.selectedEntities, [first]) + XCTAssertEqual(selectionManager.selectedEntity, first) + XCTAssertTrue(gizmoTargets.isEmpty) + } + + func test_whileAllAreInTheScene_theSelectionIsLeftAlone() { + let first = makeBox("First"), second = makeBox("Second") + selectionManager.selectEntities([first, second]) + let gizmo = parentEntityIdGizmo + + selectionManager.forgetEntitiesThatLeftTheScene() + + XCTAssertEqual(selectionManager.selectedEntities, [first, second]) + XCTAssertEqual(parentEntityIdGizmo, gizmo, "the gizmo is not made again") + } + + // MARK: - What the gizmo moves + + func test_theGizmoMoves_everySelectedEntityItMayGoOn() { + let first = makeBox("First"), second = makeBox("Second") + let empty = makeEmpty("Empty") + + selectionManager.selectEntities([first, empty, second]) + + XCTAssertEqual(selectionManager.transformTargets, [first, second], "what draws nothing takes no gizmo, alone or among several") + XCTAssertEqual(gizmoTargets, [first, second]) + XCTAssertEqual(activeEntity, second) + } + + func test_anEntityWithoutItsPlaceInTheWorld_takesNoGizmo() { + let first = makeBox("First"), second = makeBox("Second"), third = makeBox("Third") + scene.remove(component: WorldTransformComponent.self, from: second) + + selectionManager.selectEntities([first, second, third]) + + XCTAssertFalse(selectionManager.takesGizmo(second)) + XCTAssertEqual(selectionManager.transformTargets, [first, third]) + XCTAssertEqual(gizmoTargets, [first, third]) + } + + func test_anEntityUnderASelectedOne_movesWithItsParent() { + let parent = makeBox("Parent"), child = makeBox("Child"), other = makeBox("Other") + setParent(childId: child, parentId: parent) + + selectionManager.selectEntities([child, parent, other]) + + XCTAssertEqual(selectionManager.selectedEntities, [child, parent, other], "it is selected all the same") + XCTAssertEqual(selectionManager.transformTargets, [parent, other]) + } + + func test_aLockedOrHiddenEntity_isNotMoved() { + let first = makeBox("First"), locked = makeBox("Locked"), hidden = makeBox("Hidden"), last = makeBox("Last") + selectionManager.setLocked(locked, true) + selectionManager.setHidden(hidden, true) + + selectionManager.selectEntities([first, locked, hidden, last]) + + XCTAssertEqual(selectionManager.transformTargets, [first, last]) + } + + func test_theActiveEntity_isTheLastSelected_orTheLastTheGizmoMoves() { + let first = makeBox("First"), second = makeBox("Second") + let empty = makeEmpty("Empty") + + selectionManager.selectEntities([first, second]) + XCTAssertEqual(activeEntity, second) + + selectionManager.selectEntities([first, second, empty]) + XCTAssertEqual(selectionManager.selectedEntity, empty) + XCTAssertEqual(activeEntity, second) + } + + func test_severalThatCannotBeMoved_haveNoGizmo() { + let first = makeEmpty("First"), second = makeEmpty("Second") + + selectionManager.selectEntities([first, second]) + + XCTAssertEqual(selectionManager.selectedEntities, [first, second]) + XCTAssertEqual(activeEntity, .invalid) + XCTAssertEqual(parentEntityIdGizmo, .invalid) + XCTAssertFalse(gizmoActive) + } + + // MARK: - Where the gizmo stands + + func test_theGizmo_standsInTheMiddleOfAllOfThem() { + let first = makeBox("First", at: simd_float3(-4, 0, 0)) + let second = makeBox("Second", at: simd_float3(6, 2, 0), halfExtent: 1) + + selectionManager.selectEntities([first, second]) + + XCTAssertTrue(gizmoActive) + // The box around both runs from (-4.5, -0.5, -1) to (7, 3, 1). + assertNearlyEqual(getPosition(entityId: parentEntityIdGizmo), simd_float3(1.25, 1.25, 0)) + } + + func test_theSelectTool_showsNoGizmo_onSeveralEither() { + EditorViewportSettings.shared.tool = .select + let first = makeBox("First"), second = makeBox("Second") + + selectionManager.selectEntities([first, second]) + + XCTAssertFalse(gizmoActive) + XCTAssertEqual(parentEntityIdGizmo, .invalid) + XCTAssertEqual(gizmoTargets, [first, second]) + } + + func test_refreshingTheGizmo_keepsSeveralSelected() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)), second = makeBox("Second", at: simd_float3(2, 0, 0)) + selectionManager.selectEntities([first, second]) + + EditorViewportSettings.shared.tool = .rotate + selectionManager.refreshGizmo() + + XCTAssertEqual(selectionManager.selectedEntities, [first, second]) + XCTAssertEqual(gizmoTargets, [first, second]) + XCTAssertTrue(gizmoActive) + assertNearlyEqual(getPosition(entityId: parentEntityIdGizmo), .zero) + } + + func test_hidingOneOfSeveral_takesItFromTheGizmo_andLeavesItSelected() { + let first = makeBox("First", at: simd_float3(-2, 0, 0)) + let second = makeBox("Second", at: simd_float3(2, 0, 0)) + let third = makeBox("Third", at: simd_float3(10, 0, 0)) + selectionManager.selectEntities([first, second, third]) + + selectionManager.setHidden(third, true) + + XCTAssertEqual(selectionManager.selectedEntities, [first, second, third]) + XCTAssertEqual(gizmoTargets, [first, second]) + XCTAssertEqual(SelectionHighlights.shared.boxes.map(\.entityId), [first, second]) + assertNearlyEqual(getPosition(entityId: parentEntityIdGizmo), .zero) + } + + // MARK: - The boxes + + func test_everySelectedEntity_hasItsBox_inItsOwnSpace() throws { + let first = makeBox("First", at: simd_float3(5, 0, 0)), second = makeBox("Second", halfExtent: 2) + + selectionManager.selectEntities([first, second]) + + let boxes = SelectionHighlights.shared.boxes + XCTAssertEqual(boxes.map(\.entityId), [first, second]) + assertNearlyEqual(boxes[0].minimum, simd_float3(repeating: -0.5)) + assertNearlyEqual(boxes[0].maximum, simd_float3(repeating: 0.5)) + assertNearlyEqual(boxes[1].maximum, simd_float3(repeating: 2)) + + // Drawn, the first one's box is carried to where the entity stands. + let placement = try XCTUnwrap(boxes[0].placement()) + let corner = simd_mul(placement.model, simd_float4(placement.size * simd_float3(1, 1, 1), 1)) + assertNearlyEqual(simd_float3(corner.x, corner.y, corner.z), simd_float3(5.5, 0.5, 0.5)) + let origin = simd_mul(placement.model, simd_float4(0, 0, 0, 1)) + assertNearlyEqual(simd_float3(origin.x, origin.y, origin.z), simd_float3(4.5, -0.5, -0.5)) + } + + func test_aBox_holdsWhatIsUnderTheEntity() { + let parent = makeBox("Parent"), child = makeBox("Child"), other = makeBox("Other") + setParent(childId: child, parentId: parent) + translateTo(entityId: child, position: simd_float3(0, 3, 0)) + translateTo(entityId: parent, position: simd_float3(10, 0, 0)) + + selectionManager.selectEntities([parent, other]) + + let box = SelectionHighlights.shared.boxes[0] + assertNearlyEqual(box.minimum, simd_float3(-0.5, -0.5, -0.5)) + assertNearlyEqual(box.maximum, simd_float3(0.5, 3.5, 0.5)) + } + + func test_anEntityThatDrawsNothing_hasABoxWhereItStands() throws { + let box = makeBox("Box"), empty = makeEmpty("Empty", at: simd_float3(3, 4, 5)) + scaleTo(entityId: empty, scale: simd_float3(repeating: 7)) + + selectionManager.selectEntities([box, empty]) + + let boxes = SelectionHighlights.shared.boxes + XCTAssertEqual(boxes.map(\.isPoint), [false, true]) + let placement = try XCTUnwrap(boxes[1].placement()) + assertNearlyEqual(placement.size, simd_float3(repeating: SelectionHighlightBox.pointExtent), message: "whatever the entity's scale") + let origin = simd_mul(placement.model, simd_float4(0, 0, 0, 1)) + assertNearlyEqual(simd_float3(origin.x, origin.y, origin.z), simd_float3(2.75, 3.75, 4.75)) + } + + func test_aBoxFollowsItsEntity_withoutBeingTakenAgain() throws { + let first = makeBox("First"), second = makeBox("Second") + selectionManager.selectEntities([first, second]) + let box = SelectionHighlights.shared.boxes[0] + + translateTo(entityId: first, position: simd_float3(0, 8, 0)) + + let placement = try XCTUnwrap(box.placement()) + let origin = simd_mul(placement.model, simd_float4(0, 0, 0, 1)) + assertNearlyEqual(simd_float3(origin.x, origin.y, origin.z), simd_float3(-0.5, 7.5, -0.5)) + } + + func test_theUnitBox_isTwelveLines_betweenItsCorners() { + let lines = SelectionHighlights.unitBoxLines + XCTAssertEqual(lines.count, 24) + XCTAssertEqual(lines.count, boundingBoxVertexCount) + + var edges = Set() + for index in stride(from: 0, to: lines.count, by: 2) { + let from = lines[index], to = lines[index + 1] + XCTAssertEqual(simd_length(simd_float3(from.x, from.y, from.z) - simd_float3(to.x, to.y, to.z)), 1, accuracy: 0.0001) + XCTAssertEqual(from.w, 1) + let ends = ["\(from.x)\(from.y)\(from.z)", "\(to.x)\(to.y)\(to.z)"].sorted() + edges.insert(ends.joined(separator: "-")) + } + XCTAssertEqual(edges.count, 12, "every edge once") + } + + // MARK: - Framing + + func test_theBounds_areAroundAllOfThem() throws { + let first = makeBox("First", at: simd_float3(-4, 0, 0)) + let second = makeBox("Second", at: simd_float3(6, 2, 0), halfExtent: 1) + selectionManager.selectEntities([first, second]) + + let bounds = try XCTUnwrap(selectionManager.selectionBounds()) + + assertNearlyEqual(bounds.min, simd_float3(-4.5, -0.5, -1)) + assertNearlyEqual(bounds.max, simd_float3(7, 3, 1)) + } + + func test_framing_takesInWhatDrawsNothing() throws { + let box = makeBox("Box") + let empty = makeEmpty("Empty", at: simd_float3(0, 10, 0)) + selectionManager.selectEntities([box, empty]) + + let framing = try XCTUnwrap(selectionManager.selectionFramingBounds()) + + assertNearlyEqual(framing.min, simd_float3(-0.5, -0.5, -0.5)) + assertNearlyEqual(framing.max, simd_float3(0.5, 10.5, 0.5)) + } +} diff --git a/Tests/UntoldEditorTests/SelectionVisibilityPassTests.swift b/Tests/UntoldEditorTests/SelectionVisibilityPassTests.swift new file mode 100644 index 0000000..a19a18a --- /dev/null +++ b/Tests/UntoldEditorTests/SelectionVisibilityPassTests.swift @@ -0,0 +1,493 @@ +// +// SelectionVisibilityPassTests.swift +// UntoldEditor +// +// Copyright (C) Untold Engine Studios +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +import MetalKit +import ModelIO +import simd +@testable import UntoldEditor +@testable import UntoldEngine +import XCTest + +/// Which entities show in a rectangle of the viewport, told by the pass that +/// draws them: real meshes, drawn by the renderer's device and read back. +/// +/// The camera stands five units in front of the origin over a viewport of +/// 400 by 300 points and sees a quarter turn from top to bottom: a unit of +/// the world at the origin is 30 points, and the middle is (200, 150), +/// measured from the bottom left. +@MainActor +final class SelectionVisibilityPassTests: XCTestCase { + private var originalScene: Scene! + private var originalActiveCamera: EntityID? + private var originalIgnoresTransparents = false + private var window: NSWindow! + private var renderer: UntoldRenderer! + private var selectionManager: SelectionManager! + private let size = CGSize(width: 400, height: 300) + + override func setUp() { + super.setUp() + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + guard let created = UntoldRenderer.create() else { + XCTFail("Failed to initialize the renderer") + return + } + renderer = created + window.contentView = created.metalView + created.initResources() + + originalScene = scene + originalActiveCamera = CameraSystem.shared.activeCamera + originalIgnoresTransparents = isIgnoringRayIntersectionWithTransparents() + scene = Scene() + setIgnoreRayIntersectionWithTransparents(false) + selectionManager = SelectionManager() + } + + override func tearDown() { + selectionManager = nil + setIgnoreRayIntersectionWithTransparents(originalIgnoresTransparents) + gizmoTargets = [] + SelectionHighlights.shared.boxes = [] + CameraSystem.shared.activeCamera = originalActiveCamera + scene = originalScene + originalScene = nil + renderer = nil + window = nil + super.tearDown() + } + + // MARK: - Helpers + + /// A cube of `side` with real meshes, as the Entity menu makes one. + private func makeCube(_ name: String, at position: simd_float3, side: Float = 1) -> EntityID { + let entity = createEntity() + setEntityName(entityId: entity, name: name) + setEntityMeshDirect(entityId: entity, meshes: BasicPrimitives.createCube(extent: side), assetName: name) + translateTo(entityId: entity, position: position) + return entity + } + + private func view(eye: simd_float3 = simd_float3(0, 0, 5), target: simd_float3 = .zero, reverseZ: Bool = false) -> MarqueeGeometry.View { + let camera = createEntity() + registerComponent(entityId: camera, componentType: CameraComponent.self) + cameraLookAt(entityId: camera, eye: eye, target: target, up: simd_float3(0, 1, 0)) + let aspect = Float(size.width / size.height) + return MarqueeGeometry.View( + viewSpace: scene.get(component: CameraComponent.self, for: camera)?.viewSpace ?? matrix_identity_float4x4, + perspectiveSpace: reverseZ + ? matrixPerspectiveRightHandReverseZ(fovyRadians: .pi / 2, aspectRatio: aspect, nearZ: 0.1, farZ: 100) + : matrixPerspectiveRightHand(fovyRadians: .pi / 2, aspectRatio: aspect, nearZ: 0.1, farZ: 100), + size: size + ) + } + + private func seen(in rect: CGRect, among drawn: [EntityID], view: MarqueeGeometry.View? = nil, scale: CGFloat = 2) -> Set? { + SelectionVisibilityPass.entitiesSeen(in: rect, view: view ?? self.view(), scale: scale, drawn: drawn) + } + + /// What the rectangle selects, with the pass telling what is seen. + private func selected(by rect: CGRect, view: MarqueeGeometry.View? = nil) -> [EntityID] { + MarqueeSelection.entities( + inside: rect, + view: view ?? self.view(), + selectionManager: selectionManager, + seen: { rect, view, drawn in + SelectionVisibilityPass.entitiesSeen(in: rect, view: view, scale: 2, drawn: drawn) + } + ) + } + + /// A wall two units each way just in front of the origin, and a small + /// cube well behind it, wholly covered by it. + private func makeWallAndWhatIsBehind() -> (wall: EntityID, behind: EntityID) { + (makeCube("Wall", at: simd_float3(0, 0, 1), side: 2), makeCube("Behind", at: simd_float3(0, 0, -3), side: 0.5)) + } + + private let middle = CGRect(x: 150, y: 100, width: 100, height: 100) + + /// A rectangle that holds the wall, which reaches 50 points from the + /// middle, with ten points to spare. + private let aroundTheWall = CGRect(x: 140, y: 90, width: 120, height: 120) + + // MARK: - What is seen + + func test_anEntityIsSeen_inARectangleOverIt_andNotInOneBesideIt() { + let cube = makeCube("Cube", at: .zero) + + XCTAssertEqual(seen(in: CGRect(x: 190, y: 140, width: 20, height: 20), among: [cube]), [cube]) + XCTAssertEqual(seen(in: CGRect(x: 250, y: 140, width: 20, height: 20), among: [cube]), []) + } + + func test_whatStandsBehindAnother_isNotSeen() { + let (wall, behind) = makeWallAndWhatIsBehind() + + XCTAssertEqual(seen(in: middle, among: [wall, behind]), [wall]) + XCTAssertEqual(seen(in: middle, among: [behind, wall]), [wall], "whichever is drawn first") + } + + func test_alone_whatStoodBehindIsSeen() { + let (_, behind) = makeWallAndWhatIsBehind() + + XCTAssertEqual(seen(in: middle, among: [behind]), [behind]) + } + + func test_whatShowsBesideAnother_isSeenThere_andNotWhereItIsCovered() { + let wall = makeCube("Wall", at: simd_float3(0, 0, 1), side: 2) + // Three units to the right and behind: it shows from 250 to 270 + // points, and from 244 to 250 it is behind the wall, which ends at 250. + let beside = makeCube("Beside", at: simd_float3(3, 0, -3)) + + XCTAssertEqual(seen(in: CGRect(x: 255, y: 140, width: 10, height: 20), among: [wall, beside]), [beside]) + XCTAssertEqual(seen(in: CGRect(x: 245, y: 145, width: 4, height: 10), among: [wall, beside]), [wall]) + XCTAssertEqual(seen(in: CGRect(x: 230, y: 140, width: 40, height: 20), among: [wall, beside]), [wall, beside]) + } + + func test_eachEntity_isToldApart() { + let left = makeCube("Left", at: simd_float3(-3, 0, 0)) + let middleCube = makeCube("Middle", at: .zero) + let right = makeCube("Right", at: simd_float3(3, 0, 0)) + let all = [left, middleCube, right] + + XCTAssertEqual(seen(in: CGRect(x: 100, y: 140, width: 20, height: 20), among: all), [left]) + XCTAssertEqual(seen(in: CGRect(x: 190, y: 140, width: 20, height: 20), among: all), [middleCube]) + XCTAssertEqual(seen(in: CGRect(x: 280, y: 140, width: 20, height: 20), among: all), [right]) + XCTAssertEqual(seen(in: CGRect(x: 0, y: 0, width: 400, height: 300), among: all), [left, middleCube, right]) + } + + func test_anEntityIsSeenWhereItWasMovedTurnedAndScaledTo() { + let cube = makeCube("Cube", at: simd_float3(2, 1, 0)) + rotateTo(entityId: cube, rotation: simd_quatf(angle: .pi / 4, axis: simd_float3(0, 1, 0))) + scaleTo(entityId: cube, scale: simd_float3(1, 2, 1)) + + // Two units right and one up: 60 points right of the middle, 30 above it. + XCTAssertEqual(seen(in: CGRect(x: 255, y: 175, width: 10, height: 10), among: [cube]), [cube]) + XCTAssertEqual(seen(in: CGRect(x: 258, y: 205, width: 4, height: 4), among: [cube]), [cube], "twice as tall") + XCTAssertEqual(seen(in: CGRect(x: 195, y: 145, width: 10, height: 10), among: [cube]), []) + } + + func test_upInTheWorld_isUpInTheRectanglesMeasure() { + let cube = makeCube("Cube", at: simd_float3(0, 3, 0)) + + XCTAssertEqual(seen(in: CGRect(x: 190, y: 230, width: 20, height: 20), among: [cube]), [cube]) + XCTAssertEqual(seen(in: CGRect(x: 190, y: 50, width: 20, height: 20), among: [cube]), []) + } + + func test_itDoesNotMatterHowTheProjectionKeepsDepth() { + let (wall, behind) = makeWallAndWhatIsBehind() + + XCTAssertEqual(seen(in: middle, among: [wall, behind], view: view(reverseZ: true)), [wall]) + XCTAssertEqual(seen(in: middle, among: [behind, wall], view: view(reverseZ: true)), [wall]) + } + + func test_fromTheOtherSide_theOtherOneIsSeen() { + let (wall, behind) = makeWallAndWhatIsBehind() + let fromBehind = view(eye: simd_float3(0, 0, -8)) + + // The small cube is now in front of the wall, and both show. + XCTAssertEqual(seen(in: CGRect(x: 195, y: 145, width: 10, height: 10), among: [wall, behind], view: fromBehind), [behind]) + XCTAssertEqual(seen(in: middle, among: [wall, behind], view: fromBehind), [wall, behind]) + } + + // MARK: - The rectangle + + func test_aRectangleWithoutASize_seesWhatIsUnderIt() { + let (wall, behind) = makeWallAndWhatIsBehind() + + XCTAssertEqual(seen(in: CGRect(x: 200, y: 150, width: 0, height: 0), among: [wall, behind]), [wall]) + XCTAssertEqual(seen(in: CGRect(x: 100, y: 150, width: 200, height: 0), among: [wall, behind]), [wall], "a drag straight across") + } + + func test_aRectangleThatReachesBeyondTheViewport_isCutToIt() { + let cube = makeCube("Cube", at: .zero) + + XCTAssertEqual(seen(in: CGRect(x: -500, y: -500, width: 1000, height: 1000), among: [cube]), [cube]) + } + + func test_aRectangleOutsideTheViewport_cannotBeTold() { + let cube = makeCube("Cube", at: .zero) + + XCTAssertNil(seen(in: CGRect(x: 500, y: 400, width: 50, height: 50), among: [cube])) + } + + func test_withNothingDrawn_nothingIsSeen() { + XCTAssertEqual(seen(in: middle, among: []), []) + } + + func test_aCoarserScale_seesTheSame() { + let (wall, behind) = makeWallAndWhatIsBehind() + + XCTAssertEqual(seen(in: middle, among: [wall, behind], scale: 1), [wall]) + XCTAssertEqual(seen(in: middle, among: [wall, behind], scale: 0.5), [wall]) + } + + // MARK: - The pixels of the rectangle + + func test_thePixels_areCountedFromTheTopOfTheViewport() throws { + let region = try XCTUnwrap(SelectionVisibilityPass.PixelRegion( + rect: CGRect(x: 10, y: 20, width: 30, height: 40), + viewSize: size, + scale: 2 + )) + + XCTAssertEqual(region.x, 20) + XCTAssertEqual(region.y, 480, "from 60 up to 20 points above the bottom is 240 to 280 below the top") + XCTAssertEqual(region.width, 60) + XCTAssertEqual(region.height, 80) + XCTAssertEqual(region.viewWidth, 800) + XCTAssertEqual(region.viewHeight, 600) + } + + func test_thePixels_stayInTheViewport_andAreNeverNone() throws { + let beyond = try XCTUnwrap(SelectionVisibilityPass.PixelRegion(rect: CGRect(x: -50, y: -50, width: 1000, height: 1000), viewSize: size, scale: 1)) + XCTAssertEqual(beyond, try XCTUnwrap(SelectionVisibilityPass.PixelRegion(rect: CGRect(origin: .zero, size: size), viewSize: size, scale: 1))) + + let point = try XCTUnwrap(SelectionVisibilityPass.PixelRegion(rect: CGRect(x: 400, y: 0, width: 0, height: 0), viewSize: size, scale: 1)) + XCTAssertEqual(point.width, 1) + XCTAssertEqual(point.height, 1) + XCTAssertEqual(point.x, 399) + XCTAssertEqual(point.y, 299) + + XCTAssertNil(SelectionVisibilityPass.PixelRegion(rect: CGRect(x: 500, y: 0, width: 10, height: 10), viewSize: size, scale: 1)) + XCTAssertNil(SelectionVisibilityPass.PixelRegion(rect: CGRect(x: 0, y: 0, width: 10, height: 10), viewSize: .zero, scale: 1)) + XCTAssertNil(SelectionVisibilityPass.PixelRegion(rect: CGRect(x: 0, y: 0, width: 10, height: 10), viewSize: size, scale: 0)) + } + + func test_aRectangleOfVeryManyPixels_isDrawnCoarser() throws { + let large = CGSize(width: 4000, height: 3000) + let region = try XCTUnwrap(SelectionVisibilityPass.PixelRegion(rect: CGRect(origin: .zero, size: large), viewSize: large, scale: 2)) + + XCTAssertLessThanOrEqual(CGFloat(region.width * region.height), SelectionVisibilityPass.maximumPixels * 1.01) + XCTAssertEqual(Double(region.width) / Double(region.height), 4.0 / 3.0, accuracy: 0.01) + XCTAssertEqual(region.viewWidth, region.width) + } + + func test_theUniforms_areLaidOutAsTheShaderReadsThem() { + XCTAssertEqual(MemoryLayout.stride, 80) + XCTAssertEqual(MemoryLayout.offset(of: \.identifier), 64) + } + + // MARK: - What the rectangle selects + + func test_theRectangle_selectsWhatIsSeen_andLeavesOutWhatIsHidden() { + let (wall, behind) = makeWallAndWhatIsBehind() + let beside = makeCube("Beside", at: simd_float3(3, 0, -3)) + + XCTAssertEqual(selected(by: aroundTheWall), [wall]) + XCTAssertEqual(Set(selected(by: CGRect(x: 0, y: 0, width: 400, height: 300))), [wall, beside]) + XCTAssertFalse(selected(by: CGRect(x: 0, y: 0, width: 400, height: 300)).contains(behind)) + } + + func test_whatIsSeenInTheRectangle_butReachesOutOfIt_isLeftOut() { + let crate = makeCube("Crate", at: .zero) + // A floor six units each way under the crate: it shows in the + // rectangle, below the crate, and reaches far out of it. + let floor = makeCube("Floor", at: simd_float3(0, -3.5, 0), side: 6) + let around = CGRect(x: 170, y: 120, width: 60, height: 60) + + XCTAssertEqual(seen(in: around, among: [crate, floor]), [crate, floor], "both show in it") + XCTAssertEqual(selected(by: around), [crate], "only the crate is inside it") + } + + func test_aRectangleOnTheWall_selectsNeitherTheWallNorWhatItHides() { + _ = makeWallAndWhatIsBehind() + + XCTAssertEqual(selected(by: CGRect(x: 180, y: 130, width: 40, height: 40)), [], "the wall reaches out of it, and what is inside is hidden") + } + + func test_withoutThePass_whatIsInsideIsSelected_seenOrNot() { + let (wall, behind) = makeWallAndWhatIsBehind() + + let byBoxes = MarqueeSelection.entities(inside: aroundTheWall, view: view(), selectionManager: selectionManager) + + XCTAssertEqual(Set(byBoxes), [wall, behind]) + } + + func test_whenThePassCannotTell_whatIsInsideIsSelected_seenOrNot() { + let (wall, behind) = makeWallAndWhatIsBehind() + + let byBoxes = MarqueeSelection.entities(inside: aroundTheWall, view: view(), selectionManager: selectionManager, seen: { _, _, _ in nil }) + + XCTAssertEqual(Set(byBoxes), [wall, behind]) + } + + func test_thePassIsNotAsked_whenNothingItCanTellAboutIsInside() { + _ = makeWallAndWhatIsBehind() + var asked = 0 + + let chosen = MarqueeSelection.entities( + inside: CGRect(x: 10, y: 10, width: 40, height: 40), + view: view(), + selectionManager: selectionManager, + seen: { _, _, _ in + asked += 1 + return [] + } + ) + + XCTAssertEqual(chosen, []) + XCTAssertEqual(asked, 0, "a rectangle over nothing costs no pass") + } + + func test_thePassIsHanded_onlyWhatMayShowInTheRectangle() { + let (wall, behind) = makeWallAndWhatIsBehind() + // A floor that shows in the rectangle and reaches far out of it. + let floor = makeCube("Floor", at: simd_float3(0, -3.5, 0), side: 6) + _ = makeCube("Beside", at: simd_float3(6, 0, -3)) + _ = makeCube("Behind the camera", at: simd_float3(0, 0, 9)) + var handed: [EntityID] = [] + + _ = MarqueeSelection.entities( + inside: aroundTheWall, + view: view(), + selectionManager: selectionManager, + seen: { _, _, drawn in + handed = drawn + return Set(drawn) + } + ) + + XCTAssertEqual(Set(handed), [wall, behind, floor], "what stands beside the rectangle or behind the camera is not drawn for it") + } + + func test_whatCannotBeSelected_isHandedToThePassAllTheSame() { + let (wall, behind) = makeWallAndWhatIsBehind() + selectionManager.setLocked(wall, true) + var handed: [EntityID] = [] + + _ = MarqueeSelection.entities( + inside: aroundTheWall, + view: view(), + selectionManager: selectionManager, + seen: { _, _, drawn in + handed = drawn + return Set(drawn) + } + ) + + XCTAssertEqual(Set(handed), [wall, behind], "a locked wall still hides what is behind it") + } + + /// Leaving out of the pass what stands outside the rectangle changes + /// nothing of what it tells: many cubes, some hiding others, and + /// rectangles of several sizes. + func test_drawingOnlyWhatMayShow_selectsTheSameAsDrawingEverything() { + var generator = SeededGenerator(state: 11) + // One cube of a unit, drawn by every entity at its own size: small + // ones for the rectangles to hold, and a large one now and then that + // reaches out of them and hides what is behind it. + let unitCube = BasicPrimitives.createCube(extent: 1) + var cubes: [EntityID] = [] + for index in 0 ..< 120 { + let cube = createEntity() + setEntityMeshDirect(entityId: cube, meshes: unitCube, assetName: "Cube \(index)") + let side = index % 8 == 0 ? generator.next(in: 2.5 ... 5) : generator.next(in: 0.2 ... 1.2) + scaleTo(entityId: cube, scale: simd_float3(repeating: side)) + translateTo( + entityId: cube, + position: simd_float3(generator.next(in: -7 ... 7), generator.next(in: -5 ... 5), generator.next(in: -12 ... 3)) + ) + cubes.append(cube) + } + let view = view() + let rects = [ + CGRect(x: 0, y: 0, width: 400, height: 300), + CGRect(x: 100, y: 60, width: 200, height: 180), + CGRect(x: 20, y: 150, width: 160, height: 120), + CGRect(x: 230, y: 20, width: 150, height: 130), + CGRect(x: 180, y: 130, width: 40, height: 40), + ] + + var chosen = 0 + var hidden = 0 + var leftOut = 0 + for rect in rects { + let byBoxes = MarqueeSelection.entities(inside: rect, view: view, selectionManager: selectionManager) + let shown = SelectionVisibilityPass.entitiesSeen(in: rect, view: view, scale: 2, drawn: cubes) ?? [] + let withEverythingDrawn = byBoxes.filter(shown.contains) + + let withWhatMayShowDrawn = MarqueeSelection.entities( + inside: rect, + view: view, + selectionManager: selectionManager, + seen: { rect, view, drawn in + leftOut += cubes.count - drawn.count + return SelectionVisibilityPass.entitiesSeen(in: rect, view: view, scale: 2, drawn: drawn) + } + ) + + XCTAssertEqual(withWhatMayShowDrawn, withEverythingDrawn, "in \(rect)") + chosen += withEverythingDrawn.count + hidden += byBoxes.count - withEverythingDrawn.count + } + + // Enough is selected, hidden and left out for the comparison to mean something. + XCTAssertGreaterThan(chosen, 20) + XCTAssertGreaterThan(hidden, 5) + XCTAssertGreaterThan(leftOut, 100) + } + + func test_theNearestOfThoseSeen_comesLast() { + let far = makeCube("Far", at: simd_float3(-3, 0, -4)) + let near = makeCube("Near", at: simd_float3(2, 0, 2)) + let between = makeCube("Between", at: simd_float3(0, 0, -1)) + + XCTAssertEqual(selected(by: CGRect(x: 0, y: 0, width: 400, height: 300)), [far, between, near]) + } + + func test_aLockedEntity_hidesWhatIsBehindIt_andIsNotSelected() { + let (wall, _) = makeWallAndWhatIsBehind() + selectionManager.setLocked(wall, true) + + XCTAssertEqual(selected(by: aroundTheWall), []) + } + + func test_aHiddenEntity_hidesNothing() { + let (wall, behind) = makeWallAndWhatIsBehind() + selectionManager.setHidden(wall, true) + + XCTAssertEqual(selected(by: aroundTheWall), [behind]) + } + + func test_whatAClickPassesThrough_theRectangleSeesThrough() throws { + let (wall, behind) = makeWallAndWhatIsBehind() + let render = try XCTUnwrap(scene.get(component: RenderComponent.self, for: wall)) + for mesh in render.mesh.indices { + for part in render.mesh[mesh].submeshes.indices { + render.mesh[mesh].submeshes[part].material?.alphaMode = .blend + } + } + XCTAssertEqual(selected(by: aroundTheWall), [wall], "glass is seen, and hides what is behind it") + + setIgnoreRayIntersectionWithTransparents(true) + + XCTAssertEqual(selected(by: aroundTheWall), [behind]) + } + + func test_anEntityWithoutMeshes_isSelectedByItsBoxAlone() { + let (wall, _) = makeWallAndWhatIsBehind() + let placeholder = createEntity() + registerTransformComponent(entityId: placeholder) + registerSceneGraphComponent(entityId: placeholder) + registerComponent(entityId: placeholder, componentType: RenderComponent.self) + scene.get(component: LocalTransformComponent.self, for: placeholder)?.boundingBox = ( + min: simd_float3(repeating: -0.5), max: simd_float3(repeating: 0.5) + ) + translateTo(entityId: placeholder, position: simd_float3(0, 0, -3)) + + XCTAssertFalse(MarqueeSelection.drawsMeshes(placeholder)) + XCTAssertEqual(Set(selected(by: aroundTheWall)), [wall, placeholder]) + } +} diff --git a/Tests/UntoldEditorTests/ViewportCanvasEventTests.swift b/Tests/UntoldEditorTests/ViewportCanvasEventTests.swift index 74582d1..85da7bd 100644 --- a/Tests/UntoldEditorTests/ViewportCanvasEventTests.swift +++ b/Tests/UntoldEditorTests/ViewportCanvasEventTests.swift @@ -271,20 +271,21 @@ final class ViewportCanvasEventTests: XCTestCase { XCTAssertFalse(InputSystem.shared.keyState.leftMousePressed) } - func test_leftDrag_isNotAClick_andLeavesTheCameraAlone() throws { + func test_leftDrag_drawsTheRectangle_andLeavesTheCameraAlone() throws { editorController = EditorController(selectionManager: SelectionManager()) editorController?.isEnabled = true selectionDelegate = recorder - let selected = createEntity() - activeEntity = selected + activeEntity = createEntity() let before = position, targetBefore = target try canvas.mouseDown(with: mouse(.leftMouseDown, at: NSPoint(x: 200, y: 150))) - try canvas.mouseDragged(with: mouse(.leftMouseDragged, at: NSPoint(x: 240, y: 150), step: (40, 0))) - try canvas.mouseUp(with: mouse(.leftMouseUp, at: NSPoint(x: 240, y: 150))) + try canvas.mouseDragged(with: mouse(.leftMouseDragged, at: NSPoint(x: 240, y: 120), step: (40, 30))) + XCTAssertEqual(ViewportMarqueeStore.shared.rect, CGRect(x: 200, y: 150, width: 40, height: 30), "measured from the top of the canvas") + try canvas.mouseUp(with: mouse(.leftMouseUp, at: NSPoint(x: 240, y: 120))) - XCTAssertEqual(recorder.clearCount, 0, "a drag selects nothing and clears nothing") - XCTAssertEqual(activeEntity, selected) + XCTAssertNil(ViewportMarqueeStore.shared.rect) + XCTAssertEqual(recorder.clearCount, 1, "the rectangle touched nothing, as a click on empty space") + XCTAssertEqual(activeEntity, .invalid) XCTAssertEqual(simd_length(position - before), 0, accuracy: 1e-6) XCTAssertEqual(simd_length(target - targetBefore), 0, accuracy: 1e-6) } diff --git a/Tests/UntoldEditorTests/ViewportOverlayHitTestTests.swift b/Tests/UntoldEditorTests/ViewportOverlayHitTestTests.swift index 824ee63..3c8f95f 100644 --- a/Tests/UntoldEditorTests/ViewportOverlayHitTestTests.swift +++ b/Tests/UntoldEditorTests/ViewportOverlayHitTestTests.swift @@ -71,7 +71,7 @@ final class ViewportOverlayHitTestTests: XCTestCase { } /// Puts the canvas with the overlays over it in a window, laid out. - private func show(showsEditorOverlays: Bool = true) { + private func show(showsEditorOverlays: Bool = true, marquee: ViewportMarqueeStore = ViewportMarqueeStore()) { let content = CanvasProbe(canvas: canvas) .overlay { ViewportOverlaysView( @@ -82,7 +82,8 @@ final class ViewportOverlayHitTestTests: XCTestCase { hasSelection: true, onSelectView: { _ in }, overlays: overlays, - store: store + store: store, + marquee: marquee ) } .frame(width: size.width, height: size.height) @@ -128,6 +129,17 @@ final class ViewportOverlayHitTestTests: XCTestCase { XCTAssertTrue(reachesTheCanvas(x: size.width / 2, y: size.height / 2)) } + func test_theRectangleBeingDragged_takesNoClickFromTheCanvas() { + let marquee = ViewportMarqueeStore() + marquee.show(CGRect(x: 200, y: 100, width: 300, height: 200)) + + show(marquee: marquee) + + XCTAssertEqual(marquee.rect, CGRect(x: 200, y: 100, width: 300, height: 200)) + XCTAssertTrue(reachesTheCanvas(x: 350, y: 200), "inside the rectangle") + XCTAssertTrue(reachesTheCanvas(x: 200, y: 200), "on its border") + } + func test_aClickOnTheBadgeOrTheStatistics_reachesTheCanvas() { show() diff --git a/create_app_bundle.sh b/create_app_bundle.sh index 013160f..d7feda8 100755 --- a/create_app_bundle.sh +++ b/create_app_bundle.sh @@ -16,6 +16,7 @@ APP_NAME="Untold Engine Studio" EXECUTABLE_NAME="UntoldEditor" BUNDLE_ID="com.untoldengine.studio" SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application: UNTOLD ENGINE STUDIOS LLC (PXXZLXYJ26)}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Determine version (env -> release/* branch -> latest tag vX.Y.Z -> fallback) detect_version() { @@ -100,22 +101,33 @@ flatten_resource_bundle "$BUILD_DIR/UntoldEditor_UntoldEditor.bundle" "UntoldEdi # compiled against exactly these modules, with exactly this compiler (recorded in sdk.json). SDK_DIR="$APP_BUNDLE/Contents/Resources/ComponentSDK" echo "🧩 Packaging the Component SDK..." -mkdir -p "$SDK_DIR/Modules" "$SDK_DIR/CShaderTypes" +mkdir -p "$SDK_DIR/Modules" if [ -d "$BUILD_DIR/Modules" ]; then MODULES_SRC="$BUILD_DIR/Modules" else MODULES_SRC="$BUILD_DIR" fi +# The engine is built without library evolution, so a component that imports UntoldEngine makes +# the compiler load every module the engine imports. So ship every Swift module the editor was +# built with: the engine, the kit, a plugin the editor links, and any module of another package +# that one of them imports. PROVIDED_MODULES=() -for module in UntoldEngine UntoldComponentKit UntoldGaussianTwins; do - if [ -e "$MODULES_SRC/$module.swiftmodule" ]; then - # A file in the classic layout, a per-architecture directory in the newer one. - cp -R "$MODULES_SRC/$module.swiftmodule" "$SDK_DIR/Modules/" - if [ -f "$MODULES_SRC/$module.swiftdoc" ]; then - cp "$MODULES_SRC/$module.swiftdoc" "$SDK_DIR/Modules/" - fi - PROVIDED_MODULES+=("$module") - else +for module_path in "$MODULES_SRC"/*.swiftmodule; do + [ -e "$module_path" ] || continue + module="$(basename "$module_path" .swiftmodule)" + # Not the editor itself, nor a test module an earlier `swift build --build-tests` left. + case "$module" in + "$EXECUTABLE_NAME" | *Tests) continue ;; + esac + # A file in the classic layout, a per-architecture directory in the newer one. + cp -R "$module_path" "$SDK_DIR/Modules/" + if [ -f "$MODULES_SRC/$module.swiftdoc" ]; then + cp "$MODULES_SRC/$module.swiftdoc" "$SDK_DIR/Modules/" + fi + PROVIDED_MODULES+=("$module") +done +for module in UntoldEngine UntoldComponentKit; do + if [ ! -e "$SDK_DIR/Modules/$module.swiftmodule" ]; then echo "⚠️ Warning: $module.swiftmodule not found in $MODULES_SRC — code components will not compile in this build" fi done @@ -123,25 +135,20 @@ done find "$SDK_DIR/Modules" \( -name '*.swiftsourceinfo' -o -name '*.abi.json' \) -delete find "$SDK_DIR/Modules" -type d -empty -delete -# UntoldEngine imports the CShaderTypes clang module. SwiftPM's generated module map points -# at the checkout with an absolute path, so ship the headers with a relocatable one. -CSHADER_SRC=".build/checkouts/UntoldEngine/Sources/CShaderTypes" -if [ -d "$CSHADER_SRC" ]; then - cp "$CSHADER_SRC"/*.h "$SDK_DIR/CShaderTypes/" - cat > "$SDK_DIR/CShaderTypes/module.modulemap" << 'MODULEMAP' -module CShaderTypes { - umbrella "." - export * -} -MODULEMAP -else - echo "⚠️ Warning: CShaderTypes headers not found at $CSHADER_SRC" -fi +# The same goes for C modules: CShaderTypes, and any other C target the engine or a package it +# depends on was built with. Each is shipped as its headers with a relocatable module map. +C_MODULES_LIST="$(python3 "$SCRIPT_DIR/scripts/copy-component-sdk-c-modules.py" "$BUILD_DIR" "$SDK_DIR")" +C_MODULES=() +while IFS= read -r module; do + if [ -n "$module" ]; then + C_MODULES+=("$module") + fi +done <<< "$C_MODULES_LIST" SWIFT_COMPILER_VERSION="$(swift --version 2>&1 | head -1)" -python3 - "$SDK_DIR/sdk.json" "$SWIFT_COMPILER_VERSION" "${PROVIDED_MODULES[@]}" << 'SDKJSON' +python3 - "$SDK_DIR/sdk.json" "$SWIFT_COMPILER_VERSION" "${PROVIDED_MODULES[*]}" "${C_MODULES[*]}" << 'SDKJSON' import json, sys -path, compiler, *modules = sys.argv[1:] +path, compiler, modules, c_modules = sys.argv[1:] engine_url = revision = None try: pins = json.load(open("Package.resolved"))["pins"] @@ -156,7 +163,8 @@ with open(path, "w") as handle: "engineRevision": revision, "target": "arm64-apple-macosx14.0", "languageMode": "5", - "providedModules": sorted(modules), + "providedModules": sorted(modules.split()), + "cModules": c_modules.split(), }, handle, indent=2) handle.write("\n") SDKJSON @@ -170,7 +178,6 @@ fi # Copy exporter scripts. SPM only checks the dependency out under .build/checkouts/ for a # git-URL dependency; while Package.swift points at a local path (e.g. during engine+editor # co-development), fall back to that sibling checkout directly. -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPTS_SRC=".build/checkouts/UntoldEngine/scripts" if [ ! -d "$SCRIPTS_SRC" ]; then SCRIPTS_SRC="$SCRIPT_DIR/../UntoldEngine/scripts" diff --git a/scripts/copy-component-sdk-c-modules.py b/scripts/copy-component-sdk-c-modules.py new file mode 100755 index 0000000..4f2b9ac --- /dev/null +++ b/scripts/copy-component-sdk-c-modules.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# +# copy-component-sdk-c-modules.py +# +# Copies into the Component SDK every C module the editor was built with, and prints their +# names, CShaderTypes first. Each becomes `//`: the module's public +# headers and a module.modulemap that names them by relative path, so the folder can move with +# the app. create_app_bundle.sh writes the names into sdk.json, and the editor passes one +# -fmodule-map-file per name. +# +# Why this exists: the engine is built without library evolution, so a component that imports +# UntoldEngine makes the compiler load every module the engine imports. For a Swift module that +# is a file in Modules/. A C module needs its headers and a module map, and neither is in the +# build folder: the module map the build system generates points at the package with an +# absolute path, and a C target that brings its own module map keeps it in its package. +# +# Which modules: the same rule as ComponentSDKBuildLayout.swift, which does this for an editor +# run from source. A C target counts when it left objects in the build, because SwiftPM's native +# build system also writes a module map for a target the package declares and the editor does +# not use. + +import json +import os +import re +import shutil +import sys + +C_SHADER_TYPES = "CShaderTypes" + +# A public headers folder can be the target's own folder (CShaderTypes), sources included. +SOURCE_SUFFIXES = (".c", ".cc", ".cpp", ".cxx", ".m", ".mm", ".s", ".S", ".swift", ".o", ".d") + +# A comment, a string, a word or a brace. Comments and strings are matched so that what is +# inside them is not read as a word or a brace. +TOKEN = re.compile(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_]*|[{}]') + + +def warn(message): + print(f"⚠️ Warning: {message}", file=sys.stderr) + + +class BuildLayout: + """SwiftPM has two layouts, and Xcode's matches the newer one.""" + + def __init__(self, products): + self.products = products + generated = os.path.normpath(os.path.join(products, "../../Intermediates.noindex/GeneratedModuleMaps")) + # Swift Build: `.o` beside the products, generated module maps together. + self.swift_build = os.path.isfile(os.path.join(generated, f"{C_SHADER_TYPES}.modulemap")) + self.generated = generated + + def generated_module_map(self, target): + if self.swift_build: + return os.path.join(self.generated, f"{target}.modulemap") + # SwiftPM's native build system: a `.build` folder per target. + return os.path.join(self.products, f"{target}.build", "module.modulemap") + + def swift_modules(self): + modules = os.path.join(self.products, "Modules") + folder = modules if os.path.isdir(modules) else self.products + return {name[: -len(".swiftmodule")] for name in os.listdir(folder) if name.endswith(".swiftmodule")} + + def built_targets(self, excluded): + """The targets that left objects in this build, without the ones in `excluded`.""" + suffix = ".o" if self.swift_build else ".build" + targets = [name[: -len(suffix)] for name in sorted(os.listdir(self.products)) if name.endswith(suffix)] + targets = [target for target in targets if target not in excluded] + if self.swift_build: + return targets + return [ + target + for target in targets + if any(file.endswith(".o") for _, _, files in os.walk(os.path.join(self.products, f"{target}.build")) for file in files) + ] + + def package_directories(self): + """The folders of the packages the build was made from: SwiftPM clones them into + `checkouts` of its scratch folder, Xcode into `SourcePackages/checkouts` of its derived + data folder, and a package used by path stays where it is. The `workspace-state.json` + beside `checkouts` is SwiftPM's record of both, and leaves out a clone the build no + longer uses; without it, every clone counts.""" + two_up = os.path.normpath(os.path.join(self.products, "../..")) + if self.swift_build: + root = os.path.dirname(two_up) + workspaces = [root, os.path.join(root, "SourcePackages")] + else: + workspaces = [two_up] + packages = [] + for workspace in workspaces: + checkouts = os.path.join(workspace, "checkouts") + try: + with open(os.path.join(workspace, "workspace-state.json")) as handle: + dependencies = json.load(handle)["object"]["dependencies"] + packages += [ + d["packageRef"]["location"] if d["packageRef"]["kind"] == "fileSystem" else os.path.join(checkouts, d["subpath"]) + for d in dependencies + ] + except (OSError, KeyError, TypeError, ValueError): + if os.path.isdir(checkouts): + packages += [os.path.join(checkouts, name) for name in sorted(os.listdir(checkouts)) if not name.startswith(".")] + return packages + + +def top_level_modules(text): + """The modules a module map declares itself: `framework module A { module B {} }` declares + A, and B only as a part of it.""" + modules, depth, name_follows = [], 0, False + for token in TOKEN.findall(text): + if token.startswith("/"): + continue + if token == "{": + depth += 1 + name_follows = False + elif token == "}": + depth -= 1 + elif name_follows: + name_follows = False + if depth == 0: + modules.append(token.strip('"')) + elif token == "module": + name_follows = True + return modules + + +def declares(module_map, module): + try: + with open(module_map, encoding="utf-8") as handle: + return module in top_level_modules(handle.read()) + except (OSError, UnicodeDecodeError): + return False + + +def own_module_map(module, packages): + """The module.modulemap a C target brings itself: in its `include` folder by default, + somewhere else in its package when the target says so.""" + for package in packages: + conventional = os.path.join(package, "Sources", module, "include", "module.modulemap") + if declares(conventional, module): + return conventional + for package in packages: + declaring = [] + for folder, subfolders, files in os.walk(package): + subfolders[:] = [name for name in subfolders if not name.startswith(".")] + if "module.modulemap" in files and declares(os.path.join(folder, "module.modulemap"), module): + declaring.append(os.path.relpath(os.path.join(folder, "module.modulemap"), package)) + if declaring: + # A copy kept deeper in the package (a benchmark, a fixture) is not the target's. + return os.path.join(package, min(declaring, key=lambda path: (path.count(os.sep), path))) + return None + + +def copy_public_headers(source, destination): + for folder, subfolders, files in os.walk(source): + subfolders[:] = [name for name in subfolders if not name.startswith(".")] + for name in files: + if name.startswith(".") or name.endswith(SOURCE_SUFFIXES): + continue + target = os.path.join(destination, os.path.relpath(os.path.join(folder, name), source)) + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.copyfile(os.path.join(folder, name), target) + + +def copy_generated(module, module_map, destination): + """SwiftPM generates one of two module maps, and both name the package by absolute path: + an umbrella folder, or an umbrella header. Returns why the module cannot be shipped, if so.""" + with open(module_map, encoding="utf-8") as handle: + text = handle.read() + header = re.search(r'umbrella\s+header\s+"([^"]+)"', text) + folder = re.search(r'umbrella\s+"([^"]+)"', text) + if header: + headers, umbrella = os.path.dirname(header.group(1)), f'umbrella header "{os.path.basename(header.group(1))}"' + elif folder: + headers, umbrella = folder.group(1), 'umbrella "."' + else: + return f"its generated module map is not one this script can move: {module_map}" + if not os.path.exists(header.group(1) if header else headers): + # A build folder keeps the objects and the module map of a target that is gone. + return f"its headers are no longer at {headers}" + copy_public_headers(headers, destination) + os.makedirs(destination, exist_ok=True) + with open(os.path.join(destination, "module.modulemap"), "w", encoding="utf-8") as handle: + handle.write(f"module {module} {{\n {umbrella}\n export *\n}}\n") + return None + + +def main(): + if len(sys.argv) != 3: + sys.exit("usage: copy-component-sdk-c-modules.py ") + # `../..` is taken from the real folder: `.build/release` is a symlink. + products, sdk = os.path.realpath(sys.argv[1]), os.path.abspath(sys.argv[2]) + layout = BuildLayout(products) + + packages = None + copied = [] + for module in [C_SHADER_TYPES] + layout.built_targets(excluded=layout.swift_modules() | {C_SHADER_TYPES}): + destination = os.path.join(sdk, module) + generated = layout.generated_module_map(module) + if os.path.isfile(generated): + problem = copy_generated(module, generated, destination) + if problem: + warn(f"{module} is not shipped: {problem}") + continue + elif module == C_SHADER_TYPES: + warn(f"CShaderTypes module map not found at {generated} — code components will not compile in this build") + continue + else: + if packages is None: + packages = layout.package_directories() + own = own_module_map(module, packages) + if own is None: + warn(f"no module map found for the C target {module}; the packaging check fails if the engine imports it") + continue + copy_public_headers(os.path.dirname(own), destination) + copied.append(module) + + print("\n".join(copied)) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-component-sdk.sh b/scripts/verify-component-sdk.sh index 59fdbf1..b66d2f9 100755 --- a/scripts/verify-component-sdk.sh +++ b/scripts/verify-component-sdk.sh @@ -4,7 +4,8 @@ # # Checks that a packaged editor can load code components: it compiles a fixture against the # bundle's Component SDK exactly as the editor does (nothing of the engine linked), then makes -# sure every engine and kit symbol the fixture needs is exported by the editor executable. +# sure every symbol the fixture needs from a module of the SDK (the engine, the kit, and any +# module they import) is exported by the editor executable. # # Why this exists: a subclass compiled into another image copies its base class's dispatch # table, entries for internal members included. Debug builds export internal symbols, release @@ -18,13 +19,31 @@ APP="${1:?usage: verify-component-sdk.sh }" SDK="$APP/Contents/Resources/ComponentSDK" EXECUTABLE="$APP/Contents/MacOS/UntoldEditor" -for required in "$SDK/sdk.json" "$SDK/Modules" "$SDK/CShaderTypes/module.modulemap" "$EXECUTABLE"; do +for required in "$SDK/sdk.json" "$SDK/Modules" "$EXECUTABLE"; do if [ ! -e "$required" ]; then echo "❌ Component SDK check: missing $required" exit 1 fi done +# The C modules sdk.json lists are the ones the editor passes a module map for: CShaderTypes, +# and any other the engine was built with. An sdk.json from before the list has CShaderTypes. +MODULE_MAP_ARGUMENTS=() +while IFS= read -r module; do + MODULE_MAP="$SDK/$module/module.modulemap" + if [ ! -e "$MODULE_MAP" ]; then + echo "❌ Component SDK check: missing $MODULE_MAP" + exit 1 + fi + # The app runs on another machine: a module map must name its headers inside the SDK. + if grep -q '"/' "$MODULE_MAP"; then + echo "❌ Component SDK check: $MODULE_MAP names a path outside the SDK" + grep '"/' "$MODULE_MAP" | sed 's/^/ /' + exit 1 + fi + MODULE_MAP_ARGUMENTS+=(-Xcc "-fmodule-map-file=$MODULE_MAP") +done < <(python3 -c 'import json, sys; print("\n".join(json.load(open(sys.argv[1])).get("cModules") or ["CShaderTypes"]))' "$SDK/sdk.json") + WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT @@ -147,7 +166,7 @@ if ! xcrun swiftc -emit-library -parse-as-library \ -o "$WORK/Fixture.dylib" -module-name ComponentSDKFixture \ -swift-version 5 -Onone -D UNTOLD_EDITOR \ -target "$TARGET" -sdk "$(xcrun --sdk macosx --show-sdk-path)" \ - -I "$SDK/Modules" -Xcc "-fmodule-map-file=$SDK/CShaderTypes/module.modulemap" \ + -I "$SDK/Modules" "${MODULE_MAP_ARGUMENTS[@]}" \ -Xlinker -undefined -Xlinker dynamic_lookup \ "$WORK/Fixture.swift" > "$WORK/compile.log" 2>&1 then @@ -156,7 +175,10 @@ then exit 1 fi -nm -u "$WORK/Fixture.dylib" | awk '{print $NF}' | grep -E '18UntoldComponentKit|12UntoldEngine' | sort -u > "$WORK/needed.txt" +# A symbol's mangled name carries its module, as the length of the name and the name: +# 12UntoldEngine. Every Swift module of the SDK is linked into the editor. +MODULE_PATTERN="$(python3 -c 'import json, sys; print("|".join(f"{len(m)}{m}" for m in json.load(open(sys.argv[1]))["providedModules"]))' "$SDK/sdk.json")" +nm -u "$WORK/Fixture.dylib" | awk '{print $NF}' | grep -E "$MODULE_PATTERN" | sort -u > "$WORK/needed.txt" nm -gU "$EXECUTABLE" | awk '{print $NF}' | sort -u > "$WORK/exported.txt" MISSING="$(comm -23 "$WORK/needed.txt" "$WORK/exported.txt")" @@ -167,4 +189,4 @@ if [ -n "$MISSING" ]; then exit 1 fi -echo "✅ Component SDK verified: the editor exports all $(wc -l < "$WORK/needed.txt" | tr -d ' ') engine and kit symbols a loaded library needs" +echo "✅ Component SDK verified: the editor exports all $(wc -l < "$WORK/needed.txt" | tr -d ' ') symbols of its modules that a loaded library needs"