diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b7906..a7db624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Bodyweight form guides now slowly circle around the figure while it moves, showing the exercise from every side. + The form-guide figures now have feet — heels rise on calf raises, toes point through leg lifts, and kneeling feet rest naturally. The Rotary form guide now shows a genuine torso twist, with the arms and bar swinging across the body. diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index 73e92a2..e659d13 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -1,4 +1,5 @@ import Foundation +import os import SwiftData import WatchConnectivity @@ -10,6 +11,8 @@ import WatchConnectivity /// SyncEngine write path (file → observer → cache → push back). @MainActor final class PhoneConnectivityBridge: NSObject { + private nonisolated static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "phone-bridge") + private let container: ModelContainer private let syncEngine: SyncEngine private let liveRunState: LiveRunState @@ -57,7 +60,10 @@ final class PhoneConnectivityBridge: NSObject { /// Sends the current splits + most-recent workouts to the watch. func pushAll() { guard let session, session.activationState == .activated, session.isPaired, - session.isWatchAppInstalled else { return } + session.isWatchAppInstalled else { + Self.log.info("pushAll skipped: activation=\(self.session?.activationState.rawValue ?? -1) paired=\(self.session?.isPaired ?? false) installed=\(self.session?.isWatchAppInstalled ?? false)") + return + } let splits = (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.order)]))) ?? [] @@ -97,7 +103,16 @@ final class PhoneConnectivityBridge: NSObject { editingWorkoutID: editingWorkoutID, editingSplitID: editingSplitID ) - try? session.updateApplicationContext(payload) + do { + try session.updateApplicationContext(payload) + let bytes = ((payload[WCPayload.splitsKey] as? Data)?.count ?? 0) + + ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0) + Self.log.info("pushAll: \(splits.count) splits, \(workouts.count) workouts (\(bytes) bytes)") + } catch { + // Payload-too-large / not-activated etc. — must be visible, or the watch + // just silently never hears about this state. + Self.log.error("updateApplicationContext failed: \(error, privacy: .public)") + } } /// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor. diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift index 4766612..e64ec76 100644 --- a/Workouts/ExerciseFigure/ExerciseFigureView.swift +++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift @@ -38,10 +38,19 @@ struct FigureAnimation { self.props = resources.motion.props ?? [] } + /// Seconds per full camera revolution for orbiting motions. + private static let orbitPeriod: Double = 24 + + /// Prop-free, hide-free motions slowly orbit the camera while animating — the + /// bodyweight moves. Scene equipment is a view-locked billboard and a `hide` list + /// describes one authored viewpoint, so machines keep their fixed camera. + var orbits: Bool { props.isEmpty && hide.isEmpty } + /// Drawable geometry at `time`, with limbs listed in `hide` dropped so neither they /// nor any prop riding them are drawn. func geometry(at time: Double) -> FigureGeometry { - var geo = timeline.geometry(at: time) + let yaw = orbits ? 360 * (time / Self.orbitPeriod) : 0 + var geo = timeline.geometry(at: time, yawOffset: yaw) for name in hide { if let limb = FigureLimb(rawValue: name) { geo.limbs[limb] = nil } } diff --git a/Workouts/ExerciseFigure/MotionSolver.swift b/Workouts/ExerciseFigure/MotionSolver.swift index bb72360..545a9ac 100644 --- a/Workouts/ExerciseFigure/MotionSolver.swift +++ b/Workouts/ExerciseFigure/MotionSolver.swift @@ -499,8 +499,9 @@ struct MotionTimeline { return resolved[0] } - /// The drawable geometry at wall-clock `time`. - func geometry(at time: Double) -> FigureGeometry { - MotionSolver.frameGeometry(frame(at: time), prof: profile, cam: cam).1 + /// The drawable geometry at wall-clock `time`. `yawOffset` turns the camera past + /// the exercise's authored yaw — the slow-orbit presentation. + func geometry(at time: Double, yawOffset: Double = 0) -> FigureGeometry { + MotionSolver.frameGeometry(frame(at: time), prof: profile, cam: cam + yawOffset).1 } } diff --git a/WorkoutsTests/ExerciseMotionTests.swift b/WorkoutsTests/ExerciseMotionTests.swift index b8aedd6..484b6e6 100644 --- a/WorkoutsTests/ExerciseMotionTests.swift +++ b/WorkoutsTests/ExerciseMotionTests.swift @@ -74,6 +74,25 @@ struct ExerciseMotionTests { #expect(hypot(releasedHand.x - 105, releasedHand.y - 152) > 1) } + /// Prop-free bodyweight motions slowly orbit the camera while looping; motions with + /// equipment (view-locked scene shapes) or a hide list keep their authored view. + @Test func orbitAppliesOnlyToPropFreeMotions() throws { + let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog")) + #expect(birdDog.orbits) + // Same loop phase seconds apart: the orbit yaw differs, so the projected head + // moves even though the pose is identical. + let duration = birdDog.timeline.duration + let head0 = birdDog.geometry(at: 0).headCenter + let head1 = birdDog.geometry(at: duration * 2).headCenter + #expect(hypot(head0.x - head1.x, head0.y - head1.y) > 1) + + let legPress = try #require(FigureAnimation(exerciseName: "Leg Press")) + #expect(!legPress.orbits) + let fixed0 = legPress.geometry(at: 0).headCenter + let fixed1 = legPress.geometry(at: legPress.timeline.duration * 2).headCenter + #expect(abs(fixed0.x - fixed1.x) < 1e-9 && abs(fixed0.y - fixed1.y) < 1e-9) + } + /// Every exported motion in the bundle decodes and builds a playable timeline. @Test func allBundledMotionsBuildTimelines() throws { let urls = Bundle.main.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []