diff --git a/Sources/DemoGame/main.swift b/Sources/DemoGame/main.swift index fac68e6b..18cdc2c8 100644 --- a/Sources/DemoGame/main.swift +++ b/Sources/DemoGame/main.swift @@ -156,13 +156,13 @@ // ----------------------------------------------------- registerCustomSystem(ballSystemUpdate) registerCustomSystem(dribblingSystemUpdate) - registerCustomSystem(cameraFollowUpdate) + // registerCustomSystem(cameraFollowUpdate) // Input (WASD) for the demo InputSystem.shared.registerKeyboardEvents() // Disable SSAO - SSAOParams.shared.enabled = true + SSAOParams.shared.enabled = false // Test Fast quality (8 samples, half-res) SSAOParams.shared.quality = .high } @@ -193,11 +193,21 @@ // Camera + lighting moveCameraTo(entityId: findGameCamera(), 0.0, 3.0, 10.0) ambientIntensity = 0.4 + + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 3, 10), lookAt: .zero, segmentDuration: 2.0), + CameraWaypoint(position: simd_float3(10, 3, 0), lookAt: .zero, segmentDuration: 2.0), + CameraWaypoint(position: simd_float3(0, 3, -10), lookAt: .zero, segmentDuration: 2.0), + CameraWaypoint(position: simd_float3(-10, 3, 0), lookAt: .zero, segmentDuration: 2.0), + ] + + startCameraPath(waypoints: waypoints, mode: .loop) } - func update(deltaTime _: Float) { + func update(deltaTime dt: Float) { // Skip logic if not in game mode if gameMode == false { return } + updateCameraPath(deltaTime: dt) } func handleInput() { diff --git a/Sources/UntoldEngine/Systems/CameraSystem.swift b/Sources/UntoldEngine/Systems/CameraSystem.swift index c3aee519..35002675 100644 --- a/Sources/UntoldEngine/Systems/CameraSystem.swift +++ b/Sources/UntoldEngine/Systems/CameraSystem.swift @@ -20,6 +20,9 @@ public final class CameraSystem { get { _activeCamera } set { _activeCamera = newValue } } + + // Camera path state (internal access for path functions) + fileprivate var pathState: CameraPathState? } public enum CameraMoveSpace { @@ -418,3 +421,311 @@ public func cameraOrbitTarget(entityId: EntityID, target: centerTransform.position, up: simd_float3(0, 1, 0)) } + +// MARK: - Camera Path Types + +/// Represents a single waypoint in a camera path with position, rotation, and duration to next waypoint +public struct CameraWaypoint { + /// World position of the camera at this waypoint + public var position: simd_float3 + /// Rotation of the camera at this waypoint (quaternion) + public var rotation: simd_quatf + /// Duration in seconds to travel from this waypoint to the next + public var segmentDuration: Float + + public init(position: simd_float3, rotation: simd_quatf, segmentDuration: Float) { + self.position = position + self.rotation = rotation + self.segmentDuration = segmentDuration + } + + /// Convenience initializer using look-at direction + public init(position: simd_float3, lookAt: simd_float3, up: simd_float3 = simd_float3(0, 1, 0), segmentDuration: Float) { + self.position = position + rotation = simd_normalize(simd_conjugate(quaternion_lookAt(eye: position, target: lookAt, up: up))) + self.segmentDuration = segmentDuration + } +} + +/// Defines how the camera path should behave when reaching the end +public enum CameraPathMode { + /// Play once and stop at the last waypoint + case once + /// Loop back to the first waypoint and repeat + case loop +} + +/// Settings for camera path playback +public struct CameraPathSettings { + /// Whether to start playback immediately upon calling startCameraPath + public var startImmediately: Bool + /// Optional callback invoked when path completes (not called in loop mode) + public var onComplete: (() -> Void)? + + public init(startImmediately: Bool = true, onComplete: (() -> Void)? = nil) { + self.startImmediately = startImmediately + self.onComplete = onComplete + } + + public static let `default` = CameraPathSettings() +} + +/// Internal state for active camera path playback +struct CameraPathState { + var waypoints: [CameraWaypoint] + var mode: CameraPathMode + var settings: CameraPathSettings + + var currentSegmentIndex: Int = 0 + var segmentT: Float = 0.0 // Normalized time [0, 1] within current segment + var totalDuration: Float = 0.0 + var isActive: Bool = true + + init(waypoints: [CameraWaypoint], mode: CameraPathMode, settings: CameraPathSettings) { + self.waypoints = waypoints + self.mode = mode + self.settings = settings + totalDuration = waypoints.reduce(0) { $0 + $1.segmentDuration } + } + + /// Returns the current and next waypoint for interpolation + func getCurrentSegment() -> (current: CameraWaypoint, next: CameraWaypoint)? { + guard currentSegmentIndex < waypoints.count else { return nil } + + let current = waypoints[currentSegmentIndex] + + // Determine next index based on mode + let nextIndex: Int + if mode == .loop { + nextIndex = (currentSegmentIndex + 1) % waypoints.count + } else { + // In .once mode, check if there's a next waypoint + guard currentSegmentIndex + 1 < waypoints.count else { return nil } + nextIndex = currentSegmentIndex + 1 + } + + let next = waypoints[nextIndex] + return (current, next) + } +} + +// MARK: - Camera Path Following API + +/** + Camera Path Following System + + This API enables the active camera to follow a deterministic path through a sequence of waypoints, + interpolating both position and rotation smoothly over time. Designed for repeatable benchmark + camera paths and cinematic sequences. + + ## Basic Usage + + ```swift + // Define waypoints with explicit rotations + let waypoints = [ + CameraWaypoint( + position: simd_float3(0, 5, 10), + rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), + segmentDuration: 2.0 + ), + CameraWaypoint( + position: simd_float3(10, 5, 10), + rotation: simd_quatf(angle: Float.pi / 4, axis: simd_float3(0, 1, 0)), + segmentDuration: 2.0 + ), + CameraWaypoint( + position: simd_float3(10, 5, 0), + rotation: simd_quatf(angle: Float.pi / 2, axis: simd_float3(0, 1, 0)), + segmentDuration: 2.0 + ) + ] + + // Start path in once mode (stops at end) + startCameraPath(waypoints: waypoints, mode: .once) + + // Update every frame in your game loop + updateCameraPath(deltaTime: deltaTime) + ``` + + ## Look-At Waypoints + + You can use the convenience initializer to create waypoints that look at a target: + + ```swift + let waypoint = CameraWaypoint( + position: simd_float3(0, 5, 10), + lookAt: simd_float3(0, 0, 0), // Look at origin + up: simd_float3(0, 1, 0), // World up + segmentDuration: 2.0 + ) + ``` + + ## Looping Paths + + ```swift + // Path will loop continuously from last waypoint back to first + startCameraPath(waypoints: waypoints, mode: .loop) + ``` + + ## Completion Callbacks + + ```swift + let settings = CameraPathSettings(startImmediately: true) { + print("Camera path completed!") + } + startCameraPath(waypoints: waypoints, mode: .once, settings: settings) + ``` + + ## Control Methods + + ```swift + // Check if path is active + if isCameraPathActive() { + // Stop the path + stopCameraPath() + } + ``` + + */ + +/// Starts the active camera following a predefined path of waypoints +/// - Parameters: +/// - waypoints: Ordered array of waypoints defining the camera path +/// - mode: Playback mode (.once or .loop) +/// - settings: Additional settings for path playback +public func startCameraPath(waypoints: [CameraWaypoint], mode: CameraPathMode = .once, settings: CameraPathSettings = .default) { + // Validate input + guard !waypoints.isEmpty else { + handleError(.missingCameraWaypoints) + return + } + + guard CameraSystem.shared.activeCamera != nil else { + handleError(.noActiveCamera) + return + } + + // Handle single waypoint case - just snap to it + if waypoints.count == 1 { + let waypoint = waypoints[0] + if let entityId = CameraSystem.shared.activeCamera { + applyCameraTransform(entityId: entityId, position: waypoint.position, rotation: waypoint.rotation) + } + settings.onComplete?() + return + } + + // Initialize path state + var state = CameraPathState(waypoints: waypoints, mode: mode, settings: settings) + state.isActive = settings.startImmediately + CameraSystem.shared.pathState = state + + // Apply initial position if starting immediately + if settings.startImmediately, let entityId = CameraSystem.shared.activeCamera { + let firstWaypoint = waypoints[0] + applyCameraTransform(entityId: entityId, position: firstWaypoint.position, rotation: firstWaypoint.rotation) + } +} + +/// Stops any active camera path playback +public func stopCameraPath() { + CameraSystem.shared.pathState = nil +} + +/// Returns true if a camera path is currently active +public func isCameraPathActive() -> Bool { + CameraSystem.shared.pathState?.isActive ?? false +} + +/// Updates the camera path state and applies interpolated transform to the active camera +/// - Parameter deltaTime: Time elapsed since last update in seconds +/// - Note: This should be called every frame from the main update loop +public func updateCameraPath(deltaTime: Float) { + guard var state = CameraSystem.shared.pathState, state.isActive else { return } + guard let entityId = CameraSystem.shared.activeCamera else { + handleError(.noActiveCamera) + CameraSystem.shared.pathState = nil + return + } + + // Get current segment + guard let segment = state.getCurrentSegment() else { + // No valid segment - we've reached the end in .once mode + // Snap to final waypoint and stop + if state.mode == .once, !state.waypoints.isEmpty { + let finalWaypoint = state.waypoints[state.waypoints.count - 1] + applyCameraTransform(entityId: entityId, position: finalWaypoint.position, rotation: finalWaypoint.rotation) + state.isActive = false + CameraSystem.shared.pathState = state + state.settings.onComplete?() + } else { + CameraSystem.shared.pathState = nil + } + return + } + + let currentWaypoint = segment.current + let nextWaypoint = segment.next + + // Validate segment duration + guard currentWaypoint.segmentDuration > 0 else { + // Invalid duration, advance to next segment + state.currentSegmentIndex += 1 + state.segmentT = 0.0 + CameraSystem.shared.pathState = state + return + } + + // Advance time within segment + state.segmentT += deltaTime / currentWaypoint.segmentDuration + + // Check if we've completed the current segment + if state.segmentT >= 1.0 { + // Handle segment overflow by carrying excess time to next segment + let overflow = state.segmentT - 1.0 + state.segmentT = 0.0 + state.currentSegmentIndex += 1 + + // In loop mode, wrap around to start + if state.mode == .loop, state.currentSegmentIndex >= state.waypoints.count { + state.currentSegmentIndex = 0 + } + // In once mode, getCurrentSegment() will return nil on next update when we reach the end + + // Store state and recursively handle the overflow time to ensure smooth transition + // This prevents flickering when transitioning between segments + CameraSystem.shared.pathState = state + if overflow > 0, state.mode == .loop { + // Recursively update with remaining time to smooth the transition + updateCameraPath(deltaTime: overflow * currentWaypoint.segmentDuration) + return + } + } + + // Clamp t to [0, 1] for safety + let t = min(max(state.segmentT, 0.0), 1.0) + + // Interpolate position (linear) + let position = currentWaypoint.position + (nextWaypoint.position - currentWaypoint.position) * t + + // Interpolate rotation (slerp for smooth rotation) + let rotation = simd_slerp(currentWaypoint.rotation, nextWaypoint.rotation, t) + + // Apply transform to camera + applyCameraTransform(entityId: entityId, position: position, rotation: rotation) + + // Store updated state + CameraSystem.shared.pathState = state +} + +/// Applies position and rotation to a camera entity +private func applyCameraTransform(entityId: EntityID, position: simd_float3, rotation: simd_quatf) { + guard let cameraComponent = scene.get(component: CameraComponent.self, for: entityId) else { + return + } + + cameraComponent.localPosition = position + cameraComponent.rotation = simd_normalize(rotation) + + updateCameraViewMatrix(entityId: entityId) +} diff --git a/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift b/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift index 16d3e0db..1038cabd 100644 --- a/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift +++ b/Sources/UntoldEngine/Systems/ErrorHandlingSystem.swift @@ -74,6 +74,7 @@ public enum ErrorHandlingSystem: Int, Error, CustomStringConvertible { case metalLibraryNotFound = 1063 case metalDeviceNotFound = 1064 case noGaussianComponent = 1065 + case missingCameraWaypoints = 1066 public var description: String { switch self { @@ -205,6 +206,8 @@ public enum ErrorHandlingSystem: Int, Error, CustomStringConvertible { return "Metal Device not found" case .noGaussianComponent: return "Gaussian Component missing" + case .missingCameraWaypoints: + return "Camera has no waypoints" } } } diff --git a/Tests/UntoldEngineRenderTests/CameraPathTest.swift b/Tests/UntoldEngineRenderTests/CameraPathTest.swift new file mode 100644 index 00000000..e02bf93e --- /dev/null +++ b/Tests/UntoldEngineRenderTests/CameraPathTest.swift @@ -0,0 +1,454 @@ +// +// CameraPathTest.swift +// UntoldEngine +// +// Copyright (C) Untold Engine Studios +// Licensed under the GNU LGPL v3.0 or later. +// See the LICENSE file or for details. + +import CShaderTypes +import simd +import UniformTypeIdentifiers +@testable import UntoldEngine +import XCTest + +final class CameraPathTests: BaseRenderSetup { + override func setUp() { + super.setUp() + // Ensure no camera path is active from previous tests + stopCameraPath() + } + + override func initializeAssets() { + // Create a simple scene with a camera + cameraLookAt( + entityId: findGameCamera(), + eye: simd_float3(0.0, 1.0, 5.0), + target: simd_float3(0.0, 0.0, 0.0), + up: simd_float3(0.0, 1.0, 0.0) + ) + } + + // MARK: - Basic Functionality Tests + + func test_startCameraPathInitializesStateAndHandlesSingleWaypoint() { + let camera = findGameCamera() + + let targetPosition = simd_float3(10.0, 5.0, 10.0) + let targetRotation = simd_quatf(angle: Float.pi / 4, axis: simd_float3(0, 1, 0)) + + let waypoint = CameraWaypoint( + position: targetPosition, + rotation: targetRotation, + segmentDuration: 1.0 + ) + + // Verify path is not active before starting + XCTAssertFalse(isCameraPathActive(), "Path should not be active before start") + + startCameraPath(waypoints: [waypoint], mode: .once) + + // With single waypoint, path should snap immediately and not remain active + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + // Verify camera position matches waypoint + let positionDelta = simd_length(cameraComponent.localPosition - targetPosition) + XCTAssertLessThan(positionDelta, 1e-3, "Camera should snap to single waypoint position") + + // Verify camera rotation matches waypoint + let rotationDelta = simd_length(cameraComponent.rotation.vector - targetRotation.vector) + XCTAssertLessThan(rotationDelta, 1e-3, "Camera rotation should match waypoint rotation") + + // Single waypoint should not create an active path + XCTAssertFalse(isCameraPathActive(), "Path should not be active after single waypoint snap") + } + + func test_singleWaypointSnaps() { + let camera = findGameCamera() + + let targetPosition = simd_float3(10.0, 5.0, 10.0) + let targetRotation = simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)) + + let waypoint = CameraWaypoint( + position: targetPosition, + rotation: targetRotation, + segmentDuration: 1.0 + ) + + startCameraPath(waypoints: [waypoint], mode: .once) + + // Camera should snap to single waypoint immediately + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + let positionDelta = simd_length(cameraComponent.localPosition - targetPosition) + XCTAssertLessThan(positionDelta, 1e-3, "Camera should snap to single waypoint position") + } + + func test_reachesFinalWaypoint() { + let camera = findGameCamera() + + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 1, 5), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + CameraWaypoint(position: simd_float3(5, 1, 5), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + CameraWaypoint(position: simd_float3(5, 1, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + ] + + let finalPosition = waypoints.last!.position + + startCameraPath(waypoints: waypoints, mode: .once) + + // Simulate time progression + let dt: Float = 0.016 // ~60 FPS + let totalTime: Float = waypoints.reduce(0) { $0 + $1.segmentDuration } + let steps = Int(ceil(totalTime / dt)) + 10 // Add extra steps to ensure completion + + for _ in 0 ..< steps { + updateCameraPath(deltaTime: dt) + } + + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + let positionDelta = simd_length(cameraComponent.localPosition - finalPosition) + XCTAssertLessThan(positionDelta, 1e-2, "Camera should reach final waypoint within tolerance. Delta: \(positionDelta)") + XCTAssertFalse(isCameraPathActive(), "Path should be inactive after completion") + } + + func test_loopingReturnsToStart() { + let camera = findGameCamera() + + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 1, 5), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.3), + CameraWaypoint(position: simd_float3(3, 1, 5), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.3), + CameraWaypoint(position: simd_float3(3, 1, 2), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.3), + ] + + let startPosition = waypoints[0].position + + startCameraPath(waypoints: waypoints, mode: .loop) + + // Simulate more than one full cycle + let dt: Float = 0.016 + let totalTime: Float = waypoints.reduce(0) { $0 + $1.segmentDuration } + let steps = Int(ceil(totalTime * 1.5 / dt)) // 1.5 cycles + + for _ in 0 ..< steps { + updateCameraPath(deltaTime: dt) + } + + // Path should still be active in loop mode + XCTAssertTrue(isCameraPathActive(), "Path should remain active in loop mode") + + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + // After 1.5 cycles, camera should be halfway through the second loop + // We'll just verify it's somewhere along the path (not stuck) + let distanceFromStart = simd_length(cameraComponent.localPosition - startPosition) + XCTAssertGreaterThan(distanceFromStart, 0.1, "Camera should have moved from start position") + } + + // MARK: - Interpolation Tests + + func test_updateCameraPathInterpolatesPositionAndRotation() { + let camera = findGameCamera() + + let startPos = simd_float3(0.0, 0.0, 0.0) + let endPos = simd_float3(10.0, 0.0, 0.0) + let startRot = simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)) + let endRot = simd_quatf(angle: Float.pi / 2, axis: simd_float3(0, 1, 0)) + + let waypoints = [ + CameraWaypoint(position: startPos, rotation: startRot, segmentDuration: 1.0), + CameraWaypoint(position: endPos, rotation: endRot, segmentDuration: 1.0), + ] + + startCameraPath(waypoints: waypoints, mode: .once) + + // Update to 50% of first segment (0.5 seconds) + updateCameraPath(deltaTime: 0.5) + + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + // At t=0.5, position should be halfway between start and end + let expectedMidPos = startPos + (endPos - startPos) * 0.5 + let positionDelta = simd_length(cameraComponent.localPosition - expectedMidPos) + XCTAssertLessThan(positionDelta, 1e-2, "Position should be interpolated to midpoint. Delta: \(positionDelta)") + + // At t=0.5, rotation should be between start and end (slerp) + let expectedMidRot = simd_slerp(startRot, endRot, 0.5) + let rotationDelta = simd_length(cameraComponent.rotation.vector - expectedMidRot.vector) + XCTAssertLessThan(rotationDelta, 1e-2, "Rotation should be interpolated via slerp. Delta: \(rotationDelta)") + + // Verify quaternion is normalized + let magnitude = simd_length(cameraComponent.rotation.vector) + XCTAssertTrue(abs(magnitude - 1.0) < 1e-4, "Quaternion should remain normalized. Magnitude: \(magnitude)") + + // Path should still be active + XCTAssertTrue(isCameraPathActive(), "Path should still be active during interpolation") + } + + // MARK: - Rotation Tests + + func test_rotationInterpolation() { + let camera = findGameCamera() + + // Create waypoints with significantly different rotations + let rot1 = simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)) + let rot2 = simd_quatf(angle: Float.pi / 2, axis: simd_float3(0, 1, 0)) // 90 degree rotation + + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 0, 0), rotation: rot1, segmentDuration: 1.0), + CameraWaypoint(position: simd_float3(5, 0, 0), rotation: rot2, segmentDuration: 1.0), + ] + + startCameraPath(waypoints: waypoints, mode: .once) + + // Step halfway through first segment + updateCameraPath(deltaTime: 0.5) + + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + // Verify rotation is valid (not NaN) + XCTAssertFalse(cameraComponent.rotation.vector.x.isNaN, "Rotation x should not be NaN") + XCTAssertFalse(cameraComponent.rotation.vector.y.isNaN, "Rotation y should not be NaN") + XCTAssertFalse(cameraComponent.rotation.vector.z.isNaN, "Rotation z should not be NaN") + XCTAssertFalse(cameraComponent.rotation.vector.w.isNaN, "Rotation w should not be NaN") + + // Verify quaternion is normalized + let magnitude = simd_length(cameraComponent.rotation.vector) + XCTAssertTrue(abs(magnitude - 1.0) < 1e-4, "Quaternion should be normalized. Magnitude: \(magnitude)") + } + + func test_cameraWaypointLookAtInitializerSetsRotationCorrectly() { + let position = simd_float3(0, 5, 10) + let lookAtTarget = simd_float3(0, 0, 0) + let up = simd_float3(0, 1, 0) + + // Create waypoint using lookAt initializer + let waypointLookAt = CameraWaypoint(position: position, lookAt: lookAtTarget, up: up, segmentDuration: 1.0) + + // Verify rotation is valid (no NaN values) + XCTAssertFalse(waypointLookAt.rotation.vector.x.isNaN, "Rotation x should be valid") + XCTAssertFalse(waypointLookAt.rotation.vector.y.isNaN, "Rotation y should be valid") + XCTAssertFalse(waypointLookAt.rotation.vector.z.isNaN, "Rotation z should be valid") + XCTAssertFalse(waypointLookAt.rotation.real.isNaN, "Rotation w should be valid") + + // Verify quaternion is normalized + let quatVec = simd_float4(waypointLookAt.rotation.vector.x, waypointLookAt.rotation.vector.y, waypointLookAt.rotation.vector.z, waypointLookAt.rotation.real) + let magnitude = simd_length(quatVec) + XCTAssertTrue(abs(magnitude - 1.0) < 1e-4, "Quaternion should be normalized. Magnitude: \(magnitude)") + + // Create another waypoint path and verify the camera can use it + let waypoints = [ + waypointLookAt, + CameraWaypoint(position: simd_float3(5, 5, 5), lookAt: lookAtTarget, up: up, segmentDuration: 1.0), + ] + + // This should not crash and should set the camera orientation correctly + startCameraPath(waypoints: waypoints, mode: .once) + XCTAssertTrue(isCameraPathActive(), "Path should be active after starting with lookAt waypoints") + + // Verify the camera component received the rotation + let camera = findGameCamera() + guard let cameraComponent = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + + // Verify the rotation is not identity (has been set) + let identityQuat = simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)) + let rotDiff = simd_length(cameraComponent.rotation.vector - identityQuat.vector) + XCTAssertGreaterThan(rotDiff, 0.01, "Camera rotation should be set from lookAt waypoint") + } + + // MARK: - Edge Cases + + func test_isCameraPathActiveReturnsTrueOnlyWhenActive() { + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + CameraWaypoint(position: simd_float3(5, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + ] + + // Initially path should not be active + XCTAssertFalse(isCameraPathActive(), "Path should not be active before starting") + + // Start the path + startCameraPath(waypoints: waypoints, mode: .once) + XCTAssertTrue(isCameraPathActive(), "Path should be active after starting") + + // Should remain active during updates + updateCameraPath(deltaTime: 0.2) + XCTAssertTrue(isCameraPathActive(), "Path should be active during playback") + + // Stop the path + stopCameraPath() + XCTAssertFalse(isCameraPathActive(), "Path should not be active after stopping") + + // Start again in loop mode + startCameraPath(waypoints: waypoints, mode: .loop) + XCTAssertTrue(isCameraPathActive(), "Path should be active after restarting in loop mode") + + // Run to completion (in loop mode it should remain active) + for _ in 0 ..< 100 { + updateCameraPath(deltaTime: 0.016) + } + XCTAssertTrue(isCameraPathActive(), "Path should remain active in loop mode") + + // Stop again + stopCameraPath() + XCTAssertFalse(isCameraPathActive(), "Path should not be active after stopping loop") + } + + func test_zeroWaypointsHandledGracefully() { + startCameraPath(waypoints: [], mode: .once) + + // Should not crash and path should not be active + XCTAssertFalse(isCameraPathActive(), "Path should not be active with zero waypoints") + } + + func test_stopCameraPathDeactivatesAndDisablesUpdates() { + let camera = findGameCamera() + + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 1.0), + CameraWaypoint(position: simd_float3(10, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 1.0), + ] + + startCameraPath(waypoints: waypoints, mode: .once) + XCTAssertTrue(isCameraPathActive(), "Path should be active after start") + + // Advance the path a bit + updateCameraPath(deltaTime: 0.3) + XCTAssertTrue(isCameraPathActive(), "Path should still be active during updates") + + guard let componentBeforeStop = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + let positionBeforeStop = componentBeforeStop.localPosition + + // Stop the path + stopCameraPath() + XCTAssertFalse(isCameraPathActive(), "Path should be inactive after stop") + + // Updating after stop should be safe (no-op - camera should not move) + updateCameraPath(deltaTime: 0.5) + XCTAssertFalse(isCameraPathActive(), "Path should remain inactive after updates") + + guard let componentAfterStop = scene.get(component: CameraComponent.self, for: camera) else { + XCTFail("Camera component not found") + return + } + let positionAfterStop = componentAfterStop.localPosition + + // Camera position should not change after stop + let positionDelta = simd_length(positionAfterStop - positionBeforeStop) + XCTAssertLessThan(positionDelta, 1e-5, "Camera should not move after path is stopped. Delta: \(positionDelta)") + } + + func test_invalidSegmentDurationHandled() { + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.0), + CameraWaypoint(position: simd_float3(5, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 1.0), + CameraWaypoint(position: simd_float3(10, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 1.0), + ] + + startCameraPath(waypoints: waypoints, mode: .once) + + // Should skip invalid segment and continue + updateCameraPath(deltaTime: 0.1) + + // Verify it doesn't crash and eventually completes + for _ in 0 ..< 200 { + updateCameraPath(deltaTime: 0.016) + } + + XCTAssertFalse(isCameraPathActive(), "Path should eventually complete despite invalid segment") + } + + // MARK: - Completion Callback Test + + func test_completionCallbackInvoked() { + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.1), + CameraWaypoint(position: simd_float3(1, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.1), + ] + + var callbackInvoked = false + let settings = CameraPathSettings(startImmediately: true) { + callbackInvoked = true + } + + startCameraPath(waypoints: waypoints, mode: .once, settings: settings) + + // Run until completion + for _ in 0 ..< 100 { + updateCameraPath(deltaTime: 0.016) + } + + XCTAssertTrue(callbackInvoked, "Completion callback should be invoked when path finishes") + } + + // MARK: - Determinism Test + + func test_deterministicBehavior() { + let waypoints = [ + CameraWaypoint(position: simd_float3(0, 0, 0), rotation: simd_quatf(angle: 0, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + CameraWaypoint(position: simd_float3(5, 2, 5), rotation: simd_quatf(angle: Float.pi / 4, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + CameraWaypoint(position: simd_float3(10, 0, 0), rotation: simd_quatf(angle: Float.pi / 2, axis: simd_float3(0, 1, 0)), segmentDuration: 0.5), + ] + + // Run path twice with same deltaTime sequence + var positions1: [simd_float3] = [] + var positions2: [simd_float3] = [] + + let camera = findGameCamera() + + // First run + startCameraPath(waypoints: waypoints, mode: .once) + for _ in 0 ..< 100 { + updateCameraPath(deltaTime: 0.016) + if let cameraComponent = scene.get(component: CameraComponent.self, for: camera) { + positions1.append(cameraComponent.localPosition) + } + } + + // Reset camera + stopCameraPath() + moveCameraTo(entityId: camera, 0, 1, 5) + + // Second run + startCameraPath(waypoints: waypoints, mode: .once) + for _ in 0 ..< 100 { + updateCameraPath(deltaTime: 0.016) + if let cameraComponent = scene.get(component: CameraComponent.self, for: camera) { + positions2.append(cameraComponent.localPosition) + } + } + + // Compare positions + XCTAssertEqual(positions1.count, positions2.count, "Should have same number of samples") + + for (i, (pos1, pos2)) in zip(positions1, positions2).enumerated() { + let delta = simd_length(pos1 - pos2) + XCTAssertLessThan(delta, 1e-5, "Position at step \(i) should be deterministic. Delta: \(delta)") + } + } +}