Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions App/AR/AREntityRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
76 changes: 73 additions & 3 deletions App/AR/ARWorldCommandRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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<Float>?
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions App/AR/CanonicalARSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 29 additions & 3 deletions App/AR/Companion/LiraARAssetCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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_<State>.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).
Expand Down Expand Up @@ -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"
}
Loading
Loading