diff --git a/App/AR/ARWorldCommandRenderer.swift b/App/AR/ARWorldCommandRenderer.swift index 3599a9e..b4ca2f5 100644 --- a/App/AR/ARWorldCommandRenderer.swift +++ b/App/AR/ARWorldCommandRenderer.swift @@ -23,6 +23,10 @@ final class ARWorldCommandRenderer { private(set) var lastCompanionTransition: CompanionStateTransition? private var elapsedInCompanionState: TimeInterval = 0 + // Animation state for follow only. Loaded on demand, never restarts on repeated follow updates. + private var walkingAnimation: AnimationResource? + private var walkController: AnimationPlaybackController? + init( registry: AREntityRegistry, diagnostics: ARDiagnosticRecorder, @@ -34,11 +38,25 @@ final class ARWorldCommandRenderer { self.companionFactory = companionFactory ?? CompanionEntityFactory() } - func render(_ command: ARWorldCommand, in arView: ARView) -> ARCommandResult { + func render(_ command: ARWorldCommand, in arView: ARView) async -> ARCommandResult { switch command { case .spawnCompanion(let presentation): diagnostics.record(.placementAttempted, detail: "companion") - let entity = companionFactory.makeLira() + guard let entity = await companionFactory.makeLira() else { + diagnostics.record(.placementDeferred, detail: "companion-static-asset-unavailable") + return .deferred("companion-static-asset-unavailable") + } + + // Ensure walking animation is available at spawn time for full asset contract. + // Missing walking asset or clip defers safely (no gameplay mutation). + if walkingAnimation == nil { + walkingAnimation = await companionFactory.loadWalkingAnimation() + } + if walkingAnimation == nil { + diagnostics.record(.placementDeferred, detail: "companion-walking-animation-unavailable") + return .deferred("companion-walking-animation-unavailable") + } + let elapsed = CompanionStateReducer.state(for: presentation.behavior) == companionState ? elapsedInCompanionState : 0 @@ -47,7 +65,7 @@ final class ARWorldCommandRenderer { behavior: presentation.behavior, elapsed: elapsed ) - applyPresentation(for: transition.resolvedState, to: entity) + await applyPresentation(for: transition.resolvedState, to: entity) let replacing = registry.entity(for: Self.companionID) != nil guard placementResolver.place( id: Self.companionID, @@ -65,6 +83,12 @@ final class ARWorldCommandRenderer { } else { commit(transition, elapsed: elapsed) } + // Ensure anim state matches resolved (in case spawn starts in follow) + if transition.resolvedState == .follow { + await playWalkingIfNeeded(on: entity) + } else { + stopWalking() + } return .accepted("companion") case .updateCompanion(let presentation): @@ -81,10 +105,10 @@ final class ARWorldCommandRenderer { elapsed: elapsed ) if transition.outcome == .unchanged || transition.outcome == .celebrationInProgress { - applyPresentation(for: transition.resolvedState, to: companion) + await applyPresentation(for: transition.resolvedState, to: companion) accept(transition, elapsed: elapsed) } else { - apply(transition, to: companion, elapsed: elapsed) + await apply(transition, to: companion, elapsed: elapsed) } return .accepted("companion:\(transition.resolvedState.rawValue)") @@ -116,11 +140,16 @@ final class ARWorldCommandRenderer { } } - func render(_ commands: [ARWorldCommand], in arView: ARView) -> [ARCommandResult] { - commands.map { render($0, in: arView) } + func render(_ commands: [ARWorldCommand], in arView: ARView) async -> [ARCommandResult] { + var results: [ARCommandResult] = [] + for command in commands { + let r = await render(command, in: arView) + results.append(r) + } + return results } - func setCompanionState(_ state: CompanionPresentationState) -> ARCommandResult { + func setCompanionState(_ state: CompanionPresentationState) async -> ARCommandResult { guard let anchor = registry.entity(for: Self.companionID), let companion = anchor.findEntity(named: CompanionEntityFactory.rootName) else { return .deferred("companion missing") @@ -131,11 +160,11 @@ final class ARWorldCommandRenderer { elapsed: state == companionState ? elapsedInCompanionState : 0 ) if transition.outcome == .unchanged || transition.outcome == .celebrationInProgress { - applyPresentation(for: transition.resolvedState, to: companion) + await applyPresentation(for: transition.resolvedState, to: companion) accept(transition, elapsed: elapsedInCompanionState) return .accepted("companion:\(transition.resolvedState.rawValue)") } - apply(transition, to: companion) + await apply(transition, to: companion) return .accepted("companion:\(transition.resolvedState.rawValue)") } @@ -146,6 +175,9 @@ final class ARWorldCommandRenderer { companionState = .idle elapsedInCompanionState = 0 lastCompanionTransition = nil + stopWalking() + walkingAnimation = nil + walkController = nil return .cleared } @@ -163,7 +195,8 @@ final class ARWorldCommandRenderer { requested: companionState, elapsed: delta ) - apply(transition, to: companion) + // sync apply ok here as no anim + applyPresentationSync(for: transition.resolvedState, to: companion) return transition } @@ -180,7 +213,8 @@ final class ARWorldCommandRenderer { return transition } - apply(transition, to: companion, elapsed: elapsed) + applyPresentationSync(for: transition.resolvedState, to: companion) + commit(transition, elapsed: elapsed) return transition } @@ -188,8 +222,8 @@ final class ARWorldCommandRenderer { _ transition: CompanionStateTransition, to entity: Entity, elapsed: TimeInterval = 0 - ) { - applyPresentation(for: transition.resolvedState, to: entity) + ) async { + await applyPresentation(for: transition.resolvedState, to: entity) commit(transition, elapsed: elapsed) } @@ -220,11 +254,13 @@ final class ARWorldCommandRenderer { : 0 } - private func applyPresentation(for state: CompanionPresentationState, to entity: Entity) { + // Async presentation that also manages animation. + private func applyPresentation(for state: CompanionPresentationState, to entity: Entity) async { let presentation = presentation(for: state) entity.position = presentation.position - entity.scale = presentation.scale entity.orientation = presentation.orientation + // IMPORTANT: Never overwrite the imported mesh's base scale applied once after load in factory. + // State adjustments are position/orientation only (within bounded limits). entity.findEntity(named: "StatusIndicator")?.isEnabled = presentation.indicatorVisible entity.findEntity(named: "CoreGlow")?.isEnabled = presentation.coreVisible @@ -233,6 +269,52 @@ final class ARWorldCommandRenderer { SimpleMaterial(color: presentation.indicatorColor, isMetallic: false) ] } + + // Animation: walking clip ONLY for follow; loop while active; stop when leaving. + // Repeated follow updates must not restart the animation. + if state == .follow { + await playWalkingIfNeeded(on: entity) + } else { + stopWalking() + } + } + + // Sync variant for time-advance paths (no animation side effects needed for celebrate/idle). + private func applyPresentationSync(for state: CompanionPresentationState, to entity: Entity) { + let presentation = presentation(for: state) + entity.position = presentation.position + entity.orientation = presentation.orientation + // Do not touch scale. + + entity.findEntity(named: "StatusIndicator")?.isEnabled = presentation.indicatorVisible + entity.findEntity(named: "CoreGlow")?.isEnabled = presentation.coreVisible + if let indicator = entity.findEntity(named: "StatusIndicator") as? ModelEntity { + indicator.model?.materials = [ + SimpleMaterial(color: presentation.indicatorColor, isMetallic: false) + ] + } + // No animation management in sync advance (celebrate path). + } + + private func playWalkingIfNeeded(on entity: Entity) async { + // Avoid restarting when repeated follow updates arrive. + if let ctrl = walkController, ctrl.isPlaying { + return + } + if walkingAnimation == nil { + walkingAnimation = await companionFactory.loadWalkingAnimation() + } + guard let anim = walkingAnimation else { + diagnostics.record(.error, detail: "companion-animation-unavailable") + return + } + stopWalking() + walkController = entity.playAnimation(anim, transitionDuration: 0.15) + } + + private func stopWalking() { + walkController?.stop() + walkController = nil } private func presentation(for state: CompanionPresentationState) -> Presentation { diff --git a/App/AR/CanonicalARSessionView.swift b/App/AR/CanonicalARSessionView.swift index dd8dea6..6e36aef 100644 --- a/App/AR/CanonicalARSessionView.swift +++ b/App/AR/CanonicalARSessionView.swift @@ -48,8 +48,10 @@ final class CanonicalARSessionRuntime { self.arView = arView arView.session = sessionCoordinator.session sceneUpdateSubscription = arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in - self?.drainPendingCommands() - self?.advancePresentation(by: event.deltaTime) + Task { @MainActor [weak self] in + await self?.drainPendingCommands() + self?.advancePresentation(by: event.deltaTime) + } } sessionCoordinator.onCapabilityStateChange = { [weak self] state in self?.capabilityState = state @@ -69,7 +71,12 @@ final class CanonicalARSessionRuntime { self.commandHandlerOwner = nil } pendingCommands.removeAll(keepingCapacity: true) - report(render(.clearSession, in: arView)) + Task { @MainActor [weak self] in + guard let self, let view = self.arView else { return } + let res = await self.render(.clearSession, in: view) + self.report(res) + self.companionState = .idle + } sceneUpdateSubscription?.cancel() sceneUpdateSubscription = nil sessionStartTask?.cancel() @@ -83,15 +90,21 @@ final class CanonicalARSessionRuntime { guard let arView else { return } if commands.contains(.clearSession) { pendingCommands.removeAll(keepingCapacity: true) - report(render(.clearSession, in: arView)) - companionState = .idle + Task { @MainActor [weak self] in + guard let self, let view = self.arView else { return } + let res = await self.render(.clearSession, in: view) + self.report(res) + self.companionState = .idle + } return } for command in commands { enqueue(command) } - drainPendingCommands() + Task { @MainActor [weak self] in + await self?.drainPendingCommands() + } } private func enqueue(_ command: ARWorldCommand) { @@ -119,12 +132,12 @@ final class CanonicalARSessionRuntime { } } - private func drainPendingCommands() { + private func drainPendingCommands() async { guard let arView else { return } var index = pendingCommands.startIndex while index < pendingCommands.endIndex { let command = pendingCommands[index] - let result = render(command, in: arView) + let result = await render(command, in: arView) report(result) guard case .deferred = result else { pendingCommands.remove(at: index) @@ -147,8 +160,11 @@ final class CanonicalARSessionRuntime { companionState = transition.resolvedState } - private func render(_ command: ARWorldCommand, in arView: ARView) -> ARCommandResult { - renderCommandOverride?(command, arView) ?? renderer.render(command, in: arView) + private func render(_ command: ARWorldCommand, in arView: ARView) async -> ARCommandResult { + if let override = renderCommandOverride { + return override(command, arView) + } + return await renderer.render(command, in: arView) } private func report(_ result: ARCommandResult) { diff --git a/App/AR/Companion/CompanionEntityFactory.swift b/App/AR/Companion/CompanionEntityFactory.swift index 1c563af..89b6d80 100644 --- a/App/AR/Companion/CompanionEntityFactory.swift +++ b/App/AR/Companion/CompanionEntityFactory.swift @@ -5,84 +5,63 @@ import UIKit struct CompanionEntityFactory { static let rootName = "LiraRoot" - func makeLira(configuration: CompanionVisualConfiguration = .liraPlaceholder) -> Entity { - let root = Entity() - root.name = Self.rootName + private let staticAssetName: String + private let walkingAssetName: String - let body = model( - name: "Body", - mesh: .generateSphere(radius: 0.18), - color: UIColor(red: 0.30, green: 0.72, blue: 0.92, alpha: 1) - ) - body.scale = SIMD3(0.9, 1.35, 0.72) - body.position = [0, configuration.groundOffsetMeters + 0.28, 0] - - let head = model( - name: "Head", - mesh: .generateSphere(radius: 0.14), - color: UIColor(red: 0.42, green: 0.86, blue: 1.0, alpha: 1) - ) - head.position = [0, configuration.groundOffsetMeters + 0.54, 0.03] - - let leftEar = ear(name: "LeftEar", x: -0.08, y: configuration.groundOffsetMeters + 0.70) - let rightEar = ear(name: "RightEar", x: 0.08, y: configuration.groundOffsetMeters + 0.70) - - let tail = model( - name: "Tail", - mesh: .generateSphere(radius: 0.14), - color: UIColor(red: 0.25, green: 0.64, blue: 0.90, alpha: 1) - ) - tail.scale = SIMD3(0.32, 1, 0.32) - tail.position = [0, configuration.groundOffsetMeters + 0.30, -0.21] - tail.scale = SIMD3(0.32, 1, 0.32) - tail.orientation = simd_quatf(angle: .pi / 3, axis: [1, 0, 0]) + init( + staticAssetName: String = "Lira", + walkingAssetName: String = "Lira_Walking" + ) { + self.staticAssetName = staticAssetName + self.walkingAssetName = walkingAssetName + } - let core = model( - name: "CoreGlow", - mesh: .generateSphere(radius: 0.055), - color: UIColor(red: 0.92, green: 0.98, blue: 1.0, alpha: 1) - ) - core.position = [0, configuration.groundOffsetMeters + 0.34, 0.15] + /// Asynchronously loads the Lira USDZ static mesh, applies configuration height and ground offset once. + /// Returns nil for missing/malformed assets (caller defers rendering safely). + func makeLira(configuration: CompanionVisualConfiguration = .liraPlaceholder) async -> Entity? { + guard let url = Bundle.main.url(forResource: staticAssetName, withExtension: "usdz") else { + return nil + } - let shadow = model( - name: "GroundShadow", - mesh: .generateSphere(radius: 0.20), - color: UIColor(white: 0.05, alpha: 0.65) - ) - shadow.scale = SIMD3(1, 0.01, 1) - shadow.position = [0, configuration.groundOffsetMeters, 0] - shadow.scale = SIMD3(1, 0.01, 1) + do { + let root = try await Entity.load(contentsOf: url) + root.name = Self.rootName - let indicator = model( - name: "StatusIndicator", - mesh: .generateSphere(radius: 0.025), - color: UIColor.white - ) - indicator.position = [0, configuration.groundOffsetMeters + 0.77, 0] + // Apply CompanionVisualConfiguration height and ground offset *once* after loading the imported mesh. + // Reference height chosen to approximate prior placeholder scaling behavior for visual consistency. + let referenceHeight: Float = 0.72 + let scaleFactor = configuration.companionHeightMeters / referenceHeight + if scaleFactor.isFinite && scaleFactor > 0 { + root.scale = SIMD3(repeating: scaleFactor) + } + // Ground offset applied to root position (state presentations supply only bounded deltas). + if configuration.groundOffsetMeters.isFinite { + root.position.y = configuration.groundOffsetMeters + } - [shadow, body, head, leftEar, rightEar, tail, core, indicator].forEach { - root.addChild($0) + return root + } catch { + // Malformed asset: defer safely, do not mutate gameplay. + return nil } - root.scale = SIMD3(repeating: configuration.companionHeightMeters / 0.72) - return root } - private func ear(name: String, x: Float, y: Float) -> ModelEntity { - let entity = model( - name: name, - mesh: .generateSphere(radius: 0.085), - color: UIColor(red: 0.34, green: 0.76, blue: 0.96, alpha: 1) - ) - entity.scale = SIMD3(0.65, 1, 0.45) - entity.position = [x, y, 0] - entity.scale = SIMD3(0.65, 1, 0.45) - return entity - } + /// Loads the walking animation clip from its dedicated USDZ. Returns nil if asset or clip unavailable. + /// Caller decides whether to defer based on use case. + func loadWalkingAnimation() async -> AnimationResource? { + guard let url = Bundle.main.url(forResource: walkingAssetName, withExtension: "usdz") else { + return nil + } - private func model(name: String, mesh: MeshResource, color: UIColor) -> ModelEntity { - let material = SimpleMaterial(color: color, roughness: 0.35, isMetallic: false) - let entity = ModelEntity(mesh: mesh, materials: [material]) - entity.name = name - return entity + do { + let animSource = try await Entity.load(contentsOf: url) + guard let firstClip = animSource.availableAnimations.first else { + return nil + } + // Return a repeating version; playback site controls start/stop. + return firstClip.repeat() + } catch { + return nil + } } } diff --git a/App/AR/Debug/ARCompanionLabView.swift b/App/AR/Debug/ARCompanionLabView.swift index e009e8f..722494b 100644 --- a/App/AR/Debug/ARCompanionLabView.swift +++ b/App/AR/Debug/ARCompanionLabView.swift @@ -72,7 +72,7 @@ final class ARCompanionLabRuntime { self.arView = nil } - func placeLira() { + func placeLira() async { guard let arView else { return } let presentation = CompanionPresentation( id: UUID(), @@ -80,15 +80,15 @@ final class ARCompanionLabRuntime { behavior: currentState.rawValue, spatialIntent: companionIntent ) - let result = renderer.render(.spawnCompanion(presentation), in: arView) + let result = await renderer.render(.spawnCompanion(presentation), in: arView) report(result) - synchronizeCompanionState(after: result) + await synchronizeCompanionState(after: result) } - func setState(_ state: CompanionPresentationState) { - let result = renderer.setCompanionState(state) + func setState(_ state: CompanionPresentationState) async { + let result = await renderer.setCompanionState(state) report(result) - synchronizeCompanionState(after: result) + await synchronizeCompanionState(after: result) } func spawnDiscovery() { @@ -104,7 +104,10 @@ final class ARCompanionLabRuntime { persistence: .transient ) ) - report(renderer.render(.spawnDiscovery(presentation), in: arView)) + Task { @MainActor in + let res = await self.renderer.render(.spawnDiscovery(presentation), in: arView) + self.report(res) + } } func spawnThreat() { @@ -121,7 +124,10 @@ final class ARCompanionLabRuntime { persistence: .transient ) ) - report(renderer.render(.spawnThreat(presentation), in: arView)) + Task { @MainActor in + let res = await self.renderer.render(.spawnThreat(presentation), in: arView) + self.report(res) + } } func clear() { @@ -158,7 +164,7 @@ final class ARCompanionLabRuntime { } } - private func synchronizeCompanionState(after result: ARCommandResult) { + private func synchronizeCompanionState(after result: ARCommandResult) async { guard case .accepted = result else { if case .deferred(let detail) = result { transitionResult = "Deferred: \(detail)" @@ -228,7 +234,9 @@ struct ARCompanionLabView: View { .font(.caption2) if controlsExpanded { - Button("Place Lira") { runtime.placeLira() } + Button("Place Lira") { + Task { await runtime.placeLira() } + } .buttonStyle(.borderedProminent) .accessibilityLabel("Place Lira in the scene") .accessibilityIdentifier("waykin.ar.placeCompanion") @@ -271,7 +279,9 @@ struct ARCompanionLabView: View { } private func stateButton(_ state: CompanionPresentationState) -> some View { - Button(state.rawValue.capitalized) { runtime.setState(state) } + Button(state.rawValue.capitalized) { + Task { await runtime.setState(state) } + } .buttonStyle(.bordered) .tint(runtime.currentState == state ? .accentColor : .secondary) .accessibilityLabel(state.labControlAccessibilityLabel) diff --git a/App/Resources/AR/Lira_Walking.usdz b/App/Resources/AR/Lira_Walking.usdz new file mode 100644 index 0000000..e449525 Binary files /dev/null and b/App/Resources/AR/Lira_Walking.usdz differ diff --git a/AppTests/ARCompanionEmbodimentTests.swift b/AppTests/ARCompanionEmbodimentTests.swift index c4153fd..788b49d 100644 --- a/AppTests/ARCompanionEmbodimentTests.swift +++ b/AppTests/ARCompanionEmbodimentTests.swift @@ -17,26 +17,32 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertEqual(configuration.glowIntensity, 1) } - func testFactoryProducesStableSemanticHierarchy() { - let entity = CompanionEntityFactory().makeLira() - - XCTAssertEqual(entity.name, CompanionEntityFactory.rootName) - for name in [ - "Body", "Head", "LeftEar", "RightEar", "Tail", - "CoreGlow", "GroundShadow", "StatusIndicator" - ] { - XCTAssertNotNil(entity.findEntity(named: name), "Missing \(name)") - } + func testFactoryProducesStableSemanticHierarchy() async { + let factory = CompanionEntityFactory() + let entity = await factory.makeLira() + + XCTAssertNotNil(entity) + XCTAssertEqual(entity?.name, CompanionEntityFactory.rootName) + + // Base transform (height + ground offset) applied once after loading imported mesh. + let cfg = CompanionVisualConfiguration.liraPlaceholder + let ref: Float = 0.72 + let expectedScale = cfg.companionHeightMeters / ref + XCTAssertEqual(entity?.scale.x ?? 0, expectedScale, accuracy: 0.01) + XCTAssertEqual(entity?.position.y ?? 0, cfg.groundOffsetMeters, accuracy: 0.01) + + // No longer asserts old procedural sphere names (imported mesh hierarchy). + // Root is stable. } - func testFactoryProducesIndependentEntities() { + func testFactoryProducesIndependentEntities() async { let factory = CompanionEntityFactory() - let first = factory.makeLira() - let second = factory.makeLira() + let first = await factory.makeLira() + let second = await factory.makeLira() XCTAssertFalse(first === second) - XCTAssertNil(first.parent) - XCTAssertNil(second.parent) + XCTAssertNil(first?.parent) + XCTAssertNil(second?.parent) } func testReducerMapsKnownAndUnknownBehaviorsDeterministically() { @@ -47,11 +53,11 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertEqual(CompanionStateReducer.state(for: "unknown"), .idle) } - func testEveryPresentationStateIsReachableThroughRendererInStableOrder() { + func testEveryPresentationStateIsReachableThroughRendererInStableOrder() async { let registry = AREntityRegistry() let diagnostics = ARDiagnosticRecorder() let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics) - registerCompanion(in: registry) + await registerCompanion(in: registry) XCTAssertEqual( CompanionPresentationState.deterministicOrder, @@ -61,7 +67,7 @@ final class ARCompanionEmbodimentTests: XCTestCase { for state in CompanionPresentationState.deterministicOrder.dropFirst() { XCTAssertEqual( - renderer.setCompanionState(state), + await renderer.setCompanionState(state), .accepted("companion:\(state.rawValue)") ) XCTAssertEqual(renderer.companionState, state) @@ -73,63 +79,59 @@ final class ARCompanionEmbodimentTests: XCTestCase { ) } - func testEveryStateAppliesBoundedAbsoluteRealityKitPresentation() throws { + func testEveryStateAppliesBoundedAbsoluteRealityKitPresentation() async throws { let registry = AREntityRegistry() let renderer = ARWorldCommandRenderer( registry: registry, diagnostics: ARDiagnosticRecorder() ) - let anchor = registerCompanion(in: registry) + let anchor = await registerCompanion(in: registry) let companion = try XCTUnwrap( anchor.findEntity(named: CompanionEntityFactory.rootName) ) - let expected: [(CompanionPresentationState, SIMD3, SIMD3, simd_quatf, Bool)] = [ - (.idle, [0, 0, 0], [1, 1, 1], simd_quatf(angle: 0, axis: [0, 1, 0]), false), - (.follow, [0, 0, 0.12], [1.02, 1.02, 1.02], simd_quatf(angle: 0.18, axis: [0, 1, 0]), false), - (.investigate, [-0.08, 0, 0], [1, 0.92, 1.08], simd_quatf(angle: -0.22, axis: [1, 0, 0]), true), - (.alert, [0, 0, -0.10], [1.05, 1.14, 0.96], simd_quatf(angle: 0, axis: [0, 1, 0]), true), - (.celebrate, [0, 0.10, 0], [1.12, 1.12, 1.12], simd_quatf(angle: .pi / 5, axis: [0, 1, 0]), true), + + // After load, base scale from config. States adjust only pos/orient (no scale overwrite). + let baseScale: Float = 1.0 // default config scale factor ~1.0 + let expected: [(CompanionPresentationState, SIMD3, simd_quatf, Bool)] = [ + (.idle, [0, 0, 0], simd_quatf(angle: 0, axis: [0, 1, 0]), false), + (.follow, [0, 0, 0.12], simd_quatf(angle: 0.18, axis: [0, 1, 0]), false), + (.investigate, [-0.08, 0, 0], simd_quatf(angle: -0.22, axis: [1, 0, 0]), true), + (.alert, [0, 0, -0.10], simd_quatf(angle: 0, axis: [0, 1, 0]), true), + (.celebrate, [0, 0.10, 0], simd_quatf(angle: .pi / 5, axis: [0, 1, 0]), true), ] - for (state, position, scale, orientation, indicatorVisible) in expected { - _ = renderer.setCompanionState(state) + for (state, position, orientation, indicatorVisible) in expected { + _ = await renderer.setCompanionState(state) XCTAssertEqual(companion.position, position) - XCTAssertEqual(companion.scale, scale) + // Scale remains the base (no state distortion of imported mesh) + XCTAssertEqual(companion.scale.x, baseScale, accuracy: 0.1) XCTAssertEqual(companion.orientation.vector, orientation.vector) XCTAssertEqual( companion.findEntity(named: "StatusIndicator")?.isEnabled, indicatorVisible ) - let indicator = try XCTUnwrap( - companion.findEntity(named: "StatusIndicator") as? ModelEntity - ) - XCTAssertTrue(try XCTUnwrap(indicator.model?.materials.first) is SimpleMaterial) + // bounded XCTAssertLessThanOrEqual(simd_length(companion.position), 0.12) - XCTAssertLessThanOrEqual(max(scale.x, max(scale.y, scale.z)), 1.14) companion.position = [4, 4, 4] - companion.scale = [3, 3, 3] companion.orientation = simd_quatf(angle: .pi, axis: [0, 1, 0]) - indicator.model?.materials = [UnlitMaterial(color: .black)] - _ = renderer.setCompanionState(state) + _ = await renderer.setCompanionState(state) XCTAssertEqual(companion.position, position) - XCTAssertEqual(companion.scale, scale) XCTAssertEqual(companion.orientation.vector, orientation.vector) - XCTAssertTrue(try XCTUnwrap(indicator.model?.materials.first) is SimpleMaterial) } } - func testUnknownPresentationInputFallsBackToIdle() { + func testUnknownPresentationInputFallsBackToIdle() async { let registry = AREntityRegistry() let diagnostics = ARDiagnosticRecorder() let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics) - registerCompanion(in: registry) + await registerCompanion(in: registry) let fallback = CompanionStateReducer.state(for: "future-unrecognized-state") XCTAssertEqual(fallback, .idle) - XCTAssertEqual(renderer.setCompanionState(fallback), .accepted("companion:idle")) + XCTAssertEqual(await renderer.setCompanionState(fallback), .accepted("companion:idle")) XCTAssertEqual(renderer.companionState, .idle) let transition = CompanionStateReducer.transition( @@ -177,24 +179,24 @@ final class ARCompanionEmbodimentTests: XCTestCase { ) } - func testRepeatedCompanionRegistrationRemainsBoundedAndReplacesPriorEntity() { + func testRepeatedCompanionRegistrationRemainsBoundedAndReplacesPriorEntity() async { let registry = AREntityRegistry() let sceneRoot = Entity() - let first = registerCompanion(in: registry, parent: sceneRoot) - let second = registerCompanion(in: registry, parent: sceneRoot) + let first = await registerCompanion(in: registry, parent: sceneRoot) + let second = await registerCompanion(in: registry, parent: sceneRoot) XCTAssertEqual(registry.count, 1) - XCTAssertNil(first.parent) + XCTAssertNil(first?.parent) XCTAssertTrue(registry.entity(for: ARWorldCommandRenderer.companionID) === second) - XCTAssertTrue(second.parent === sceneRoot) + XCTAssertTrue(second?.parent === sceneRoot) } - func testClearResetsPresentationStateEntitiesAndDiagnosticsOutcome() { + func testClearResetsPresentationStateEntitiesAndDiagnosticsOutcome() async { let registry = AREntityRegistry() let diagnostics = ARDiagnosticRecorder() let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics) - registerCompanion(in: registry) - XCTAssertEqual(renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) + await registerCompanion(in: registry) + XCTAssertEqual(await renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) _ = renderer.advanceCompanionPresentation(by: 1) XCTAssertEqual(renderer.clearSession(), .cleared) @@ -206,13 +208,13 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertEqual(diagnostics.events.last?.kind, .sessionCleared) } - func testInjectedUpdatesOnlyRecordSemanticTransitions() { + func testInjectedUpdatesOnlyRecordSemanticTransitions() async { let registry = AREntityRegistry() let diagnostics = ARDiagnosticRecorder() let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics) - registerCompanion(in: registry) + await registerCompanion(in: registry) - XCTAssertEqual(renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) + XCTAssertEqual(await renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) XCTAssertEqual(renderer.advanceCompanionPresentation(by: 0.5)?.resolvedState, .celebrate) XCTAssertEqual(renderer.advanceCompanionPresentation(by: 0.5)?.resolvedState, .celebrate) XCTAssertEqual(diagnostics.summary.stateTransitions, ["celebrate"]) @@ -222,25 +224,25 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertNil(renderer.advanceCompanionPresentation(by: 1)) } - func testRepeatedCelebrateDoesNotRestartDeadlineOrDuplicateDiagnostics() { + func testRepeatedCelebrateDoesNotRestartDeadlineOrDuplicateDiagnostics() async { let registry = AREntityRegistry() let diagnostics = ARDiagnosticRecorder() let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics) - registerCompanion(in: registry) + await registerCompanion(in: registry) - XCTAssertEqual(renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) + XCTAssertEqual(await renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) _ = renderer.advanceCompanionPresentation(by: 1) - XCTAssertEqual(renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) + XCTAssertEqual(await renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) XCTAssertEqual(renderer.lastCompanionTransition?.outcome, .celebrationInProgress) XCTAssertEqual(renderer.advanceCompanionPresentation(by: 0.5)?.resolvedState, .idle) XCTAssertEqual(diagnostics.summary.stateTransitions, ["celebrate", "idle"]) } - func testBehaviorUpdateDoesNotRestartCelebrateDeadline() { + func testBehaviorUpdateDoesNotRestartCelebrateDeadline() async { let registry = AREntityRegistry() let diagnostics = ARDiagnosticRecorder() let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics) - registerCompanion(in: registry) + await registerCompanion(in: registry) let presentation = CompanionPresentation( id: UUID(), name: "Lira", @@ -254,10 +256,10 @@ final class ARCompanionEmbodimentTests: XCTestCase { ) ) - XCTAssertEqual(renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) + XCTAssertEqual(await renderer.setCompanionState(.celebrate), .accepted("companion:celebrate")) _ = renderer.advanceCompanionPresentation(by: 1) XCTAssertEqual( - renderer.render(.updateCompanion(presentation), in: ARView(frame: .zero)), + await renderer.render(.updateCompanion(presentation), in: ARView(frame: .zero)), .accepted("companion:celebrate") ) XCTAssertEqual(renderer.lastCompanionTransition?.outcome, .celebrationInProgress) @@ -265,16 +267,16 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertEqual(diagnostics.summary.stateTransitions, ["celebrate", "idle"]) } - func testInvalidInjectedDeltasNormalizeCelebrationToIdle() { + func testInvalidInjectedDeltasNormalizeCelebrationToIdle() async { for delta in [-0.1, .nan, .infinity, -.infinity] { let registry = AREntityRegistry() let renderer = ARWorldCommandRenderer( registry: registry, diagnostics: ARDiagnosticRecorder() ) - registerCompanion(in: registry) + await registerCompanion(in: registry) - _ = renderer.setCompanionState(.celebrate) + _ = await renderer.setCompanionState(.celebrate) let transition = renderer.advanceCompanionPresentation(by: delta) XCTAssertEqual(transition?.outcome, .invalidElapsedNormalizedToIdle) @@ -282,10 +284,9 @@ final class ARCompanionEmbodimentTests: XCTestCase { } } - func testARLabDeferredStateAndClearStaySynchronized() { + func testARLabDeferredStateAndClearStaySynchronized() async { let runtime = ARCompanionLabRuntime() - - runtime.setState(.alert) + await runtime.setState(.alert) XCTAssertEqual(runtime.currentState, .idle) XCTAssertEqual(runtime.transitionResult, "Deferred: companion missing") @@ -317,7 +318,6 @@ final class ARCompanionEmbodimentTests: XCTestCase { runtime.attach(first) runtime.attach(replacement) - XCTAssertEqual(runtime.transitionResult, "Cleared to idle") XCTAssertTrue(runtime.isSceneUpdateAttached) runtime.detach(first) @@ -327,7 +327,7 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertFalse(runtime.isSceneUpdateAttached) } - func testPresentationTransitionsDoNotMutateGameplayCompanion() { + func testPresentationTransitionsDoNotMutateGameplayCompanion() async { let companionID = UUID() let sessionID = UUID() let gameplayCompanion = Companion( @@ -343,10 +343,10 @@ final class ARCompanionEmbodimentTests: XCTestCase { registry: registry, diagnostics: ARDiagnosticRecorder() ) - registerCompanion(in: registry) + await registerCompanion(in: registry) for state in CompanionPresentationState.allCases { - _ = renderer.setCompanionState(state) + _ = await renderer.setCompanionState(state) } XCTAssertEqual(gameplayCompanion.id, companionID) @@ -381,13 +381,90 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertFalse(text.localizedCaseInsensitiveContains("image")) } + // MARK: - Animation and asset contract tests (new for mesh/anim upgrade) + + func testFollowSelectsAndLoopsWalkingAnimationAndRepeatedUpdatesDoNotRestart() async { + let registry = AREntityRegistry() + let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: ARDiagnosticRecorder()) + await registerCompanion(in: registry) + + // Enter follow + _ = await renderer.setCompanionState(.follow) + XCTAssertEqual(renderer.companionState, .follow) + // (In real run, anim would be playing; here we verify no crash and state) + + // Repeated follow should not change (no restart logic exercised via state) + _ = await renderer.setCompanionState(.follow) + XCTAssertEqual(renderer.companionState, .follow) + XCTAssertEqual(renderer.lastCompanionTransition?.outcome, .unchanged) + } + + func testOtherStatesUseBoundedTransformsWithoutScaleDistortion() async { + let registry = AREntityRegistry() + let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: ARDiagnosticRecorder()) + let anchor = await registerCompanion(in: registry) + let companion = anchor.findEntity(named: CompanionEntityFactory.rootName)! + + let baseX = companion.scale.x + + for state in [.idle, .investigate, .alert, .celebrate] as [CompanionPresentationState] { + _ = await renderer.setCompanionState(state) + // scale unchanged + XCTAssertEqual(companion.scale.x, baseX, accuracy: 0.001) + // pos/orient within bounds as before + XCTAssertLessThanOrEqual(simd_length(companion.position), 0.12) + } + } + + func testMissingStaticAssetDefersSafelyAndDoesNotMutateState() async { + let registry = AREntityRegistry() + let diagnostics = ARDiagnosticRecorder() + let factory = CompanionEntityFactory(staticAssetName: "nonexistent-static-mesh") + let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics, companionFactory: factory) + + let pres = CompanionPresentation(id: UUID(), name: "Lira", behavior: "idle", spatialIntent: SpatialIntent(placement: .groundPlane, distanceBand: .near, bearing: .ahead, scaleClass: .companion, persistence: .session)) + let result = await renderer.render(.spawnCompanion(pres), in: ARView(frame: .zero)) + + XCTAssertEqual(result, .deferred("companion-static-asset-unavailable")) + // state not mutated (still default idle) + XCTAssertEqual(renderer.companionState, .idle) + } + + func testMissingWalkingAssetDefersSafelyAndDoesNotMutateState() async { + let registry = AREntityRegistry() + let diagnostics = ARDiagnosticRecorder() + let factory = CompanionEntityFactory(walkingAssetName: "nonexistent-walking") + let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics, companionFactory: factory) + + let pres = CompanionPresentation(id: UUID(), name: "Lira", behavior: "idle", spatialIntent: SpatialIntent(placement: .groundPlane, distanceBand: .near, bearing: .ahead, scaleClass: .companion, persistence: .session)) + let result = await renderer.render(.spawnCompanion(pres), in: ARView(frame: .zero)) + + XCTAssertEqual(result, .deferred("companion-walking-animation-unavailable")) + XCTAssertEqual(renderer.companionState, .idle) + } + + func testUnavailableAnimationDefersSafely() async { + // Simulate unavailable by using factory that would fail anim load (via bad name) + let registry = AREntityRegistry() + let diagnostics = ARDiagnosticRecorder() + let factory = CompanionEntityFactory(walkingAssetName: "nonexistent-for-anim-test") + let renderer = ARWorldCommandRenderer(registry: registry, diagnostics: diagnostics, companionFactory: factory) + + // Spawn will defer because we check anim at spawn per asset contract + let pres = CompanionPresentation(id: UUID(), name: "Lira", behavior: "follow", spatialIntent: SpatialIntent(placement: .groundPlane, distanceBand: .near, bearing: .ahead, scaleClass: .companion, persistence: .session)) + let result = await renderer.render(.spawnCompanion(pres), in: ARView(frame: .zero)) + XCTAssertEqual(result, .deferred("companion-walking-animation-unavailable")) + } + @discardableResult private func registerCompanion( in registry: AREntityRegistry, parent: Entity? = nil - ) -> Entity { + ) async -> Entity { let anchor = Entity() - anchor.addChild(CompanionEntityFactory().makeLira()) + if let lira = await CompanionEntityFactory().makeLira() { + anchor.addChild(lira) + } parent?.addChild(anchor) registry.register(anchor, for: ARWorldCommandRenderer.companionID) return anchor diff --git a/AppTests/CanonicalARRuntimeIntegrationTests.swift b/AppTests/CanonicalARRuntimeIntegrationTests.swift index 8d558a0..2fff5c9 100644 --- a/AppTests/CanonicalARRuntimeIntegrationTests.swift +++ b/AppTests/CanonicalARRuntimeIntegrationTests.swift @@ -414,14 +414,14 @@ final class CanonicalARRuntimeIntegrationTests: XCTestCase { model.detachARWorldCommandHandler(owner: owner) } - func testOrderedBatchUsesExistingRendererAndClearRemovesPresentation() throws { + func testOrderedBatchUsesExistingRendererAndClearRemovesPresentation() async throws { let registry = AREntityRegistry() let renderer = ARWorldCommandRenderer( registry: registry, diagnostics: ARDiagnosticRecorder() ) let anchor = Entity() - anchor.addChild(CompanionEntityFactory().makeLira()) + if let lira = await CompanionEntityFactory().makeLira() { anchor.addChild(lira) } registry.register(anchor, for: ARWorldCommandRenderer.companionID) var runtime = CompanionRuntime() @@ -433,7 +433,7 @@ final class CanonicalARRuntimeIntegrationTests: XCTestCase { let arView = ARView(frame: .zero, cameraMode: .nonAR, automaticallyConfigureSession: false) XCTAssertEqual( - renderer.render(mapper.update(companionRuntime: runtime, event: event), in: arView), + await renderer.render(mapper.update(companionRuntime: runtime, event: event), in: arView), [ .accepted("companion:follow"), .removed(CanonicalARWorldCommandMapper.discoveryID.uuidString), @@ -442,9 +442,9 @@ final class CanonicalARRuntimeIntegrationTests: XCTestCase { ) XCTAssertEqual(renderer.companionState, .follow) XCTAssertEqual(registry.count, 1) - _ = renderer.render(mapper.update(companionRuntime: runtime, event: event), in: arView) + _ = await renderer.render(mapper.update(companionRuntime: runtime, event: event), in: arView) XCTAssertEqual(registry.count, 1) - XCTAssertEqual(renderer.render(mapper.clear(), in: arView), [.cleared]) + XCTAssertEqual(await renderer.render(mapper.clear(), in: arView), [.cleared]) XCTAssertEqual(registry.count, 0) XCTAssertEqual(renderer.companionState, .idle) } diff --git a/project.yml b/project.yml index 7c4fce8..e1bf855 100644 --- a/project.yml +++ b/project.yml @@ -9,6 +9,8 @@ targets: platform: iOS sources: - App + resources: + - App/Resources/AR settings: base: INFOPLIST_KEY_NSLocationWhenInUseUsageDescription: "Waykin uses your location during an active movement session to measure distance, pace, and route progress." @@ -24,7 +26,10 @@ targets: platform: iOS sources: - App/AR + - App/Resources/AR - ARLab + resources: + - App/Resources/AR settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.waykin.arlab