From 19a88ffd72fc665d51c69a4f17b01e1a4da92c6a Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Mon, 5 Oct 2026 15:41:00 +0200 Subject: [PATCH 1/7] [Feature] Editor UI redesign, part 4: selecting several entities (#147) * [Feature] Selection: several entities at once, each with its box, one gizmo for them all and one undo step The selection could hold one entity. It now holds several, in the order they were selected; the last one stays the one the Inspector names, and the many places that set the one selected entity still select it alone. - SelectionManager keeps selectedEntities beside selectedEntity, with selectEntities(_:) and toggleSelection(of:). SelectionDelegate gains didSelectEntities and didToggleEntity, with default implementations. - One gizmo for all of them: it stands in the middle of their boxes and moves each by the same distance, turns them round it and scales them and their distance from it, each in its parent's measure. Entities under another selected one follow their parent. After a drag the gizmo goes back to the middle. - A drag is one undo step, "Transform N Entities". - Each selected entity shows its box: the highlight pass draws one per entity from a unit box, in the entity's own space, so the boxes follow what the gizmo moves with nothing rebuilt. An entity that draws nothing gets a small box where it stands. With one entity selected nothing changes. - F frames all of them. Nothing selects several yet: the next two commits bring the rectangle and the Shift click in the viewport, then the hierarchy and the Inspector. * [Feature] Viewport: a rectangle selects what stands inside it, and a Shift click adds an entity or takes it out A drag of the left button that takes no gizmo handle draws a dashed rectangle over the viewport, and its release selects what stands inside it. - An entity is inside when all of its box is: every corner in front of the camera and within the rectangle. What reaches out of it is left out, so the floor under what the rectangle is drawn around, or the wall behind it, is not selected however much of it shows. A light or an entity written in code, which has no box, is inside where it stands. - Of what draws meshes, only what is seen is selected: what stands behind a wall or under a floor is left out though it is inside. An editor-only pass tells: at the release it draws the scene's meshes into a texture of entity numbers the size of the rectangle and a compute kernel gathers which of them show, about a millisecond for a whole viewport. A locked entity hides what is behind it and is not selected; a hidden one hides nothing. When the pass cannot run, being inside is enough. - With Command held the rectangle selects the assets those entities belong to, as a Command click does. With nothing inside, the selection is cleared. - A Shift click adds the entity under the pointer to the selection or takes it out; a Shift drag with a selection still moves that entity. - No rectangle is drawn while playing, over a locked preview or with the editor disabled, and a canvas that loses the keyboard takes it away without selecting. - The hints gain "Add or remove: Shift Click", and "Select" says "Click or drag". * [Feature] Hierarchy and Inspector: several entities selected The panels follow a selection of several entities. - Hierarchy: every selected row is highlighted and the footer counts them. A Shift or Command click on a row adds it to the selection or takes it out; a plain click selects it alone, as before. Deleting an entity from the menu leaves the rest selected. - From Play to Stop the hierarchy selects nothing, as the viewport does not, and the footer says so. - Inspector: with several selected it says how many, unless it is pinned to an entity. With one it shows that entity as before. - H hides every selected entity. * [Test] The highlight box test looks where no edge is, at any pixel density test_anEntityThatDrawsNothing_hasItsBoxWhereItStands checked that the box is drawn as lines by looking at the middle of 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, and the far face's right edge passes two pixels from that middle where a point of the window is one pixel, as on the CI runner: the test found a line there and failed. On a display with two pixels per point the edge is four pixels away and it passed. It now looks between that edge and the near face's own, clear of both, and says so when a box is too few pixels wide to tell. Checked at both sizes. * [Patch] Rectangle selection: only what reaches into the rectangle is drawn to tell what is seen At each release of the rectangle the pass that tells what is seen drew every mesh of the scene, and the selection asked whether an entity can be selected before it asked where the entity stands. Both grew with the scene: over 100,000 entities one release held the main thread for 625 ms in a debug build and 236 ms in a release one. Nearly all of that came before the wait for the GPU, which took about 20 ms. An entity outside the rectangle neither shows in it nor hides anything in it, so it is left out of the pass now. MarqueeGeometry.Frustum is made once for a rectangle and tells a box inside, across or outside it, by the box the engine culls by. Where an entity stands is asked first, and the rest only of what the rectangle reaches. The same release takes 150 ms and 36 ms, and the pass draws 90 of the 100,000 entities. What is selected is the same: a test compares both ways on 120 cubes, some hiding others, over five rectangles, and another tries 3,000 boxes to see that none told outside shows in its rectangle. * [Patch] Gizmo on several entities: only those with both transforms, and no made-up place in the world A scale of several entities took an entity's place under its parent for its place in the world when the entity had no world transform, which is wrong under a parent that was moved or turned. A turn took the gizmo's own place. The engine gives an entity its two transforms together and takes them away together, so neither was reached, but nothing said so where the gizmo's entities are chosen. takesGizmo and gizmoTransformTargets now ask for both transforms, with hasBothTransforms, and the two fallbacks are gone: the place in the world is always the one the engine keeps. --------- Co-authored-by: Javier Segura --- .../Editor/Hierarchy/EntityRow.swift | 6 +- .../Hierarchy/HierarchyFooterView.swift | 15 +- .../Editor/Hierarchy/HierarchyNode.swift | 14 +- .../Editor/Hierarchy/HierarchyRowClick.swift | 31 ++ .../Editor/Hierarchy/SceneHierarchyView.swift | 18 +- .../InspectorSeveralSelectedView.swift | 47 ++ .../Editor/Inspector/InspectorView.swift | 11 +- .../Editor/Project/EditorController.swift | 14 + .../EditorGroupTransformChangeCommand.swift | 40 ++ .../Editor/Project/EditorUndoManager.swift | 28 + .../Project/SelectionManager+Several.swift | 134 +++++ .../Editor/Project/SelectionManager.swift | 120 ++++- .../Editor/Project/SelectionWorldBounds.swift | 37 ++ .../Editor/Viewport/MarqueeFrustum.swift | 110 ++++ .../Editor/Viewport/MarqueeGeometry.swift | 133 +++++ .../Editor/Viewport/MarqueeView.swift | 35 ++ .../Editor/Viewport/ViewportHint.swift | 11 +- .../Viewport/ViewportMarqueeStore.swift | 37 ++ .../Viewport/ViewportOverlaysView.swift | 18 +- .../Editor/Window/EditorView+Entities.swift | 5 +- .../Window/EditorView+EntityStates.swift | 10 +- .../Renderer/EditorRenderPasses.swift | 17 +- .../Renderer/EditorUntoldRenderer.swift | 60 +-- .../Renderer/SelectionHighlightPass.swift | 52 ++ .../Renderer/SelectionHighlights.swift | 93 ++++ .../Renderer/SelectionVisibilityPass.swift | 357 ++++++++++++ .../Systems/EditorInputSystemAppKit.swift | 143 ++++- Sources/UntoldEditor/Systems/GizmoGroup.swift | 243 +++++++++ .../UntoldEditor/Systems/GizmoSystem.swift | 33 +- .../Systems/MarqueeSelection.swift | 167 ++++++ Tests/UntoldEditorTests/GizmoGroupTests.swift | 510 ++++++++++++++++++ .../MarqueeGeometryTests.swift | 363 +++++++++++++ .../UntoldEditorTests/MarqueeInputTests.swift | 412 ++++++++++++++ .../MarqueeSelectionTests.swift | 220 ++++++++ Tests/UntoldEditorTests/SeededGenerator.swift | 23 + .../SelectionHighlightPassTests.swift | 238 ++++++++ .../SelectionSeveralPanelsTests.swift | 233 ++++++++ .../SelectionSeveralTests.swift | 480 +++++++++++++++++ .../SelectionVisibilityPassTests.swift | 493 +++++++++++++++++ .../ViewportCanvasEventTests.swift | 15 +- .../ViewportOverlayHitTestTests.swift | 16 +- 41 files changed, 4930 insertions(+), 112 deletions(-) create mode 100644 Sources/UntoldEditor/Editor/Hierarchy/HierarchyRowClick.swift create mode 100644 Sources/UntoldEditor/Editor/Inspector/InspectorSeveralSelectedView.swift create mode 100644 Sources/UntoldEditor/Editor/Project/EditorGroupTransformChangeCommand.swift create mode 100644 Sources/UntoldEditor/Editor/Project/SelectionManager+Several.swift create mode 100644 Sources/UntoldEditor/Editor/Viewport/MarqueeFrustum.swift create mode 100644 Sources/UntoldEditor/Editor/Viewport/MarqueeGeometry.swift create mode 100644 Sources/UntoldEditor/Editor/Viewport/MarqueeView.swift create mode 100644 Sources/UntoldEditor/Editor/Viewport/ViewportMarqueeStore.swift create mode 100644 Sources/UntoldEditor/Renderer/SelectionHighlightPass.swift create mode 100644 Sources/UntoldEditor/Renderer/SelectionHighlights.swift create mode 100644 Sources/UntoldEditor/Renderer/SelectionVisibilityPass.swift create mode 100644 Sources/UntoldEditor/Systems/GizmoGroup.swift create mode 100644 Sources/UntoldEditor/Systems/MarqueeSelection.swift create mode 100644 Tests/UntoldEditorTests/GizmoGroupTests.swift create mode 100644 Tests/UntoldEditorTests/MarqueeGeometryTests.swift create mode 100644 Tests/UntoldEditorTests/MarqueeInputTests.swift create mode 100644 Tests/UntoldEditorTests/MarqueeSelectionTests.swift create mode 100644 Tests/UntoldEditorTests/SeededGenerator.swift create mode 100644 Tests/UntoldEditorTests/SelectionHighlightPassTests.swift create mode 100644 Tests/UntoldEditorTests/SelectionSeveralPanelsTests.swift create mode 100644 Tests/UntoldEditorTests/SelectionSeveralTests.swift create mode 100644 Tests/UntoldEditorTests/SelectionVisibilityPassTests.swift 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 6d79e44..d5f77df 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 { @ObservedObject var selectionManager: SelectionManager @ObservedObject var sceneGraphModel: SceneGraphModel @@ -24,12 +25,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 @@ -114,6 +117,8 @@ struct InspectorView: View { .padding(10) } } + } else if selectionManager.hasSeveralSelected { + InspectorSeveralSelectedView(count: selectionManager.selectedEntities.count) } else { InspectorEmptyStateView() } 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/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..2ae1b5e 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() 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/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/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/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() From 9ede4444b25bd2abdbde9a88c5412bdda0bdc9c7 Mon Sep 17 00:00:00 2001 From: Javier Segura Date: Mon, 5 Oct 2026 15:41:37 +0200 Subject: [PATCH 2/7] [Feature] Component SDK: every C module the engine was built with, not only CShaderTypes (#148) * [Feature] Component SDK: pass the module map of every C module the engine was built with The engine is built without library evolution, so a plugin that imports UntoldEngine makes the compiler load every module the engine imports. The editor passed the module map of CShaderTypes and nothing else, so one more C module on the engine side (a C target of its own, or one that comes with a Swift package such as swift-atomics) broke every plugin build with "missing required module". An editor run from source now passes the module map of every C target it was built with, CShaderTypes first: - The module map the build system generated for the target, beside the CShaderTypes one, in both SwiftPM layouts and in Xcode's. - For a target that brings its own module.modulemap, the one in its package. The packages of a build are the ones SwiftPM's workspace-state.json lists: a cloned one in `checkouts` (SwiftPM) or `SourcePackages/checkouts` (Xcode), one used by path where it is. A target counts when it left objects in the build, so a target the engine declares and the editor does not use is not passed, and a target that is gone from its package and left its objects behind is not passed either. A built C target without any module map is reported in the Plugins tab and the console, because the compiler would only name the missing module. A packaged editor passes the C modules its sdk.json lists under "cModules"; an sdk.json without the list has CShaderTypes alone. With the engine as it is the arguments do not change. * [Feature] Component SDK: package every Swift and C module the editor was built with The app bundle shipped three Swift modules by name and the CShaderTypes headers, found at a fixed path in the engine's checkout. A plugin compiled by a packaged editor needs whatever the engine imports, so the bundle now ships what the editor was built with: - Every Swift module of the build except the editor's own (the engine, the kit, a plugin the editor links, and a module of another package that one of them imports, such as Atomics). - Every C module of the build, by the rule the editor follows when run from source: scripts/copy-component-sdk-c-modules.py copies the public headers of each into ComponentSDK// with a module map that names them by relative path. For a generated module map that is the same file the script wrote for CShaderTypes; a target's own module map is shipped as it is. - sdk.json lists the C modules under "cModules", which is what the editor passes. verify-component-sdk.sh compiles its fixture with the module maps sdk.json lists, refuses a module map that names a path outside the SDK, and checks the exported symbols of every Swift module of the SDK, not only the engine's and the kit's. With the engine as it is the bundle is the same, but for the new key in sdk.json. Two side effects: CShaderTypes is found when the engine is used by path, where it was missing before, and the warning about UntoldGaussianTwins.swiftmodule on an editor that does not link it is gone. The tests run the script on build folders shaped like the real ones and check that it ships the modules the editor finds when run from source, and that a packaged editor passes each of them. --------- Co-authored-by: Javier Segura --- .../Components/ComponentCompiler.swift | 10 +- .../Components/ComponentProject.swift | 7 +- .../Components/ComponentSDK.swift | 39 ++- .../Components/ComponentSDKBuildLayout.swift | 238 ++++++++++++++++++ .../CodeComponentsTestSupport.swift | 9 +- .../ComponentCompilerTests.swift | 28 ++- .../ComponentSDKBuildFolders.swift | 126 ++++++++++ .../ComponentSDKBuildLayoutTests.swift | 206 +++++++++++++++ .../ComponentSDKPackagingTests.swift | 212 ++++++++++++++++ .../UntoldEditorTests/ComponentSDKTests.swift | 35 ++- .../EditorEnginePackageTests.swift | 3 +- create_app_bundle.sh | 63 ++--- scripts/copy-component-sdk-c-modules.py | 220 ++++++++++++++++ scripts/verify-component-sdk.sh | 32 ++- 14 files changed, 1169 insertions(+), 59 deletions(-) create mode 100644 Sources/UntoldEditor/Components/ComponentSDKBuildLayout.swift create mode 100644 Tests/UntoldEditorTests/ComponentSDKBuildFolders.swift create mode 100644 Tests/UntoldEditorTests/ComponentSDKBuildLayoutTests.swift create mode 100644 Tests/UntoldEditorTests/ComponentSDKPackagingTests.swift create mode 100755 scripts/copy-component-sdk-c-modules.py 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/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/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/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" From aa1cd1efa3b62f0da2a7182a1bc243eab8d4af25 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 5 Oct 2026 07:17:09 -0700 Subject: [PATCH 3/7] [Release] Preparing release for 0.22.0 (#149) --- CHANGELOG.md | 9 +++++++++ Package.swift | 4 ++-- Sources/UntoldEditor/UntoldEditorApp.swift | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) 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 aea8fd4..65028c7 100644 --- a/Package.swift +++ b/Package.swift @@ -11,9 +11,9 @@ let package = Package( ], dependencies: [ // Use a branch during active development: - .package(url: "https://github.com/untoldengine/UntoldEngine.git", branch: "develop"), + // .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"), ], targets: [ .executableTarget( diff --git a/Sources/UntoldEditor/UntoldEditorApp.swift b/Sources/UntoldEditor/UntoldEditorApp.swift index 3681db9..dda50d8 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? From 769186b63f9626c0ee58656800a6f0e211d0568c Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 5 Oct 2026 07:33:33 -0700 Subject: [PATCH 4/7] [Chores] Reset to develop (#150) --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 65028c7..75da016 100644 --- a/Package.swift +++ b/Package.swift @@ -11,9 +11,9 @@ let package = Package( ], dependencies: [ // Use a branch during active development: - // .package(url: "https://github.com/untoldengine/UntoldEngine.git", branch: "develop"), + .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.22.0"), + // .package(url: "https://github.com/untoldengine/UntoldEngine.git", exact: "0.22.0"), ], targets: [ .executableTarget( From 5ed90edd168990d85d3df866fcbead86d55239dd Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Mon, 5 Oct 2026 22:31:00 -0700 Subject: [PATCH 5/7] Fix build and test warnings (#151) * [Chores] Fixed warnigns in build and test * [Chores] Formatted files --- .../Inspector/StaticBatchingEditorView.swift | 4 +- .../Editor/Shared/LoadingIndicatorView.swift | 1 + .../Shared/MinimalLoadingIndicator.swift | 1 + .../Editor/Tasks/TasksPanelView.swift | 1 + .../Editor/Window/EditorView+Entities.swift | 2 - .../Systems/ScriptProjectManager.swift | 2 +- .../AssetImportCopyTests.swift | 4 +- .../CreateProjectViewTests.swift | 56 ++++++++----------- .../EditorControllerTests.swift | 3 +- .../EnvironmentViewTests.swift | 2 - .../SceneHierarchyViewTests.swift | 16 +++--- 11 files changed, 40 insertions(+), 52 deletions(-) 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/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/Window/EditorView+Entities.swift b/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift index 2ae1b5e..e4ad914 100644 --- a/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift +++ b/Sources/UntoldEditor/Editor/Window/EditorView+Entities.swift @@ -142,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/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/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/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/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/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/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 From ad012c168ee99376b0c743b12056351243be8657 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 6 Oct 2026 23:48:38 -0700 Subject: [PATCH 6/7] [Patch] Hide cook-source folders in asset browser (#154) * [Patch] Hide cook-source folders in asset browser, reveal via context menu Source folders sharing a stem with a runtime asset (e.g. bl/ for bl.untoldpack) cluttered the listing; they're now hidden and reachable through a "Reveal Source Folder" menu item. Co-Authored-By: Claude Sonnet 5 * [Chores] Formatted files --------- Co-authored-by: Claude Sonnet 5 --- .../AssetBrowserView+Contents.swift | 32 ++++++++++++++++++- .../AssetBrowserView+Loading.swift | 23 +++++++++++-- .../Editor/AssetBrowser/AssetModel.swift | 4 +++ 3 files changed, 56 insertions(+), 3 deletions(-) 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 { From 2de8117936945409cee1d3c644302e2aad5ea3f8 Mon Sep 17 00:00:00 2001 From: Untold Engine Date: Tue, 6 Oct 2026 23:49:04 -0700 Subject: [PATCH 7/7] [Patch] Skip intro gallery and disable Explore asset panel, drop AWS URLs (#155) The editor no longer serves demo/asset-pack content from AWS, so launch straight into the full editor instead of the Explore gallery, hide the Explore tab in the Asset Browser, and remove the CloudFront URLs from the demo and asset-pack catalogs. Updated tests to match the now-empty catalogs. --- .../Editor/AssetPacks/AssetPackCatalog.swift | 18 ++--------- .../UntoldEditor/Editor/Docking/PanelID.swift | 1 + .../Editor/Explore/StarterStreamModels.swift | 30 ++----------------- .../Editor/Window/EditorView.swift | 6 ++-- .../AssetBrowserViewTests.swift | 7 ++--- .../DemoGalleryViewTests.swift | 16 ++++++++-- 6 files changed, 25 insertions(+), 53 deletions(-) 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/Window/EditorView.swift b/Sources/UntoldEditor/Editor/Window/EditorView.swift index 462f9e4..2b40416 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/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/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 },