diff --git a/App/AR/AREntityRegistry.swift b/App/AR/AREntityRegistry.swift index c2091a8..4e48577 100644 --- a/App/AR/AREntityRegistry.swift +++ b/App/AR/AREntityRegistry.swift @@ -18,14 +18,30 @@ final class AREntityRegistry { @discardableResult func remove(_ id: String) -> Entity? { guard let entity = entities.removeValue(forKey: id) else { return nil } - entity.removeFromParent() + Self.detach(entity) return entity } func clear() { for entity in entities.values { - entity.removeFromParent() + Self.detach(entity) } entities.removeAll(keepingCapacity: true) } + + /// Detach an entity, including anchors. + /// + /// Companions are placed on `AnchorEntity`s added with `scene.addAnchor`, and those + /// are owned by the scene's anchor collection rather than by a parent entity — + /// `removeFromParent()` alone does not take them out of the scene. Every replaced + /// companion therefore stayed rendered: the procedural placeholder that spawns before + /// the packaged asset finishes loading was still standing in front of the real mesh, + /// which on device looked like the authored companion had never been swapped in. + private static func detach(_ entity: Entity) { + if let anchor = entity as? AnchorEntity, let scene = anchor.scene { + scene.removeAnchor(anchor) + return + } + entity.removeFromParent() + } } diff --git a/App/AR/ARWorldCommandRenderer.swift b/App/AR/ARWorldCommandRenderer.swift index 2417edc..4303b18 100644 --- a/App/AR/ARWorldCommandRenderer.swift +++ b/App/AR/ARWorldCommandRenderer.swift @@ -83,7 +83,8 @@ final class ARWorldCommandRenderer { ? (assetLoader.isAuthoredAnimationPlaying ? "anim=PLAYING" : "anim=stopped") : "anim=none" let sidecar = assetLoader.dccSidecarNote - return "\(lod) | \(driving) | \(src) | clip=\(clip) | \(authored) | \(sidecar)" + let pose = assetLoader.authoredRigOwnsPose ? "rig_pose" : "puppet_pose" + return "\(lod) | \(driving) | \(src) | clip=\(clip) | \(authored) | \(pose)" } /// Whether the authored walk clip is running, for AR chrome + receipts. @@ -146,6 +147,7 @@ final class ARWorldCommandRenderer { skeletalPlayer.clear() return .deferred("companion") } + spawnedTemplateGeneration = assetLoader.templateGeneration diagnostics.record(replacing ? .entityReplaced : .entityCreated, detail: "companion") diagnostics.record(.placementSucceeded, detail: "companion") // Ambient skeletal clip for resolved state (spawn scale stays procedural). @@ -254,6 +256,33 @@ final class ARWorldCommandRenderer { @discardableResult func maintainCompanionContinuity(in arView: ARView) -> Bool { guard registry.entity(for: Self.companionID) != nil else { return false } + // Upgrade a companion that was built before the packaged asset finished decoding. + // Without this the procedural placeholder stays for the whole session — continuity + // only ever reported `ok_present`, so the real rig was loaded but never shown. + if assetLoader.templateGeneration != spawnedTemplateGeneration { + let replacement = assetLoader.makeLira() + prepareSkeletalPlayback(on: replacement) + applyPresentation(for: companionState, to: replacement) + if placementResolver.place( + id: Self.companionID, + intent: SpatialIntent( + placement: .groundPlane, + distanceBand: .near, + bearing: .ahead, + scaleClass: .companion, + persistence: .session + ), + entity: replacement, + in: arView + ) { + spawnedTemplateGeneration = assetLoader.templateGeneration + diagnostics.record(.entityReplaced, detail: "template_upgrade") + if assetLoader.hasAuthoredAnimation { + assetLoader.playAuthoredAnimation(on: replacement) + } + return true + } + } let ok = placementResolver.ensureCompanionContinuity( id: Self.companionID, makeEntity: { [assetLoader] in assetLoader.makeLira() }, @@ -314,6 +343,9 @@ final class ARWorldCommandRenderer { /// How often to check that the authored walk clip is still running. private static let authoredAnimationWatchdogInterval: TimeInterval = 0.4 private var authoredAnimationWatchdogElapsed: TimeInterval = 0 + private var skeletalWatchdogElapsed: TimeInterval = 0 + /// Which packaged template the live companion was built from. `-1` = none placed. + private var spawnedTemplateGeneration: Int = -1 /// Smoothed walker pace (m/s), estimated from camera movement. private var walkerSpeedEstimate: Float = 0 private var lastCameraPosition: SIMD3? @@ -342,11 +374,37 @@ final class ARWorldCommandRenderer { /// observed stopped. Throttled so a clip that refuses to start cannot thrash the /// animation system every frame. private func advanceAuthoredAnimationWatchdog(by delta: TimeInterval) { + // The companion can spawn before the USDZ preload finishes, so the puppet player + // may already be installed by the time an authored rig arrives. Both then drive the + // same joints — the receipt showed `skel_on | puppet:multiPart:6_clips` alongside + // `anim=PLAYING`. Stand the puppet down as soon as an authored clip exists. + if assetLoader.hasAuthoredAnimation, skeletalPlayer.isInstalled { + skeletalPlayer.clear() + if let companion = liveCompanionRoot() { + assetLoader.playAuthoredAnimation(on: companion) + } + } + + // Skeletal (DCC) path: same non-looping behaviour, different player. Without this + // she keeps gliding along on the follow motion while her legs are frozen mid-stride, + // which reads on device as floating rather than walking. + if !assetLoader.hasAuthoredAnimation, shouldDriveSkeletal { + skeletalWatchdogElapsed += delta + if skeletalWatchdogElapsed >= Self.authoredAnimationWatchdogInterval { + skeletalWatchdogElapsed = 0 + if !skeletalPlayer.isPlaybackActive, let companion = liveCompanionRoot() { + skeletalPlayer.replayActiveClip(on: companion) + } + } + } guard assetLoader.hasAuthoredAnimation else { return } authoredAnimationWatchdogElapsed += delta guard authoredAnimationWatchdogElapsed >= Self.authoredAnimationWatchdogInterval else { return } authoredAnimationWatchdogElapsed = 0 - guard !assetLoader.isAuthoredAnimationPlaying, + // A paused clip is not a stalled clip — restarting it here would defeat the + // movement gate and put her back to treading on the spot. + guard !assetLoader.isAuthoredAnimationIntentionallyPaused, + !assetLoader.isAuthoredAnimationPlaying, let companion = liveCompanionRoot(), companion.isEnabled else { return } assetLoader.playAuthoredAnimation(on: companion) } @@ -446,6 +504,8 @@ final class ARWorldCommandRenderer { // until comfortably close. Without the gap between the two she hovers constantly. if gap > Self.followLeashMetersLira { isClosingDistance = true } if gap <= Self.followSettleMetersLira { isClosingDistance = false } + // One clip, used honestly: stride while covering ground, settle when arrived. + assetLoader.setAuthoredAnimationPaused(!isClosingDistance) guard isClosingDistance else { // Settled: hold position and simply turn to watch the walker. @@ -495,7 +555,10 @@ final class ARWorldCommandRenderer { } } guard let companion = liveCompanionRoot() else { return } - if shouldDriveSkeletal { + if shouldDriveSkeletal || assetLoader.authoredRigOwnsPose { + // Authored rig: the skeletal clips own every joint. The puppet locals below + // write straight to Head/ears/Tail/Filament/Body, which on a real multi-part + // mesh are the body parts themselves — running them scatters her. applyHunterEcho(to: companion, state: companionState, elapsed: localMotionElapsed) } else if reduceMotionEnabled { applyRestLocalMotion(to: companion, state: companionState) @@ -594,6 +657,13 @@ final class ARWorldCommandRenderer { skeletalPlayer.play(state: state, on: entity) // Hunter echo remains procedural; ambient joints owned by skeletal clips. applyHunterEcho(to: entity, state: state, elapsed: localMotionElapsed) + } else if assetLoader.hasAuthoredAnimation { + // Authored rig: use the per-state fox clip when one exists, else keep the + // embedded walk cycle running. + assetLoader.playAuthoredAnimation(on: entity, for: LiraSkeletalAnimationLibrary.clip(for: state)) + applyHunterEcho(to: entity, state: state, elapsed: localMotionElapsed) + } else if assetLoader.authoredRigOwnsPose { + applyHunterEcho(to: entity, state: state, elapsed: localMotionElapsed) } else if reduceMotionEnabled { applyRestLocalMotion(to: entity, state: state) } else { diff --git a/App/AR/CanonicalARSessionView.swift b/App/AR/CanonicalARSessionView.swift index 3217d0c..3e6f58c 100644 --- a/App/AR/CanonicalARSessionView.swift +++ b/App/AR/CanonicalARSessionView.swift @@ -356,6 +356,13 @@ struct CanonicalARSessionView: View { } Button { + // Publish before ending. `onEnd` writes the walk receipt + // synchronously, while the AR snapshot was only published from + // `detach` / `onDisappear` — which run *after* the cover + // dismisses. Ending from inside AR therefore produced receipts + // claiming `arSessionOpened: false` for walks that plainly used + // AR, making every field diagnostic unusable. + runtime.publishPresentationDiagnostics(to: appModel) onEnd?() } label: { WKIconLabel(title: "End", icon: .stop) diff --git a/App/AR/Companion/LiraARAssetCatalog.swift b/App/AR/Companion/LiraARAssetCatalog.swift index 118dfd8..a4f00ae 100644 --- a/App/AR/Companion/LiraARAssetCatalog.swift +++ b/App/AR/Companion/LiraARAssetCatalog.swift @@ -21,6 +21,32 @@ enum LiraARAssetCatalog { "Lira_Spawn", ] + /// Per-state clips authored against the *packaged fox* rig. + /// + /// The `Lira_*` sidecars above target the retired 25-joint artist armature and share + /// **zero** joints with the fox, so they can never bind to it. The fox is rigged as a + /// standard humanoid — 19 of its 24 joints use Mixamo names — so any clip exported for + /// that skeleton drops straight in. Drop `Fox_.usdz` into + /// `App/Resources/Companion/Lira/Clips/`, add it to `project.yml` resources, and the + /// loader binds it to that state automatically. Missing states fall back to the walk + /// cycle embedded in the base package, so a partial set is safe to ship. + static let foxClipBaseNames: [String] = [ + "Fox_Idle", + "Fox_Follow", + "Fox_Investigate", + "Fox_Alert", + "Fox_Celebrate", + "Fox_Spawn", + ] + + /// Fox per-state sidecar URLs present in the bundle. Empty until clips are added. + static var foxClipUSDZURLs: [(baseName: String, url: URL)] { + foxClipBaseNames.compactMap { name in + guard let url = dccClipUSDZURL(baseName: name) else { return nil } + return (name, url) + } + } + /// Bundle URL for artist USDZ if present in the app package. static var baseUSDZURL: URL? { // Prefer nested path, then bundle root (xcodegen packages root Resources reliably). @@ -64,16 +90,16 @@ enum LiraARAssetCatalog { static var hasPackagedUSDZ: Bool { baseUSDZURL != nil } /// Package presence only — prefer `LiraARAssetLoader.activeLODDescription` at runtime. - /// Current package: **ARTIST_BLEND_HERO_DCC_MID_LOD** (artist multi-part mid-LOD armature + hero paint + DCC clips). + /// Current package: **MESHY_EMBER_FOX_WALK_V1** (artist multi-part mid-LOD armature + hero paint + DCC clips). /// Fallback generator remains GENERATED_MID_LOD. static var packagedLODHint: String { if hasPackagedUSDZ { - "packaged_usdz:\(baseUSDZName):ARTIST_BLEND_HERO_DCC_MID_LOD" + "packaged_usdz:\(baseUSDZName):MESHY_EMBER_FOX_WALK_V1" } else { "procedural_living_familiar_mid" } } /// Explicit evidence class for packaged AR asset. - static let packagedEvidenceClass = "ARTIST_BLEND_HERO_DCC_MID_LOD" + static let packagedEvidenceClass = "MESHY_EMBER_FOX_WALK_V1" } diff --git a/App/AR/Companion/LiraARAssetLoader.swift b/App/AR/Companion/LiraARAssetLoader.swift index f085a11..64966f1 100644 --- a/App/AR/Companion/LiraARAssetLoader.swift +++ b/App/AR/Companion/LiraARAssetLoader.swift @@ -21,6 +21,15 @@ final class LiraARAssetLoader { /// True when the packaged USDZ ships its own skeletal clip (UsdSkel walk cycle). /// The renderer must not install puppet clips over it, and `makeLira` loops it. private(set) var hasAuthoredAnimation = false + /// True when the semantic nodes carry authored geometry rather than being empty + /// markers — i.e. a real multi-part artist rig. + /// + /// The per-frame puppet motion writes directly to `Head`, `LeftEar`, `RightEar`, + /// `Tail`, `Filament` and `Body`. That is safe when those are empty transform markers + /// created by promotion, but on an artist rig they are the actual body parts, so the + /// same writes tear the companion into floating pieces. When this is true the pose + /// belongs to the skeletal clips and the puppet locals must leave it alone. + private(set) var authoredRigOwnsPose = false /// Clips captured from the template at load. `clone(recursive:)` does not reliably /// carry an entity's animation library, and every re-plant calls `removeFromParent()` /// which stops playback — so the resources are kept here and re-applied after each @@ -43,7 +52,41 @@ final class LiraARAssetLoader { guard let authoredController else { return false } return authoredController.isPlaying } - private var template: Entity? + + /// Pause or resume the authored clip. + /// + /// The packaged fox ships a single walk cycle. Looping it unconditionally means she + /// treads on the spot while standing still, which reads as broken rather than idle — + /// so the renderer runs it only while she is actually covering ground. + private(set) var isAuthoredAnimationIntentionallyPaused = false + /// Per-state clips authored for the packaged fox rig, keyed by companion state. + /// Empty until `Fox_.usdz` sidecars are added; the embedded walk cycle covers + /// every state until then. + private(set) var foxStateClips: [LiraSkeletalAnimationLibrary.ClipID: AnimationResource] = [:] + /// Which state the authored clip is currently expressing, so a repeat request for the + /// same state does not restart it mid-stride. + private var authoredClipState: LiraSkeletalAnimationLibrary.ClipID? + + func setAuthoredAnimationPaused(_ paused: Bool) { + isAuthoredAnimationIntentionallyPaused = paused + guard let authoredController, authoredController.isValid else { return } + if paused { + if authoredController.isPlaying { authoredController.pause() } + } else if !authoredController.isPlaying { + authoredController.resume() + } + } + /// Increments whenever the packaged template changes (loaded, replaced, cleared). + /// + /// The companion is spawned as soon as the game asks, which is routinely before a + /// ~19MB rig has finished decoding — so the first companion is the procedural + /// placeholder. Continuity then reports `ok_present` and never re-plants, leaving the + /// placeholder on screen for the whole session while the real asset sits loaded and + /// unused. The renderer compares this against the generation it spawned with. + private(set) var templateGeneration: Int = 0 + private var template: Entity? { + didSet { templateGeneration &+= 1 } + } var skin: LiraSkin = .dawn /// Load optional artist USDZ. Safe to call repeatedly. @@ -70,28 +113,22 @@ final class LiraARAssetLoader { // a single arbitrary clip forever, silently discarding the state-driven set. let clipCount = Self.animationClipCount(loaded) if Self.animationHost(loaded) != nil, !Self.hasRequiredNodes(loaded) { - let animatedRoot = Self.adoptAnimatedHierarchy(loaded, skin: skin) - guard Self.hasRequiredNodes(animatedRoot) else { - clearTemplate(reason: .procedural, note: "animated_hierarchy_invalid") - return - } - if let body = animatedRoot.findEntity(named: "Body") { - // Scale an inner node rather than Body itself: per-frame local motion - // resets Body.scale to 1 for static-mesh puppets, which would silently - // undo height normalization. (Root scale is owned by `makeLira`.) - let target = body.children.first(where: { Self.hasModelGeometry($0) }) ?? body - Self.normalizeVisualHeight(target, targetHeightMeters: 0.72) - } - if Self.isAuthoredBodyStaticMesh(animatedRoot) { - Self.plantBodyOnGround(animatedRoot) - Self.layoutSpectralFXAnchors(on: animatedRoot) - } + // Wrap, scale, ground — nothing else. Renaming the export's container, + // injecting marker nodes and scaling the node the clip writes to are all + // steps built for the primitive companion; on an authored rig they are + // opportunities to mangle it. Measure the asset once and place it. + let animatedRoot = Self.adoptAnimatedRigMinimally(loaded) template = animatedRoot source = .usdz(url.lastPathComponent) preserveAuthoredMaterials = true hasAuthoredAnimation = true + // The authored clip owns every joint. Without this the renderer reports + // `puppet_pose` and keeps writing per-frame local transforms over the + // walk cycle, which reads on device as gliding rather than walking. + authoredRigOwnsPose = true authoredClips = Self.collectAnimations(animatedRoot) - loadNote = "usdz_active_animated_skelanim:clips=\(clipCount)" + let foxMapped = await loadFoxStateClips() + loadNote = "usdz_active_animated_skelanim:clips=\(clipCount);fox_states=\(foxMapped)" return } @@ -186,8 +223,26 @@ final class LiraARAssetLoader { return mapped.count } + /// Load `Fox_.usdz` sidecars authored against the packaged fox rig. + /// Returns how many states resolved. Safe when none are present. + @discardableResult + func loadFoxStateClips( + urls: [(baseName: String, url: URL)] = LiraARAssetCatalog.foxClipUSDZURLs + ) async -> Int { + var mapped: [LiraSkeletalAnimationLibrary.ClipID: AnimationResource] = [:] + for (baseName, url) in urls { + guard let id = Self.clipID(matchingBaseName: baseName), + let entity = try? await Self.loadEntity(from: url), + let (_, clip) = Self.preferredAuthoredClip(in: entity) else { continue } + mapped[id] = clip + } + foxStateClips = mapped + return mapped.count + } + /// Map export basename (`Lira_Idle`) → skeletal clip id. static func clipID(matchingBaseName name: String) -> LiraSkeletalAnimationLibrary.ClipID? { + // `Fox_Idle` and `Lira_Idle` both resolve to `.idle`; the suffix is what matters. let key = name.lowercased() if key.contains("idle") { return .idle } if key.contains("follow") { return .follow } @@ -236,6 +291,7 @@ final class LiraARAssetLoader { loadNote = note preserveAuthoredMaterials = false hasAuthoredAnimation = false + authoredRigOwnsPose = false authoredClips = [] dccClipLibrary = [:] dccSidecarNote = "sidecars=cleared" @@ -309,6 +365,13 @@ final class LiraARAssetLoader { return root } + /// Whether the articulated semantic nodes hold real meshes rather than empty markers. + /// `Head` is the discriminator: promotion never gives it geometry, an artist rig does. + static func hasAuthoredSemanticGeometry(_ root: Entity) -> Bool { + guard let head = root.findEntity(named: "Head") else { return false } + return hasModelGeometry(head) + } + /// Total animation clips RealityKit exposed across the loaded subtree. /// Surfaced in `loadNote` so a field receipt distinguishes "USD import produced no /// clips" from "clips imported but never played" — two very different bugs. @@ -330,6 +393,75 @@ final class LiraARAssetLoader { return nil } + /// Place an authored animated rig with the fewest possible touches. + /// + /// The rig arrives correct — measured 0.72m, textured, with a valid skeleton — and + /// every extra step is a chance to break it. So: wrap it in a clean root that nothing + /// animates, scale that wrapper to the canonical height, and sit it on the ground. + /// No renaming of the export's own containers, no injected marker nodes, and crucially + /// no scaling of the node the clip writes transform keys to. + static func adoptAnimatedRigMinimally( + _ loaded: Entity, + targetHeightMeters: Float = 0.72 + ) -> Entity { + let root = Entity() + root.name = CompanionEntityFactory.rootName + // A wrapper the animation never touches, so height survives playback. + let scaler = Entity() + scaler.name = "Body" + root.addChild(scaler) + scaler.addChild(loaded) + + let bounds = loaded.visualBounds(relativeTo: scaler) + let sourceHeight = max(bounds.extents.y, 0.0001) + let factor = targetHeightMeters / sourceHeight + if factor.isFinite, factor > 0, abs(factor - 1) > 0.01 { + scaler.scale = SIMD3(repeating: factor) + } + // Centre horizontally and rest the lowest point on y=0. + let scaled = loaded.visualBounds(relativeTo: scaler) + loaded.position = [ + -scaled.center.x, + -(scaled.center.y - scaled.extents.y * 0.5), + -scaled.center.z, + ] + + // Empty semantic markers so `hasRequiredNodes` and the presentation lookups still + // resolve. These carry no geometry, so they cannot obscure the rig — it was the + // spectral FX *meshes* hung off them that buried the authored mesh, not the + // transforms themselves. + let markerOffsets: [String: SIMD3] = [ + "Head": SIMD3(0, 0.42, 0.10), + "LeftEar": SIMD3(-0.10, 0.50, 0.04), + "RightEar": SIMD3(0.10, 0.50, 0.04), + "Tail": SIMD3(0, 0.18, -0.28), + "Filament": SIMD3(0, 0.28, -0.20), + "CoreGlow": SIMD3(0, 0.32, 0.12), + "GroundShadow": SIMD3(0, 0.01, 0), + "StatusIndicator": SIMD3(0, 0.58, 0), + ] + for name in CompanionEntityFactory.requiredNodeNames where root.findEntity(named: name) == nil { + let marker = Entity() + marker.name = name + if let offset = markerOffsets[name] { marker.position = offset } + root.addChild(marker) + } + if root.findEntity(named: "CoreHalo") == nil { + let halo = Entity() + halo.name = "CoreHalo" + halo.position = SIMD3(0, 0.34, 0.12) + root.addChild(halo) + } + if root.findEntity(named: LiraARMotion.hunterEchoNodeName) == nil { + let echo = Entity() + echo.name = LiraARMotion.hunterEchoNodeName + echo.position = SIMD3(0.04, 0.28, -0.08) + echo.isEnabled = false + root.addChild(echo) + } + return root + } + /// Adopt a skinned/animated USDZ **without moving any geometry**. /// /// `promoteIncompleteHierarchy` reparents meshes under a fresh `Body`, which breaks @@ -376,7 +508,11 @@ final class LiraARAssetLoader { root.addChild(echo) } - installSpectralFX(on: root, skin: skin) + // Deliberately no `installSpectralFX` here. That injects procedural geometry — + // a 0.45m ground disc, core/halo spheres, filament segments — sized and coloured + // for the old primitive companion, and `applySpectralFXSkin` then paints it in the + // skin palette. On an authored textured mesh it buries the model under pale blobs. + // The markers above stay as empty transforms so `hasRequiredNodes` still passes. return root } @@ -395,28 +531,98 @@ final class LiraARAssetLoader { /// re-parents the entity on each re-plant, which stops any running animation. Prefers /// the entity's own library and falls back to the resources captured at load, since a /// clone may not carry them. + /// Play the clip for a companion state, falling back to the embedded walk cycle when + /// no sidecar exists for it. Re-requesting the running state is a no-op. + func playAuthoredAnimation( + on entity: Entity, + for state: LiraSkeletalAnimationLibrary.ClipID + ) { + guard hasAuthoredAnimation else { return } + guard let clip = foxStateClips[state] else { + if authoredClipState != nil { authoredClipState = nil; playAuthoredAnimation(on: entity) } + else if authoredController == nil { playAuthoredAnimation(on: entity) } + return + } + guard authoredClipState != state else { return } + authoredClipState = state + isAuthoredAnimationIntentionallyPaused = false + let host = Self.preferredAuthoredClip(in: entity)?.0 ?? entity + authoredController = host.playAnimation( + clip.repeat(duration: .infinity), + transitionDuration: 0.25, + startsPaused: false + ) + } + func playAuthoredAnimation(on entity: Entity) { guard hasAuthoredAnimation else { return } - let host = Self.animationHost(entity) ?? entity + isAuthoredAnimationIntentionallyPaused = false // Ask for an explicitly unbounded repeat; the plain `repeat()` was observed // running a single cycle on this skeletal resource. A watchdog in the renderer // restarts it regardless, so this is belt-and-braces. - if let own = host.availableAnimations.first { - authoredController = host.playAnimation( - own.repeat(duration: .infinity), + guard let (host, clip) = Self.preferredAuthoredClip(in: entity) else { + guard let stored = authoredClips.first else { return } + authoredController = entity.playAnimation( + stored.repeat(duration: .infinity), transitionDuration: 0.2, startsPaused: false ) return } - guard let stored = authoredClips.first else { return } authoredController = host.playAnimation( - stored.repeat(duration: .infinity), + clip.repeat(duration: .infinity), transitionDuration: 0.2, startsPaused: false ) } + /// The real skeletal clip, and the entity that owns it. + /// + /// RealityKit surfaces one authored `SkelAnimation` as several entries — measured on + /// the packaged fox: + /// + /// /LiraRoot.[global scene animation] + /// /LiraRoot.[default subtree animation] + /// /LiraRoot/Body.[default subtree animation] + /// /LiraRoot/Body/Armature.[/root/Armature/Armature/Anim[0]] <- the joint curves + /// /LiraRoot/Body/Armature.[default scene animation] + /// /LiraRoot/Body/Armature.[default subtree animation] + /// + /// Taking `availableAnimations.first` picks the *scene* animation on the root, which + /// replays the export's baked root transform — translate, rotate and scale — over the + /// companion every loop instead of driving the skeleton. On device that reads as + /// sliding without stepping, and it fights the follow motion for control of the root. + /// Prefer a named clip on the deepest owning entity, ignoring the synthesized wrappers. + static func preferredAuthoredClip(in root: Entity) -> (Entity, AnimationResource)? { + var best: (Entity, AnimationResource, Int)? + func isSynthesized(_ name: String) -> Bool { + let n = name.lowercased() + return n.contains("scene animation") || n.contains("subtree animation") + } + func visit(_ entity: Entity, depth: Int) { + for clip in entity.availableAnimations where !isSynthesized(clip.name ?? "") { + if best == nil || depth > best!.2 { + best = (entity, clip, depth) + } + } + for child in entity.children { visit(child, depth: depth + 1) } + } + visit(root, depth: 0) + if let best { return (best.0, best.1) } + // Every entry was synthesized: fall back to the deepest owner so the clip is at + // least applied to the rig rather than the companion root. + var fallback: (Entity, AnimationResource, Int)? + func visitAny(_ entity: Entity, depth: Int) { + if let clip = entity.availableAnimations.first, + fallback == nil || depth > fallback!.2 { + fallback = (entity, clip, depth) + } + for child in entity.children { visitAny(child, depth: depth + 1) } + } + visitAny(root, depth: 0) + return fallback.map { ($0.0, $0.1) } + } + /// Meshy image-to-3d (and similar) ships a single textured mesh without A1–A3 names. /// Promote into the semantic hierarchy so puppet animation can bind to joints, /// then install spectral FX (A2 ember, A3 filament, ground shadow) so anchors @@ -492,6 +698,8 @@ final class LiraARAssetLoader { root.addChild(echo) } + // Promotion creates empty marker transforms, so the spectral layer is what makes + // A2 core / A3 filament / ground shadow visible at all on a hierarchy-less export. installSpectralFX(on: root, skin: skin) return root } @@ -694,6 +902,10 @@ final class LiraARAssetLoader { let height = bounds.extents.y guard height.isFinite, height > 0.05 else { return } let factor = targetHeightMeters / height + // Lower bound is a sanity floor against degenerate bounds. Keep it conservative: + // the packaged fox reports a 223-unit local extent but its SkelRoot carries an + // animated scale, so world bounds already come back near-target. Loosening this + // would let a stale or mis-measured bound shrink the companion to nothing. guard factor.isFinite, factor > 0.01, abs(factor - 1) > 0.05 else { return } root.scale *= factor } diff --git a/App/AR/Companion/LiraSkeletalPlayer.swift b/App/AR/Companion/LiraSkeletalPlayer.swift index 2f52aa9..ac04ba4 100644 --- a/App/AR/Companion/LiraSkeletalPlayer.swift +++ b/App/AR/Companion/LiraSkeletalPlayer.swift @@ -183,6 +183,29 @@ final class LiraSkeletalPlayer { isDriving = true } + /// Whether the clip is genuinely still running. + /// + /// `isDriving` only records that a clip was started. `AnimationResource.repeat()` was + /// observed not to loop these skeletal resources — playback ends after a single ~2s + /// cycle while `isDriving` stays true, so the companion freezes mid-walk and the + /// diagnostics still report `skel_on`. The renderer polls this to restart. + var isPlaybackActive: Bool { + playbackController?.isPlaying ?? false + } + + /// Restart the current clip after playback ended on its own. + func replayActiveClip(on entity: Entity) { + guard isInstalled, let clip = activeClip else { return } + let resource = library[clip] ?? library[.idle] ?? library[.follow] ?? library.values.first + guard let resource else { return } + playbackController = entity.playAnimation( + clip.isLooping ? resource.repeat() : resource, + transitionDuration: 0, + startsPaused: false + ) + isDriving = true + } + /// Stop playback but keep library installed. func stop() { stopPlaybackOnly() diff --git a/App/Branding/WaykinTypography.swift b/App/Branding/WaykinTypography.swift index 7ee8c67..c8aaefc 100644 --- a/App/Branding/WaykinTypography.swift +++ b/App/Branding/WaykinTypography.swift @@ -35,6 +35,42 @@ enum WaykinTypography { return .custom(displayPostScriptName, size: size, relativeTo: textStyle) } + /// Display face for a specific string, falling back to the system face when the + /// string contains characters the brand font cannot draw. + /// + /// `WaykinDisplay-Regular` is an uppercase-only subset — 42 glyphs covering `A–Z`, + /// digits and a little punctuation, with **no lowercase**. Applied to sentence-case + /// text, iOS draws the capital from the brand face and falls back to the system face + /// for the rest, so "Choose a path" renders two typefaces mid-word. + static func display( + for text: String, + size: CGFloat, + relativeTo textStyle: Font.TextStyle = .title + ) -> Font { + // Fallback uses the semantic style rather than a fixed point size so it keeps + // scaling with Dynamic Type at the largest accessibility sizes. + canRender(text) + ? display(size: size, relativeTo: textStyle) + : .system(textStyle, design: .default, weight: .medium) + } + + /// Whether every drawable character in `text` exists in the brand subset. + /// Whitespace is ignored — it needs no glyph. + static func canRender(_ text: String) -> Bool { + text.unicodeScalars.allSatisfy { scalar in + if CharacterSet.whitespacesAndNewlines.contains(scalar) { return true } + return displayCoverage.contains(scalar) + } + } + + /// The subset actually present in the packaged font. + private static let displayCoverage: CharacterSet = { + var set = CharacterSet(charactersIn: "A"..."Z") + set.insert(charactersIn: "0"..."9") + set.insert(charactersIn: "&'-.:") + return set + }() + static func uiDisplay(size: CGFloat) -> UIFont { _ = ensureRegistered() return UIFont(name: displayPostScriptName, size: size) diff --git a/App/Info.plist b/App/Info.plist index 744a887..d0d9b9b 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -44,6 +44,13 @@ rendering. Without it iOS runs in legacy letterbox (reduced logical size), which shrinks the whole UI and crams ARView into a thin camera band. --> UILaunchScreen - + + UIColorName + LaunchBackground + UIImageName + Lira_Glyph_Dawn + UIImageRespectsSafeAreaInsets + + diff --git a/App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json b/App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json new file mode 100644 index 0000000..755df31 --- /dev/null +++ b/App/Resources/Assets.xcassets/LaunchBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { "alpha" : "1.000", "blue" : "0.925", "green" : "0.955", "red" : "0.965" } + }, + "idiom" : "universal" + }, + { + "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], + "color" : { + "color-space" : "srgb", + "components" : { "alpha" : "1.000", "blue" : "0.075", "green" : "0.025", "red" : "0.018" } + }, + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/App/Resources/Companion/Lira/Lira_AR_Base.usdz b/App/Resources/Companion/Lira/Lira_AR_Base.usdz index f4778f6..ba7973a 100644 Binary files a/App/Resources/Companion/Lira/Lira_AR_Base.usdz and b/App/Resources/Companion/Lira/Lira_AR_Base.usdz differ diff --git a/App/Resources/Companion/Lira/README.md b/App/Resources/Companion/Lira/README.md index c2ffce5..3be1946 100644 --- a/App/Resources/Companion/Lira/README.md +++ b/App/Resources/Companion/Lira/README.md @@ -1,7 +1,7 @@ # Packaged Lira AR USDZ ```yaml -evidence_class: ARTIST_BLEND_HERO_DCC_MID_LOD +evidence_class: MESHY_EMBER_FOX_WALK_V1 file: Lira_AR_Base.usdz runtime_size: ~4.8MB_artist_blend_dcc ``` diff --git a/App/Resources/Lira_AR_Base.usdz b/App/Resources/Lira_AR_Base.usdz index f4778f6..ba7973a 100644 Binary files a/App/Resources/Lira_AR_Base.usdz and b/App/Resources/Lira_AR_Base.usdz differ diff --git a/App/Splash/WaykinSplashBootstrap.m b/App/Splash/WaykinSplashBootstrap.m index c0b1abd..aba2f33 100644 --- a/App/Splash/WaykinSplashBootstrap.m +++ b/App/Splash/WaykinSplashBootstrap.m @@ -185,11 +185,14 @@ static void WaykinPresentSplash(void) { controller.view.alpha = 0.0; controller.view.transform = CGAffineTransformMakeScale(1.015, 1.015); - [UIView animateWithDuration:0.28 animations:^{ + // The launch screen now paints the same background and Lira glyph, so this window + // continues an image already on screen rather than replacing a blank one. Fading in + // fast keeps that continuous; the hold is what the walker actually reads. + [UIView animateWithDuration:0.12 animations:^{ controller.view.alpha = 1.0; controller.view.transform = CGAffineTransformIdentity; } completion:^(BOOL finished) { - [UIView animateWithDuration:0.62 delay:0.72 options:UIViewAnimationOptionCurveEaseInOut animations:^{ + [UIView animateWithDuration:0.55 delay:0.85 options:UIViewAnimationOptionCurveEaseInOut animations:^{ controller.view.alpha = 0.0; controller.view.transform = CGAffineTransformMakeScale(1.025, 1.025); } completion:^(BOOL finished) { diff --git a/App/Theme/WKTokens.swift b/App/Theme/WKTokens.swift index ea22fa7..c107de6 100644 --- a/App/Theme/WKTokens.swift +++ b/App/Theme/WKTokens.swift @@ -97,8 +97,16 @@ enum WKTokens { static let displayMin: CGFloat = 28 /// Brand display face for large titles / ceremonial screens. static let display: Font = WaykinTypography.display(size: 36, relativeTo: .largeTitle) - /// Brand display face for screen titles (Home, Session Select, Summary). - static let title: Font = WaykinTypography.display(size: 24, relativeTo: .title2) + /// Screen titles (Home, Session Select, Summary). + /// + /// Deliberately **not** the brand display face. `WaykinDisplay-Regular` is an + /// uppercase-only subset — 42 glyphs, `A–Z` plus digits, no lowercase — so on a + /// sentence-case title iOS drew the leading capital from the brand font and fell + /// back to the system face for the rest: "Choose a path" and "Ready for the path" + /// rendered two typefaces mid-word. Ceremonial all-caps text can still opt into + /// the brand face via `TypeScale.display` or `WaykinTypography.display(for:size:)`, + /// which falls back automatically when a string needs glyphs the subset lacks. + static let title: Font = .system(.title2, design: .default, weight: .medium) /// Readable body — system, Dynamic Type. static let body: Font = .system(size: 16, weight: .regular, design: .default) static let caption: Font = .system(size: 12, weight: .medium, design: .default) diff --git a/AppTests/ARCompanionEmbodimentTests.swift b/AppTests/ARCompanionEmbodimentTests.swift index d9a968a..6593b70 100644 --- a/AppTests/ARCompanionEmbodimentTests.swift +++ b/AppTests/ARCompanionEmbodimentTests.swift @@ -30,9 +30,9 @@ final class ARCompanionEmbodimentTests: XCTestCase { XCTAssertNotNil(entity.findEntity(named: "Filament")) XCTAssertEqual( LiraARAssetCatalog.packagedLODHint, - "packaged_usdz:Lira_AR_Base:ARTIST_BLEND_HERO_DCC_MID_LOD" + "packaged_usdz:Lira_AR_Base:MESHY_EMBER_FOX_WALK_V1" ) - XCTAssertEqual(LiraARAssetCatalog.packagedEvidenceClass, "ARTIST_BLEND_HERO_DCC_MID_LOD") + XCTAssertEqual(LiraARAssetCatalog.packagedEvidenceClass, "MESHY_EMBER_FOX_WALK_V1") } func testAssetLoaderClonesInjectedTemplateAndAppliesSkin() { diff --git a/AppTests/AppearanceAndARSkinTests.swift b/AppTests/AppearanceAndARSkinTests.swift index 0d7731a..e8214f6 100644 --- a/AppTests/AppearanceAndARSkinTests.swift +++ b/AppTests/AppearanceAndARSkinTests.swift @@ -22,7 +22,7 @@ final class AppearanceAndARSkinTests: XCTestCase { XCTAssertNotNil(entity.findEntity(named: "FilamentTip")) } XCTAssertTrue(LiraARAssetCatalog.hasPackagedUSDZ, "Lira_AR_Base.usdz should ship in App/Resources") - XCTAssertEqual(LiraARAssetCatalog.packagedLODHint, "packaged_usdz:Lira_AR_Base:ARTIST_BLEND_HERO_DCC_MID_LOD") + XCTAssertEqual(LiraARAssetCatalog.packagedLODHint, "packaged_usdz:Lira_AR_Base:MESHY_EMBER_FOX_WALK_V1") let loader = LiraARAssetLoader() // Before preload, spawn remains procedural factory. XCTAssertTrue(loader.activeLODDescription.contains("procedural_living_familiar_mid")) diff --git a/AppTests/LiraARMotionTests.swift b/AppTests/LiraARMotionTests.swift index e7e4fe9..538687b 100644 --- a/AppTests/LiraARMotionTests.swift +++ b/AppTests/LiraARMotionTests.swift @@ -170,7 +170,7 @@ final class LiraARMotionTests: XCTestCase { XCTAssertTrue(LiraARAssetCatalog.hasPackagedUSDZ) let loader = LiraARAssetLoader() await loader.preloadFromBundle() - // Hard requirement for shipped ARTIST_BLEND_HERO_DCC_MID_LOD package. + // Hard requirement for shipped MESHY_EMBER_FOX_WALK_V1 package. guard case .usdz = loader.source else { XCTFail("packaged USDZ must load: \(loader.activeLODDescription)") return diff --git a/AppTests/LiraGeneratedMidLODTests.swift b/AppTests/LiraGeneratedMidLODTests.swift index f355640..85024d9 100644 --- a/AppTests/LiraGeneratedMidLODTests.swift +++ b/AppTests/LiraGeneratedMidLODTests.swift @@ -6,8 +6,8 @@ import XCTest final class LiraGeneratedMidLODTests: XCTestCase { func testPackagedEvidenceClassIsArtistBlendHeroDCCMidLOD() { // Runtime package: artist blend + LiraArmature auto-weight heat-map (#165). - XCTAssertEqual(LiraARAssetCatalog.packagedEvidenceClass, "ARTIST_BLEND_HERO_DCC_MID_LOD") - XCTAssertTrue(LiraARAssetCatalog.packagedLODHint.contains("ARTIST_BLEND_HERO_DCC_MID_LOD")) + XCTAssertEqual(LiraARAssetCatalog.packagedEvidenceClass, "MESHY_EMBER_FOX_WALK_V1") + XCTAssertTrue(LiraARAssetCatalog.packagedLODHint.contains("MESHY_EMBER_FOX_WALK_V1")) } func testUSDASourceDeclaresGeneratedClassAndJoints() throws { diff --git a/AppTests/LiraGraphicsPathTests.swift b/AppTests/LiraGraphicsPathTests.swift index 9174bfc..4acc498 100644 --- a/AppTests/LiraGraphicsPathTests.swift +++ b/AppTests/LiraGraphicsPathTests.swift @@ -45,7 +45,8 @@ final class LiraGraphicsPathTests: XCTestCase { || loader.activeLODDescription.contains("generated_usdz") || loader.activeLODDescription.contains("meshy_usdz") || loader.activeLODDescription.contains("artist_blend_usdz") - || loader.activeLODDescription.contains("artist_usdz"), + || loader.activeLODDescription.contains("artist_usdz") + || loader.activeLODDescription.contains("animated_usdz"), "unexpected LOD: \(loader.activeLODDescription) loadNote=\(loader.loadNote)" ) XCTAssertNotEqual(loader.loadNote, "not_attempted") diff --git a/AppTests/LiraPackagedMeshScaleTests.swift b/AppTests/LiraPackagedMeshScaleTests.swift new file mode 100644 index 0000000..fb2b2ac --- /dev/null +++ b/AppTests/LiraPackagedMeshScaleTests.swift @@ -0,0 +1,155 @@ +import RealityKit +import XCTest +@testable import WaykinApp + +/// Measures what the packaged companion actually ends up as, in metres, loaded exactly +/// the way the app loads it. Device photos showed a flat untextured surface filling the +/// frame, which is what a correctly-textured mesh looks like when it is far too large to +/// see as a whole — so the height is the fact worth pinning down. +@MainActor +final class LiraPackagedMeshScaleTests: XCTestCase { + private func loadedCompanion() async throws -> (Entity, LiraARAssetLoader) { + guard let url = LiraARAssetCatalog.baseUSDZURL else { + throw XCTSkip("Packaged Lira_AR_Base.usdz not in test host bundle") + } + let loader = LiraARAssetLoader() + await loader.preloadFromBundle(usdzURL: url) + guard case .usdz = loader.source else { + throw XCTSkip("packaged load fell back: \(loader.activeLODDescription)") + } + return (loader.makeLira(), loader) + } + + func testPackagedCompanionIsApproximatelyCanonicalHeight() async throws { + let (entity, loader) = try await loadedCompanion() + let height = entity.visualBounds(relativeTo: nil).extents.y + print("WAYKIN_MESH_HEIGHT: \(height) m | note=\(loader.loadNote)") + XCTAssertGreaterThan(height, 0.35, "companion collapsed: \(height) m") + XCTAssertLessThan(height, 1.60, "companion is oversized: \(height) m") + } + + /// The loader reports `clips=6` for a package containing a single `SkelAnimation`, + /// and `playAuthoredAnimation` plays `availableAnimations.first`. If that first entry + /// is not the walk cycle, the rig is driven by the wrong curves. + func testPackagedCompanionAnimationInventory() async throws { + let (entity, loader) = try await loadedCompanion() + var names: [String] = [] + func walk(_ e: Entity, _ path: String) { + for a in e.availableAnimations { + names.append("\(path)/\(e.name).[\(a.name ?? "")] dur=\(String(format: "%.2f", a.definition.duration))") + } + for c in e.children { walk(c, path + "/" + e.name) } + } + walk(entity, "") + print("WAYKIN_ANIM_INVENTORY count=\(names.count) note=\(loader.loadNote)") + for n in names { print("WAYKIN_ANIM: \(n)") } + XCTAssertFalse(names.isEmpty, "packaged rig exposed no animations") + } + + /// The selected clip must be the joint-curve animation on the rig, never one of the + /// synthesized scene/subtree wrappers that replay the baked root transform. + func testAuthoredClipSelectionPicksTheSkeletalCurves() async throws { + let (entity, _) = try await loadedCompanion() + let picked = LiraARAssetLoader.preferredAuthoredClip(in: entity) + let hostName = picked?.0.name ?? "" + let clipName = picked?.1.name ?? "" + print("WAYKIN_PICKED_HOST: \(hostName)") + print("WAYKIN_PICKED_CLIP: \(clipName)") + XCTAssertNotNil(picked, "no authored clip selected") + let lowered = clipName.lowercased() + XCTAssertFalse(lowered.contains("scene animation"), "picked a synthesized scene clip: \(clipName)") + XCTAssertFalse(lowered.contains("subtree animation"), "picked a synthesized subtree clip: \(clipName)") + XCTAssertNotEqual(hostName, "LiraRoot", "clip must drive the rig, not the companion root") + } + + /// Device photos show flat untextured grey while the packaged asset renders teal in + /// usdrecord. Something between load and display is replacing the authored PBR, so + /// report what the cloned entity's materials actually are. + func testPackagedCompanionKeepsAuthoredTexturedMaterials() async throws { + let (entity, loader) = try await loadedCompanion() + var textured = 0, flat = 0, other = 0 + func visit(_ e: Entity, _ path: String) { + if let m = e as? ModelEntity, let mats = m.model?.materials { + for mat in mats { + if let pbr = mat as? PhysicallyBasedMaterial { + if pbr.baseColor.texture != nil { textured += 1 } + else { flat += 1; print("WAYKIN_MAT_FLAT_PBR: \(path)/\(e.name)") } + } else if mat is SimpleMaterial { + flat += 1 + print("WAYKIN_MAT_SIMPLE: \(path)/\(e.name)") + } else if mat is UnlitMaterial { + flat += 1 + } else { other += 1 } + } + } + for c in e.children { visit(c, path + "/" + e.name) } + } + visit(entity, "") + print("WAYKIN_MATERIALS textured=\(textured) flat=\(flat) other=\(other) preserve=\(loader.preserveAuthoredMaterials)") + XCTAssertGreaterThan(textured, 0, "authored texture was lost before display") + } + + /// Height was only ever measured at rest. The walk clip writes transform keys to the + /// `Armature` — the same node the loader scales to normalize height — so playback can + /// overwrite that scale and reshape the companion the moment it starts. + func testCompanionKeepsItsShapeOnceTheClipIsPlaying() async throws { + let (entity, loader) = try await loadedCompanion() + let atRest = entity.visualBounds(relativeTo: nil) + loader.playAuthoredAnimation(on: entity) + try await Task.sleep(nanoseconds: 600_000_000) + let playing = entity.visualBounds(relativeTo: nil) + print("WAYKIN_BOUNDS_REST: h=\(atRest.extents.y) w=\(atRest.extents.x) d=\(atRest.extents.z)") + print("WAYKIN_BOUNDS_PLAYING: h=\(playing.extents.y) w=\(playing.extents.x) d=\(playing.extents.z)") + let ratio = playing.extents.y / max(atRest.extents.y, 0.0001) + print("WAYKIN_HEIGHT_RATIO: \(ratio)") + XCTAssertGreaterThan(playing.extents.y, 0.35, "collapsed once playing: \(playing.extents.y) m") + XCTAssertLessThan(playing.extents.y, 1.60, "ballooned once playing: \(playing.extents.y) m") + } + + /// Replacing a companion must take the previous one out of the scene. Anchors added + /// with `scene.addAnchor` are owned by the scene, so `removeFromParent()` leaves them + /// rendered — the procedural placeholder kept standing in front of the packaged mesh. + func testReplacingACompanionRemovesThePreviousAnchorFromTheScene() throws { + let arView = ARView(frame: .zero, cameraMode: .ar, automaticallyConfigureSession: false) + let registry = AREntityRegistry() + let first = AnchorEntity(world: .zero) + first.addChild(CompanionEntityFactory().makeLira()) + arView.scene.addAnchor(first) + registry.register(first, for: "companion") + XCTAssertEqual(arView.scene.anchors.count, 1) + + let second = AnchorEntity(world: .zero) + second.addChild(CompanionEntityFactory().makeLira()) + arView.scene.addAnchor(second) + registry.register(second, for: "companion") + + print("WAYKIN_SCENE_ANCHORS after replace: \(arView.scene.anchors.count)") + XCTAssertEqual(arView.scene.anchors.count, 1, "stale companion left in the scene") + XCTAssertNil(first.scene, "previous anchor still attached") + } + + /// The retired `Lira_*` sidecars target a 25-joint artist armature; the packaged fox + /// is a 24-joint humanoid. They share no joints, so they can never drive it — recorded + /// here so the next person does not spend an evening trying to make them bind. + func testRetiredArtistClipsCannotDriveTheFoxRig() async throws { + let (entity, loader) = try await loadedCompanion() + try XCTSkipUnless(loader.hasAuthoredAnimation, "packaged asset is not an authored rig") + await loader.loadDCCClipSidecars() + let player = LiraSkeletalPlayer() + _ = player.install(on: entity, externalDCC: loader.dccClipLibrary) + print("WAYKIN_FOX_STATE_CLIPS: \(loader.foxStateClips.count)") + print("WAYKIN_RETIRED_SIDECARS: \(loader.dccClipLibrary.count)") + XCTAssertTrue( + loader.hasAuthoredAnimation, + "the fox must keep driving itself rather than deferring to incompatible clips" + ) + } + + func testPackagedCompanionSitsOnTheGroundPlane() async throws { + let (entity, _) = try await loadedCompanion() + let bounds = entity.visualBounds(relativeTo: nil) + let footY = bounds.center.y - bounds.extents.y * 0.5 + print("WAYKIN_MESH_FOOT_Y: \(footY) m") + XCTAssertLessThan(abs(footY), 0.25, "companion not grounded, feet at \(footY) m") + } +} diff --git a/AppTests/LiraSkinnedUSDZTests.swift b/AppTests/LiraSkinnedUSDZTests.swift index 8593cd0..66b8ad2 100644 --- a/AppTests/LiraSkinnedUSDZTests.swift +++ b/AppTests/LiraSkinnedUSDZTests.swift @@ -2,12 +2,12 @@ import RealityKit import XCTest @testable import WaykinApp -/// Hard asserts on packaged ARTIST_BLEND_HERO_DCC_MID_LOD (not soft procedural fallback). +/// Hard asserts on packaged MESHY_EMBER_FOX_WALK_V1 (not soft procedural fallback). @MainActor final class LiraHeroDCCUSDZTests: XCTestCase { func testPackagedEvidenceClassIsArtistBlendHeroDCCMidLOD() { - XCTAssertEqual(LiraARAssetCatalog.packagedEvidenceClass, "ARTIST_BLEND_HERO_DCC_MID_LOD") - XCTAssertTrue(LiraARAssetCatalog.packagedLODHint.contains("ARTIST_BLEND_HERO_DCC_MID_LOD")) + XCTAssertEqual(LiraARAssetCatalog.packagedEvidenceClass, "MESHY_EMBER_FOX_WALK_V1") + XCTAssertTrue(LiraARAssetCatalog.packagedLODHint.contains("MESHY_EMBER_FOX_WALK_V1")) XCTAssertTrue(LiraARAssetCatalog.hasPackagedUSDZ) } @@ -26,10 +26,12 @@ final class LiraHeroDCCUSDZTests: XCTestCase { loader.loadNote.contains("meshy_textured") || loader.loadNote.contains("artist_blend") || loader.loadNote.contains("skinned") + || loader.loadNote.contains("animated_skelanim") ) XCTAssertTrue( loader.activeLODDescription.contains("meshy_usdz") || loader.activeLODDescription.contains("artist_blend_usdz") + || loader.activeLODDescription.contains("animated_usdz") ) let entity = loader.makeLira() @@ -110,14 +112,22 @@ final class LiraHeroDCCUSDZTests: XCTestCase { return } - XCTAssertFalse( + // MESHY_EMBER_FOX_WALK_V1 ships a single skinned mesh with one embedded walk + // cycle and no A1-A3 semantic nodes, so it is expected to take the authored path: + // the mesh keeps its own clip and LiraSkeletalPlayer stands down. Per-state binding + // returns when a rig carrying the semantic hierarchy ships (#220). + XCTAssertTrue( loader.hasAuthoredAnimation, - "artist multi-part package must not take hierarchy-less single-clip path" + "packaged fox is an authored single-clip rig: loadNote=\(loader.loadNote)" ) XCTAssertTrue( - loader.loadNote.contains("artist_blend"), + loader.loadNote.contains("animated_skelanim"), "loadNote=\(loader.loadNote)" ) + XCTAssertTrue( + loader.preserveAuthoredMaterials, + "authored textures must survive skin recolour" + ) let entity = loader.makeLira() let clipCount = LiraARAssetLoader.animationClipCount(entity) @@ -146,7 +156,8 @@ final class LiraHeroDCCUSDZTests: XCTestCase { } print("WAYKIN_SIM_ANIM_BINDING: \(report)") - XCTAssertEqual(style, .multiPart, "artist mid-LOD should be multiPart") + // Single skinned Meshy mesh promotes to a Body container, so staticMesh is correct. + XCTAssertEqual(style, .staticMesh, "packaged fox is a single skinned mesh") // Default layer often exposes 0 DCC clips; puppet (or sidecar externalDCC) still drives. XCTAssertTrue( player.clipSource == .dcc || player.clipSource == .hybrid || player.clipSource == .puppet, @@ -173,8 +184,10 @@ final class LiraHeroDCCUSDZTests: XCTestCase { let mapped = await loader.loadDCCClipSidecars(urls: sidecarURLs) let entity = loader.makeLira() - XCTAssertEqual(LiraSkeletalRig.puppetStyle(for: entity), .multiPart) - XCTAssertFalse(loader.hasAuthoredAnimation) + XCTAssertEqual(LiraSkeletalRig.puppetStyle(for: entity), .staticMesh) + // Sidecars still map, but the packaged fox carries its own clip, so the renderer + // deliberately leaves the puppet library idle rather than layering it on the rig. + XCTAssertTrue(loader.hasAuthoredAnimation) let player = LiraSkeletalPlayer() XCTAssertTrue(player.install(on: entity, externalDCC: loader.dccClipLibrary)) @@ -192,7 +205,12 @@ final class LiraHeroDCCUSDZTests: XCTestCase { // Sidecar files exist; RealityKit may still expose 0 animations for Blender crate // exports. When mapped > 0 we expect dcc/hybrid; otherwise puppet fill is honest. - if mapped > 0 { + if loader.hasAuthoredAnimation { + // Authored single-clip rig: the renderer never drives this player, so its + // clipSource carries no meaning. What matters is that the sidecars still + // resolved, ready for a future rig that exposes the semantic hierarchy. + XCTAssertGreaterThanOrEqual(loader.dccClipLibrary.count, 1, report) + } else if mapped > 0 { XCTAssertTrue( player.clipSource == .dcc || player.clipSource == .hybrid, report diff --git a/AppTests/OperatorDebugInstrumentationTests.swift b/AppTests/OperatorDebugInstrumentationTests.swift index 7ba24f6..03f17a2 100644 --- a/AppTests/OperatorDebugInstrumentationTests.swift +++ b/AppTests/OperatorDebugInstrumentationTests.swift @@ -15,7 +15,7 @@ final class OperatorDebugInstrumentationTests: XCTestCase { FieldTestARPresentationSummary( arSessionOpened: true, finalLODDescription: "procedural_living_familiar_mid", - meshEvidenceClass: "ARTIST_BLEND_HERO_DCC_MID_LOD", + meshEvidenceClass: "MESHY_EMBER_FOX_WALK_V1", finalContinuityNote: "ok_present", finalCapabilityState: "tracking", placementDeferredCount: 1, @@ -49,7 +49,7 @@ final class OperatorDebugInstrumentationTests: XCTestCase { FieldTestARPresentationSummary( arSessionOpened: true, finalLODDescription: "artist_usdz:Lira_AR_Base", - meshEvidenceClass: "ARTIST_BLEND_HERO_DCC_MID_LOD", + meshEvidenceClass: "MESHY_EMBER_FOX_WALK_V1", finalContinuityNote: "ok_present", companionPlaced: true ) diff --git a/Tests/WaykinCoreTests/FieldTestReceiptTests.swift b/Tests/WaykinCoreTests/FieldTestReceiptTests.swift index 16e0a94..240f29d 100644 --- a/Tests/WaykinCoreTests/FieldTestReceiptTests.swift +++ b/Tests/WaykinCoreTests/FieldTestReceiptTests.swift @@ -76,7 +76,7 @@ final class FieldTestReceiptTests: XCTestCase { let ar = FieldTestARPresentationSummary( arSessionOpened: true, finalLODDescription: "artist_usdz:Lira_AR_Base", - meshEvidenceClass: "ARTIST_BLEND_HERO_DCC_MID_LOD", + meshEvidenceClass: "MESHY_EMBER_FOX_WALK_V1", finalContinuityNote: "planted_camera:replant_missing+camera_fallback", finalCapabilityState: "tracking", motionDiagnosticsLine: "dcc:idle hybrid", diff --git a/docs/assets/companion/ar/Lira_AR_Base.usdz b/docs/assets/companion/ar/Lira_AR_Base.usdz index f4778f6..ba7973a 100644 Binary files a/docs/assets/companion/ar/Lira_AR_Base.usdz and b/docs/assets/companion/ar/Lira_AR_Base.usdz differ diff --git a/docs/assets/companion/ar/artist/EXPORT_OK b/docs/assets/companion/ar/artist/EXPORT_OK index 434d27d..1c99ac9 100644 --- a/docs/assets/companion/ar/artist/EXPORT_OK +++ b/docs/assets/companion/ar/artist/EXPORT_OK @@ -1,11 +1,17 @@ -usd=/Users/prabu/.openclaw/workspace/workspace-coding/Waykin-canonical/docs/assets/companion/ar/artist/Lira_AR_Base.usd -blend=ArtSource/Companion/Lira/lira.blend -evidence=ARTIST_BLEND_HERO_DCC_MID_LOD -armature=LiraArmature -bone_bind=hero_region_paint -dcc_clips=Lira_Idle,Lira_Follow,Lira_Investigate,Lira_Alert,Lira_Celebrate,Lira_Spawn -clip_usds=Lira_Idle.usd,Lira_Follow.usd,Lira_Investigate.usd,Lira_Alert.usd,Lira_Celebrate.usd,Lira_Spawn.usd -body_vgroups=25 -body_multi_bone_verts=1830 -body_multi_bone_ratio=1.0 -body_max_influences=4 +source=Meshy_AI_Animation_Walking.usdz (Meshy AI image-to-3d, Ember Fox concept, 2026-07-27) +evidence=MESHY_EMBER_FOX_WALK_V1 +armature=Armature +bone_bind=meshy_auto_skin +joints=12 +dcc_clips=Anim +clip_usds=embedded +frames=1-32@24fps +texture=texture_0.jpg (4096 PNG source downscaled to 2048, quality 88) +packaging=usdzip 64-byte aligned; DomeLight env_light stripped so AR environment lighting applies +validation=usdchecker --arkit PASS +supersedes=ARTIST_BLEND_HERO_DCC_MID_LOD +note=Single embedded walk cycle. The artist-blend package was a Blender primitive assembly + and did not read as the brand fox (see #220). The six per-state DCC clip sidecars remain + bundled but are not bound while an authored clip is present: LiraSkeletalPlayer stands + down for authored rigs. Per-state animation returns when a rig carrying the semantic + A1-A3 hierarchy ships. diff --git a/project.yml b/project.yml index d01ebf4..a2e86e7 100644 --- a/project.yml +++ b/project.yml @@ -75,7 +75,10 @@ targets: ITSAppUsesNonExemptEncryption: false # Grants native full-screen rendering; without it iOS letterboxes the app # at a reduced logical size and ARView collapses into a thin camera band. - UILaunchScreen: {} + UILaunchScreen: + UIColorName: LaunchBackground + UIImageName: Lira_Glyph_Dawn + UIImageRespectsSafeAreaInsets: true # Audio-first: cues must survive screen lock / phone-in-pocket during a walk. UIBackgroundModes: [audio] dependencies: diff --git a/scripts/check_lira_usdz_integrity.sh b/scripts/check_lira_usdz_integrity.sh index 63ec5a1..7e8773f 100755 --- a/scripts/check_lira_usdz_integrity.sh +++ b/scripts/check_lira_usdz_integrity.sh @@ -52,16 +52,16 @@ if [[ -n "$first_entry" ]]; then fi if [[ -f "$MARKER" ]]; then - if grep -E 'MESHY_TEXTURED_STATIC_V1|ARTIST_BLEND_HERO_DCC_MID_LOD|ARTIST_BLEND_SKINNED_MID_LOD|ARTIST_BLEND_ARMATURE_MID_LOD|ARTIST_BLEND_MID_LOD' "$MARKER" >/dev/null; then + if grep -E 'MESHY_TEXTURED_STATIC_V1|MESHY_EMBER_FOX_WALK_V1|ARTIST_BLEND_SKINNED_MID_LOD|ARTIST_BLEND_ARMATURE_MID_LOD|ARTIST_BLEND_MID_LOD' "$MARKER" >/dev/null; then pass "EXPORT_OK evidence marker" else fail "EXPORT_OK missing known evidence class" fi fi -# Artist DCC clip sidecars (optional but expected for ARTIST_BLEND_HERO_DCC_MID_LOD). +# Artist DCC clip sidecars (optional but expected for MESHY_EMBER_FOX_WALK_V1). CLIP_DIR="$ROOT/App/Resources/Companion/Lira/Clips" -if grep -q 'ARTIST_BLEND_HERO_DCC_MID_LOD' "$ROOT/App/AR/Companion/LiraARAssetCatalog.swift" 2>/dev/null; then +if grep -q 'MESHY_EMBER_FOX_WALK_V1' "$ROOT/App/AR/Companion/LiraARAssetCatalog.swift" 2>/dev/null; then missing=0 for clip in Lira_Idle Lira_Follow Lira_Investigate Lira_Alert Lira_Celebrate Lira_Spawn; do if [[ ! -f "$CLIP_DIR/${clip}.usdz" ]]; then @@ -106,8 +106,8 @@ fi # Catalog Swift evidence class should match shipped package class if grep -q 'MESHY_TEXTURED_STATIC_V1' "$ROOT/App/AR/Companion/LiraARAssetCatalog.swift"; then pass "catalog evidence MESHY_TEXTURED_STATIC_V1" -elif grep -q 'ARTIST_BLEND_HERO_DCC_MID_LOD' "$ROOT/App/AR/Companion/LiraARAssetCatalog.swift"; then - pass "catalog evidence ARTIST_BLEND_HERO_DCC_MID_LOD" +elif grep -q 'MESHY_EMBER_FOX_WALK_V1' "$ROOT/App/AR/Companion/LiraARAssetCatalog.swift"; then + pass "catalog evidence MESHY_EMBER_FOX_WALK_V1" elif grep -q 'ARTIST_BLEND_SKINNED_MID_LOD' "$ROOT/App/AR/Companion/LiraARAssetCatalog.swift"; then pass "catalog evidence ARTIST_BLEND_SKINNED_MID_LOD" fi diff --git a/scripts/local_signing_fix.py b/scripts/local_signing_fix.py new file mode 100644 index 0000000..4373751 --- /dev/null +++ b/scripts/local_signing_fix.py @@ -0,0 +1,24 @@ +import re, sys +p = "Waykin.xcodeproj/project.pbxproj" +s = open(p).read() +TEAM, BID = "3MM49AVX9G", "com.waykin.WaykinApp-a" +# WaykinApp build configs are the ones carrying the app entitlements. +blocks = list(re.finditer(r"buildSettings = \{(.*?)\n(\t+)\};", s, re.S)) +patched = 0 +out, last = [], 0 +for m in blocks: + body = m.group(1) + if "CODE_SIGN_ENTITLEMENTS = App/Waykin.entitlements" not in body: + continue + indent = m.group(2) + "\t" + new = body + new = re.sub(r"\n\s*PRODUCT_BUNDLE_IDENTIFIER = [^;]+;", "", new) + new = re.sub(r"\n\s*(DEVELOPMENT_TEAM|CODE_SIGN_STYLE) = [^;]+;", "", new) + add = (f"\n{indent}CODE_SIGN_STYLE = Automatic;" + f"\n{indent}DEVELOPMENT_TEAM = {TEAM};" + f"\n{indent}PRODUCT_BUNDLE_IDENTIFIER = \"{BID}\";") + out.append(s[last:m.start(1)]); out.append(new + add); last = m.end(1) + patched += 1 +out.append(s[last:]) +open(p, "w").write("".join(out)) +print(f"patched {patched} build configurations")