Add the exercise reference library, animated exercise figures, and exercise categories
Exercise Library/ holds per-exercise reference docs (setup, cues, mistakes, progressions) with SVG visuals and a Python-rendered motion pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json rigs as animated stick figures on the exercise screen. Exercises gain a warm-up/main-circuit category, timed exercises display hold time via planSummary, and a completed exercise reopens to a check screen instead of its timers.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// ExerciseFigureView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The looping animated stick-figure for the run screen's bottom half, rendered with
|
||||
/// `Canvas` + `TimelineView(.animation)` from the bundled rig data (see
|
||||
/// `Exercise Library/SYSTEM.md` for the visual language).
|
||||
///
|
||||
/// Draw order matters: left limbs behind, spine, right limbs, then the head filled
|
||||
/// opaque with the background color so overhead arms pass behind the face — plus the
|
||||
/// nose tick (gaze) and the small R/L legend that makes opposite-limb moves readable.
|
||||
|
||||
/// Everything the renderer needs for one exercise, resolved once from the bundle.
|
||||
struct FigureAnimation {
|
||||
let timeline: MotionTimeline
|
||||
/// Parts drawn in the working accent color (`arm_r`, `leg_l`, `spine`, …).
|
||||
let working: Set<String>
|
||||
/// Limbs fully occluded in this view — never drawn.
|
||||
let hide: Set<String>
|
||||
let bodyProfile: ExerciseBodyProfile
|
||||
|
||||
init?(exerciseName: String) {
|
||||
guard
|
||||
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
|
||||
let timeline = MotionTimeline(motion: resources.motion, body: resources.body)
|
||||
else { return nil }
|
||||
self.timeline = timeline
|
||||
self.working = Set(resources.motion.working ?? [])
|
||||
self.hide = Set(resources.motion.hide ?? [])
|
||||
self.bodyProfile = resources.body
|
||||
}
|
||||
|
||||
func geometry(at time: Double) -> FigureGeometry {
|
||||
MotionSolver.geometry(of: timeline.pose(at: time), body: bodyProfile, hide: hide)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom-half slot for the run screen: the looping figure when a bundled motion
|
||||
/// matches the exercise name, or empty space (the pre-figure layout) when none does.
|
||||
struct ExerciseFigureSlot: View {
|
||||
let exerciseName: String
|
||||
|
||||
@State private var figure: FigureAnimation?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let figure {
|
||||
ExerciseFigureView(figure: figure)
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task(id: exerciseName) {
|
||||
figure = FigureAnimation(exerciseName: exerciseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws one `FigureAnimation`, looping forever. The 320×180 design canvas is scaled
|
||||
/// uniformly to fit the available space (stroke widths scale with it).
|
||||
struct ExerciseFigureView: View {
|
||||
let figure: FigureAnimation
|
||||
|
||||
/// Design-canvas metrics, shared with the reference renderer.
|
||||
private static let designSize = CGSize(width: 320, height: 180)
|
||||
private static let groundY: CGFloat = 152
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation) { context in
|
||||
Canvas { graphics, size in
|
||||
var ctx = graphics
|
||||
draw(&ctx, size: size, time: context.date.timeIntervalSinceReferenceDate)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Animated form guide")
|
||||
.accessibilityHidden(false)
|
||||
}
|
||||
|
||||
private func draw(_ ctx: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let scale = min(size.width / Self.designSize.width, size.height / Self.designSize.height)
|
||||
guard scale > 0 else { return }
|
||||
ctx.translateBy(x: (size.width - Self.designSize.width * scale) / 2,
|
||||
y: (size.height - Self.designSize.height * scale) / 2)
|
||||
ctx.scaleBy(x: scale, y: scale)
|
||||
|
||||
let geo = figure.geometry(at: time)
|
||||
|
||||
// Ground line.
|
||||
stroke(&ctx, [CGPoint(x: 16, y: Self.groundY + 4),
|
||||
CGPoint(x: Self.designSize.width - 16, y: Self.groundY + 4)],
|
||||
color: .figureGround, width: 3)
|
||||
|
||||
// Left limbs (behind), spine, right limbs.
|
||||
drawLimb(&ctx, geo, .armL)
|
||||
drawLimb(&ctx, geo, .legL)
|
||||
drawSpine(&ctx, geo)
|
||||
drawLimb(&ctx, geo, .armR)
|
||||
drawLimb(&ctx, geo, .legR)
|
||||
|
||||
// Head last, filled with the background color so limbs pass behind the face.
|
||||
let r = geo.headRadius
|
||||
let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r,
|
||||
width: 2 * r, height: 2 * r)
|
||||
let headPath = Path(ellipseIn: headRect)
|
||||
ctx.fill(headPath, with: .color(.figureHeadFill))
|
||||
ctx.stroke(headPath, with: .color(.figureRight), lineWidth: 6)
|
||||
stroke(&ctx, [geo.noseStart, geo.noseEnd], color: .figureRight, width: 4)
|
||||
|
||||
drawLegend(&ctx)
|
||||
}
|
||||
|
||||
private func drawLimb(_ ctx: inout GraphicsContext, _ geo: FigureGeometry, _ limb: FigureLimb) {
|
||||
guard let points = geo.limbs[limb] else { return }
|
||||
stroke(&ctx, points, color: color(for: limb.rawValue, isLeft: limb.isLeft),
|
||||
width: limb.isLeft ? 5 : 6)
|
||||
}
|
||||
|
||||
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
|
||||
var path = Path()
|
||||
path.move(to: geo.spineStart)
|
||||
path.addQuadCurve(to: geo.spineEnd, control: geo.spineControl)
|
||||
ctx.stroke(path, with: .color(color(for: "spine", isLeft: false)),
|
||||
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
|
||||
/// Small `R — / L —` legend in the top-right corner, so opposite-limb moves are
|
||||
/// visibly opposite.
|
||||
private func drawLegend(_ ctx: inout GraphicsContext) {
|
||||
let lx = Self.designSize.width - 78
|
||||
stroke(&ctx, [CGPoint(x: lx, y: 16), CGPoint(x: lx + 16, y: 16)],
|
||||
color: .figureRight, width: 4)
|
||||
stroke(&ctx, [CGPoint(x: lx + 40, y: 16), CGPoint(x: lx + 56, y: 16)],
|
||||
color: .figureLeft, width: 4)
|
||||
let legendFont = Font.system(size: 11)
|
||||
ctx.draw(Text("R").font(legendFont).foregroundStyle(.secondary),
|
||||
at: CGPoint(x: lx + 22, y: 16), anchor: .leading)
|
||||
ctx.draw(Text("L").font(legendFont).foregroundStyle(.secondary),
|
||||
at: CGPoint(x: lx + 62, y: 16), anchor: .leading)
|
||||
}
|
||||
|
||||
private func color(for part: String, isLeft: Bool) -> Color {
|
||||
if figure.working.contains(part) {
|
||||
return isLeft ? .figureLeftWorking : .figureRightWorking
|
||||
}
|
||||
return isLeft ? .figureLeft : .figureRight
|
||||
}
|
||||
|
||||
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
|
||||
var path = Path()
|
||||
path.addLines(points)
|
||||
ctx.stroke(path, with: .color(color),
|
||||
style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Figure Palette
|
||||
|
||||
/// The reference palette (`render.py`), made dark-mode adaptive: the prominent right
|
||||
/// side stays strong (near-black → light gray), the recessive left side stays muted,
|
||||
/// and the working teals brighten/desaturate so they read on black.
|
||||
private extension Color {
|
||||
/// Right-side limbs, head, nose — the prominent stroke (`#3a3f4b`).
|
||||
static let figureRight = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.76, green: 0.79, blue: 0.83, alpha: 1)
|
||||
: UIColor(red: 0.23, green: 0.25, blue: 0.29, alpha: 1)
|
||||
})
|
||||
|
||||
/// Left-side limbs, drawn behind — the recessive stroke (`#a9afba`).
|
||||
static let figureLeft = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.36, green: 0.39, blue: 0.43, alpha: 1)
|
||||
: UIColor(red: 0.66, green: 0.69, blue: 0.73, alpha: 1)
|
||||
})
|
||||
|
||||
/// Working right-side parts — teal accent (`#0d9488`).
|
||||
static let figureRightWorking = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.18, green: 0.83, blue: 0.75, alpha: 1)
|
||||
: UIColor(red: 0.05, green: 0.58, blue: 0.53, alpha: 1)
|
||||
})
|
||||
|
||||
/// Working left-side parts — light teal (`#86cfc5`), kept more muted than the
|
||||
/// right so the R/L hierarchy holds in both modes.
|
||||
static let figureLeftWorking = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.31, green: 0.56, blue: 0.52, alpha: 1)
|
||||
: UIColor(red: 0.53, green: 0.81, blue: 0.77, alpha: 1)
|
||||
})
|
||||
|
||||
/// Ground line (`#b9bec9`).
|
||||
static let figureGround = Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0.29, green: 0.31, blue: 0.36, alpha: 1)
|
||||
: UIColor(red: 0.73, green: 0.75, blue: 0.79, alpha: 1)
|
||||
})
|
||||
|
||||
/// Opaque head fill — the screen background, so limbs pass behind the face.
|
||||
static let figureHeadFill = Color(.systemBackground)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// ExerciseMotion.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Codable mirror of the Exercise Library's rig data (see `Exercise Library/SYSTEM.md`).
|
||||
///
|
||||
/// A **body profile** is a table of bone lengths; a **motion script** is key frames of
|
||||
/// absolute joint angles (degrees, y-up: 0 = right, 90 = up, −90 = down), a pelvis
|
||||
/// `root` in 320×180 canvas coordinates, optional IK pins for planted hands/feet, and
|
||||
/// hold/tween timings. The app bundles verbatim copies exported by
|
||||
/// `render.py --export` into `Resources/ExerciseMotions/` — `body.json` plus one
|
||||
/// `<Exercise Name>.motion.json` per library entry.
|
||||
|
||||
/// Bone lengths for one figure profile (`neutral` is the default).
|
||||
struct ExerciseBodyProfile: Codable {
|
||||
let headR: Double
|
||||
let neck: Double
|
||||
let spine1: Double
|
||||
let spine2: Double
|
||||
let upperArm: Double
|
||||
let foreArm: Double
|
||||
let thigh: Double
|
||||
let shin: Double
|
||||
/// Offset separating left-limb attachments visually: `[dx, dy]` in canvas points.
|
||||
let leftOffset: [Double]
|
||||
}
|
||||
|
||||
/// One exercise's motion script: key frames plus the parts drawn in the accent color.
|
||||
struct ExerciseMotion: Codable {
|
||||
let name: String
|
||||
/// 1-based frame used for the static visual (unused by the animated renderer).
|
||||
let primary: Int?
|
||||
/// Parts (`arm_r`, `leg_l`, `spine`, …) drawn in the working accent color.
|
||||
let working: [String]?
|
||||
/// Limbs fully occluded in this view — never drawn.
|
||||
let hide: [String]?
|
||||
let frames: [MotionKeyFrame]
|
||||
}
|
||||
|
||||
/// A key frame of absolute joint angles. Limbs listed in the motion's `hide` may be
|
||||
/// absent entirely; a two-element array is `[upper, lower]` bone angles.
|
||||
struct MotionKeyFrame: Codable {
|
||||
/// Seconds held at this key frame (default 0.5).
|
||||
let hold: Double?
|
||||
/// Seconds animating to the *next* frame (default 0.6); the last frame tweens
|
||||
/// back to the first, looping.
|
||||
let tween: Double?
|
||||
/// Pelvis position, canvas coordinates `[x, y]`.
|
||||
let root: [Double]
|
||||
/// Pelvis→mid and mid→neck angles.
|
||||
let spine: [Double]
|
||||
/// Head direction from the neck joint.
|
||||
let neck: Double
|
||||
/// Nose-tick direction (the belly is on that side).
|
||||
let gaze: Double
|
||||
let armR: [Double]?
|
||||
let armL: [Double]?
|
||||
let legR: [Double]?
|
||||
let legL: [Double]?
|
||||
/// IK targets for planted extremities, keyed `hand_r`/`hand_l`/`foot_r`/`foot_l`.
|
||||
/// A pin present in two consecutive key frames stays planted through the tween.
|
||||
let pins: [String: [Double]]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hold, tween, root, spine, neck, gaze, pins
|
||||
case armR = "arm_r"
|
||||
case armL = "arm_l"
|
||||
case legR = "leg_r"
|
||||
case legL = "leg_l"
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds and decodes the bundled rig resources for an exercise, by exact name match
|
||||
/// against the exported `<Exercise Name>.motion.json` files.
|
||||
enum ExerciseMotionLibrary {
|
||||
struct Resources {
|
||||
let motion: ExerciseMotion
|
||||
let body: ExerciseBodyProfile
|
||||
}
|
||||
|
||||
/// The motion script plus the neutral body profile for `exerciseName`, or `nil`
|
||||
/// when no bundled motion matches (most exercises have none — the caller keeps
|
||||
/// its space empty).
|
||||
static func resources(for exerciseName: String) -> Resources? {
|
||||
guard
|
||||
let motionURL = Bundle.main.url(forResource: exerciseName, withExtension: "motion.json"),
|
||||
let bodyURL = Bundle.main.url(forResource: "body", withExtension: "json"),
|
||||
let motionData = try? Data(contentsOf: motionURL),
|
||||
let bodyData = try? Data(contentsOf: bodyURL),
|
||||
let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
|
||||
let profiles = try? JSONDecoder().decode([String: ExerciseBodyProfile].self, from: bodyData),
|
||||
let body = profiles["neutral"]
|
||||
else { return nil }
|
||||
return Resources(motion: motion, body: body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//
|
||||
// MotionSolver.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
/// Swift port of the Exercise Library's reference solver (`Exercise Library/render.py`):
|
||||
/// forward kinematics, analytic 2-bone IK, and angle-space tweening with shortest-path
|
||||
/// lerp and ease-in-out. The math is kept 1:1 with the Python so both renderers produce
|
||||
/// the same figure from the same data — change them in lockstep.
|
||||
///
|
||||
/// Angles are absolute world angles in degrees, y-up (0 = right, 90 = up, −90 = down);
|
||||
/// points are y-down 320×180 canvas coordinates, so dir(θ) = (cos θ, −sin θ).
|
||||
|
||||
/// The four two-bone limbs, keyed by their motion-script names.
|
||||
enum FigureLimb: String, CaseIterable {
|
||||
case armR = "arm_r"
|
||||
case armL = "arm_l"
|
||||
case legR = "leg_r"
|
||||
case legL = "leg_l"
|
||||
|
||||
var isLeft: Bool { self == .armL || self == .legL }
|
||||
|
||||
/// The key a planted extremity uses in a key frame's `pins`.
|
||||
var pinKey: String {
|
||||
switch self {
|
||||
case .armR: "hand_r"
|
||||
case .armL: "hand_l"
|
||||
case .legR: "foot_r"
|
||||
case .legL: "foot_l"
|
||||
}
|
||||
}
|
||||
|
||||
/// `[upper, lower]` bone lengths for this limb.
|
||||
func boneLengths(_ body: ExerciseBodyProfile) -> [Double] {
|
||||
switch self {
|
||||
case .armR, .armL: [body.upperArm, body.foreArm]
|
||||
case .legR, .legL: [body.thigh, body.shin]
|
||||
}
|
||||
}
|
||||
|
||||
/// The authored `[upper, lower]` angles for this limb, if present in the frame.
|
||||
func angles(in frame: MotionKeyFrame) -> [Double]? {
|
||||
switch self {
|
||||
case .armR: frame.armR
|
||||
case .armL: frame.armL
|
||||
case .legR: frame.legR
|
||||
case .legL: frame.legL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A key frame resolved to pure angles (pinned limbs replaced by their IK solution),
|
||||
/// so poses interpolate cleanly in angle space.
|
||||
struct FigurePose {
|
||||
var root: CGPoint
|
||||
var spine: [Double]
|
||||
var neck: Double
|
||||
var gaze: Double
|
||||
var limbs: [FigureLimb: [Double]]
|
||||
var pins: [String: CGPoint]
|
||||
var hold: Double
|
||||
var tween: Double
|
||||
}
|
||||
|
||||
/// Trunk FK: pelvis, spine mid, neck joint, and the offset left-limb attachments.
|
||||
struct FigureAttachments {
|
||||
let pelvis: CGPoint
|
||||
let mid: CGPoint
|
||||
let neck: CGPoint
|
||||
let shoulderL: CGPoint
|
||||
let hipL: CGPoint
|
||||
|
||||
func point(for limb: FigureLimb) -> CGPoint {
|
||||
switch limb {
|
||||
case .armR: neck
|
||||
case .armL: shoulderL
|
||||
case .legR: pelvis
|
||||
case .legL: hipL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pose resolved to drawable points.
|
||||
struct FigureGeometry {
|
||||
var headCenter: CGPoint
|
||||
var headRadius: Double
|
||||
var noseStart: CGPoint
|
||||
var noseEnd: CGPoint
|
||||
/// Quadratic Bézier through pelvis → mid → neck (control = 2·mid − (pelvis+neck)/2).
|
||||
var spineStart: CGPoint
|
||||
var spineControl: CGPoint
|
||||
var spineEnd: CGPoint
|
||||
/// Attachment → elbow/knee → extremity, per drawn limb.
|
||||
var limbs: [FigureLimb: [CGPoint]]
|
||||
}
|
||||
|
||||
enum MotionSolver {
|
||||
/// Unit vector for a y-up angle, in y-down canvas coordinates.
|
||||
static func direction(_ degrees: Double) -> CGVector {
|
||||
let r = degrees * .pi / 180
|
||||
return CGVector(dx: cos(r), dy: -sin(r))
|
||||
}
|
||||
|
||||
/// Chain FK: `[start, joint1, joint2, …]` for per-bone absolute angles.
|
||||
static func walk(from start: CGPoint, angles: [Double], lengths: [Double]) -> [CGPoint] {
|
||||
var points = [start]
|
||||
var current = start
|
||||
for (angle, length) in zip(angles, lengths) {
|
||||
let d = direction(angle)
|
||||
current = CGPoint(x: current.x + d.dx * length, y: current.y + d.dy * length)
|
||||
points.append(current)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
/// Y-up world angle (degrees) of the segment a→b.
|
||||
static func angle(from a: CGPoint, to b: CGPoint) -> Double {
|
||||
atan2(-(b.y - a.y), b.x - a.x) * 180 / .pi
|
||||
}
|
||||
|
||||
/// Analytic 2-bone IK: `[upper, lower]` angles reaching from `start` toward
|
||||
/// `target`, choosing the elbow/knee solution nearest the authored guess.
|
||||
static func ik2(start: CGPoint, target: CGPoint, upper a: Double, lower b: Double, guess: [Double]) -> [Double] {
|
||||
let dx = target.x - start.x
|
||||
let dyUp = -(target.y - start.y)
|
||||
// Clamp the reach inside the chain's annulus so acos stays defined.
|
||||
let d = max(abs(a - b) + 0.5, min(a + b - 0.01, hypot(dx, dyUp)))
|
||||
let base = atan2(dyUp, dx) * 180 / .pi
|
||||
let alpha = acos((a * a + d * d - b * b) / (2 * a * d)) * 180 / .pi
|
||||
let guessElbow = walk(from: start, angles: guess, lengths: [a, b])[1]
|
||||
var best: (distance: Double, angles: [Double])?
|
||||
for sign in [1.0, -1.0] {
|
||||
let upperAngle = base + sign * alpha
|
||||
let elbow = walk(from: start, angles: [upperAngle], lengths: [a])[1]
|
||||
let distance = Double(hypot(elbow.x - guessElbow.x, elbow.y - guessElbow.y))
|
||||
if best == nil || distance < best!.distance {
|
||||
best = (distance, [upperAngle, angle(from: elbow, to: target)])
|
||||
}
|
||||
}
|
||||
return best!.angles
|
||||
}
|
||||
|
||||
static func attachments(root: CGPoint, spine: [Double], body: ExerciseBodyProfile) -> FigureAttachments {
|
||||
let mid = walk(from: root, angles: [spine[0]], lengths: [body.spine1])[1]
|
||||
let neck = walk(from: mid, angles: [spine[1]], lengths: [body.spine2])[1]
|
||||
let ox = body.leftOffset.count > 0 ? body.leftOffset[0] : 6
|
||||
let oy = body.leftOffset.count > 1 ? body.leftOffset[1] : 2
|
||||
return FigureAttachments(
|
||||
pelvis: root, mid: mid, neck: neck,
|
||||
shoulderL: CGPoint(x: neck.x + ox, y: neck.y + oy),
|
||||
hipL: CGPoint(x: root.x + ox, y: root.y + oy)
|
||||
)
|
||||
}
|
||||
|
||||
/// Resolve a key frame to pure angles: replace each pinned limb's authored angles
|
||||
/// with its IK solution (the authored angles only pick the bend direction).
|
||||
static func normalize(_ kf: MotionKeyFrame, body: ExerciseBodyProfile) -> FigurePose {
|
||||
let root = CGPoint(x: kf.root[0], y: kf.root[1])
|
||||
var pins: [String: CGPoint] = [:]
|
||||
for (key, xy) in kf.pins ?? [:] where xy.count == 2 {
|
||||
pins[key] = CGPoint(x: xy[0], y: xy[1])
|
||||
}
|
||||
let at = attachments(root: root, spine: kf.spine, body: body)
|
||||
var limbs: [FigureLimb: [Double]] = [:]
|
||||
for limb in FigureLimb.allCases {
|
||||
guard let authored = limb.angles(in: kf) else { continue }
|
||||
if let pin = pins[limb.pinKey] {
|
||||
let lengths = limb.boneLengths(body)
|
||||
limbs[limb] = ik2(start: at.point(for: limb), target: pin,
|
||||
upper: lengths[0], lower: lengths[1], guess: authored)
|
||||
} else {
|
||||
limbs[limb] = authored
|
||||
}
|
||||
}
|
||||
return FigurePose(root: root, spine: kf.spine, neck: kf.neck, gaze: kf.gaze,
|
||||
limbs: limbs, pins: pins,
|
||||
hold: kf.hold ?? 0.5, tween: kf.tween ?? 0.6)
|
||||
}
|
||||
|
||||
/// Ease-in-out: 3t² − 2t³.
|
||||
static func ease(_ t: Double) -> Double {
|
||||
3 * t * t - 2 * t * t * t
|
||||
}
|
||||
|
||||
/// Shortest-path angular interpolation, so limbs swing in natural arcs.
|
||||
static func lerpAngle(_ a: Double, _ b: Double, _ t: Double) -> Double {
|
||||
var delta = (b - a + 180).truncatingRemainder(dividingBy: 360)
|
||||
if delta < 0 { delta += 360 }
|
||||
return a + (delta - 180) * t
|
||||
}
|
||||
|
||||
/// Interpolate two normalized poses. A pin survives the tween only if planted in
|
||||
/// BOTH neighboring key frames (so planted limbs hold exactly and releasing pins
|
||||
/// release naturally); a limb absent from either side is dropped for the tween.
|
||||
static func lerp(_ a: FigurePose, _ b: FigurePose, _ t: Double) -> FigurePose {
|
||||
var limbs: [FigureLimb: [Double]] = [:]
|
||||
for (limb, va) in a.limbs {
|
||||
guard let vb = b.limbs[limb] else { continue }
|
||||
limbs[limb] = zip(va, vb).map { lerpAngle($0, $1, t) }
|
||||
}
|
||||
var pins: [String: CGPoint] = [:]
|
||||
for (key, pa) in a.pins {
|
||||
guard let pb = b.pins[key] else { continue }
|
||||
pins[key] = CGPoint(x: pa.x + (pb.x - pa.x) * t, y: pa.y + (pb.y - pa.y) * t)
|
||||
}
|
||||
return FigurePose(
|
||||
root: CGPoint(x: a.root.x + (b.root.x - a.root.x) * t,
|
||||
y: a.root.y + (b.root.y - a.root.y) * t),
|
||||
spine: zip(a.spine, b.spine).map { lerpAngle($0, $1, t) },
|
||||
neck: lerpAngle(a.neck, b.neck, t),
|
||||
gaze: lerpAngle(a.gaze, b.gaze, t),
|
||||
limbs: limbs, pins: pins, hold: a.hold, tween: a.tween
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalized pose → drawable points. Limbs with an active pin are re-solved so
|
||||
/// planted hands/feet hold that point exactly through tweens.
|
||||
static func geometry(of pose: FigurePose, body: ExerciseBodyProfile, hide: Set<String>) -> FigureGeometry {
|
||||
let at = attachments(root: pose.root, spine: pose.spine, body: body)
|
||||
let head = walk(from: at.neck, angles: [pose.neck], lengths: [body.neck])[1]
|
||||
let noseTip = walk(from: head, angles: [pose.gaze], lengths: [body.headR + 7])[1]
|
||||
// Nose tick: 7pt outward from the head rim along the gaze direction.
|
||||
let d = max(hypot(noseTip.x - head.x, noseTip.y - head.y), 1)
|
||||
let ux = (noseTip.x - head.x) / d
|
||||
let uy = (noseTip.y - head.y) / d
|
||||
let r = body.headR
|
||||
|
||||
var limbs: [FigureLimb: [CGPoint]] = [:]
|
||||
for (limb, authored) in pose.limbs where !hide.contains(limb.rawValue) {
|
||||
let lengths = limb.boneLengths(body)
|
||||
var angles = authored
|
||||
if let pin = pose.pins[limb.pinKey] {
|
||||
angles = ik2(start: at.point(for: limb), target: pin,
|
||||
upper: lengths[0], lower: lengths[1], guess: angles)
|
||||
}
|
||||
limbs[limb] = walk(from: at.point(for: limb), angles: angles, lengths: lengths)
|
||||
}
|
||||
|
||||
return FigureGeometry(
|
||||
headCenter: head,
|
||||
headRadius: r,
|
||||
noseStart: CGPoint(x: head.x + ux * r, y: head.y + uy * r),
|
||||
noseEnd: CGPoint(x: head.x + ux * (r + 7), y: head.y + uy * (r + 7)),
|
||||
spineStart: at.pelvis,
|
||||
spineControl: CGPoint(x: 2 * at.mid.x - (at.pelvis.x + at.neck.x) / 2,
|
||||
y: 2 * at.mid.y - (at.pelvis.y + at.neck.y) / 2),
|
||||
spineEnd: at.neck,
|
||||
limbs: limbs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The full looping animation for one motion: normalized key poses plus
|
||||
/// continuous-time sampling — hold at each key frame, then an eased angle-space tween
|
||||
/// to the next; the last frame tweens back to the first.
|
||||
struct MotionTimeline {
|
||||
let poses: [FigurePose]
|
||||
let duration: Double
|
||||
|
||||
init?(motion: ExerciseMotion, body: ExerciseBodyProfile) {
|
||||
let poses = motion.frames.map { MotionSolver.normalize($0, body: body) }
|
||||
let duration = poses.reduce(0) { $0 + $1.hold + $1.tween }
|
||||
guard !poses.isEmpty, duration > 0 else { return nil }
|
||||
self.poses = poses
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
/// The pose at wall-clock `time`, looping every `duration` seconds.
|
||||
func pose(at time: Double) -> FigurePose {
|
||||
var t = time.truncatingRemainder(dividingBy: duration)
|
||||
if t < 0 { t += duration }
|
||||
for (i, pose) in poses.enumerated() {
|
||||
if t < pose.hold { return pose }
|
||||
t -= pose.hold
|
||||
if t < pose.tween {
|
||||
let next = poses[(i + 1) % poses.count]
|
||||
return MotionSolver.lerp(pose, next, MotionSolver.ease(t / pose.tween))
|
||||
}
|
||||
t -= pose.tween
|
||||
}
|
||||
return poses[0]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Bird Dog",
|
||||
"primary": 2,
|
||||
"working": ["arm_r", "leg_l"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, "tween": 0.8,
|
||||
"root": [190, 106],
|
||||
"spine": [171, 171], "neck": 187, "gaze": 205,
|
||||
"arm_r": [-90, -90], "arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], "leg_l": [-83, 0],
|
||||
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
|
||||
},
|
||||
{
|
||||
"hold": 1.4, "tween": 0.8,
|
||||
"root": [190, 106],
|
||||
"spine": [171, 171], "neck": 187, "gaze": 195,
|
||||
"arm_r": [161, 161], "arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], "leg_l": [15, 15],
|
||||
"pins": {"hand_l": [111, 154]}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Cat-Cow",
|
||||
"primary": 1,
|
||||
"working": ["spine"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.8, "tween": 0.9,
|
||||
"root": [190, 106],
|
||||
"spine": [188, 155], "neck": 185, "gaze": 164,
|
||||
"arm_r": [-90, -90], "arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], "leg_l": [-83, 0],
|
||||
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
|
||||
},
|
||||
{
|
||||
"hold": 0.8, "tween": 0.9,
|
||||
"root": [190, 106],
|
||||
"spine": [155, 192], "neck": 215, "gaze": 225,
|
||||
"arm_r": [-90, -90], "arm_l": [-90, -90],
|
||||
"leg_r": [-83, 0], "leg_l": [-83, 0],
|
||||
"pins": {"hand_r": [105, 152], "hand_l": [111, 154]}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Dead Bug",
|
||||
"primary": 2,
|
||||
"working": ["arm_r", "leg_l"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, "tween": 0.8,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_r": [90, 90], "arm_l": [90, 90],
|
||||
"leg_r": [64, -10], "leg_l": [64, -10]
|
||||
},
|
||||
{
|
||||
"hold": 0.9, "tween": 0.8,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_r": [135, 135], "arm_l": [90, 90],
|
||||
"leg_r": [64, -10], "leg_l": [16, 16]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Hollow Body Hold",
|
||||
"primary": 2,
|
||||
"working": ["spine"],
|
||||
"hide": ["arm_l"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.4, "tween": 0.8,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_r": [180, 180],
|
||||
"leg_r": [0, 0], "leg_l": [0, 0]
|
||||
},
|
||||
{
|
||||
"hold": 1.8, "tween": 0.8,
|
||||
"root": [185, 148],
|
||||
"spine": [174, 160], "neck": 140, "gaze": 50,
|
||||
"arm_r": [155, 155],
|
||||
"leg_r": [12, 12], "leg_l": [12, 12]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Leg Raises",
|
||||
"primary": 1,
|
||||
"working": ["leg_r", "leg_l"],
|
||||
"hide": ["arm_r"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, "tween": 1.4,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_l": [-2, -2],
|
||||
"leg_r": [60, 60], "leg_l": [60, 60]
|
||||
},
|
||||
{
|
||||
"hold": 0.3, "tween": 0.6,
|
||||
"root": [185, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_l": [-2, -2],
|
||||
"leg_r": [8, 8], "leg_l": [8, 8]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Plank",
|
||||
"primary": 2,
|
||||
"working": ["spine"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.4, "tween": 0.7,
|
||||
"root": [172, 114],
|
||||
"spine": [185, 185], "neck": 185, "gaze": 210,
|
||||
"arm_r": [-90, 180], "arm_l": [-90, 180],
|
||||
"leg_r": [-55, 0], "leg_l": [-55, 0]
|
||||
},
|
||||
{
|
||||
"hold": 1.8, "tween": 0.7,
|
||||
"root": [168, 137],
|
||||
"spine": [170, 170], "neck": 170, "gaze": 210,
|
||||
"arm_r": [-90, 180], "arm_l": [-90, 180],
|
||||
"leg_r": [-10, -10], "leg_l": [-10, -10]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Reverse Crunch",
|
||||
"primary": 2,
|
||||
"working": ["spine"],
|
||||
"hide": ["arm_r"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.5, "tween": 0.7,
|
||||
"root": [172, 148],
|
||||
"spine": [180, 180], "neck": 168, "gaze": 90,
|
||||
"arm_l": [-2, -2],
|
||||
"leg_r": [100, -20], "leg_l": [100, -20]
|
||||
},
|
||||
{
|
||||
"hold": 0.8, "tween": 0.9,
|
||||
"root": [166, 136],
|
||||
"spine": [195, 175], "neck": 182, "gaze": 90,
|
||||
"arm_l": [-2, -2],
|
||||
"leg_r": [115, -10], "leg_l": [115, -10]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Side Plank",
|
||||
"primary": 2,
|
||||
"working": ["spine"],
|
||||
"frames": [
|
||||
{
|
||||
"hold": 0.4, "tween": 0.8,
|
||||
"root": [174, 150],
|
||||
"spine": [162, 162], "neck": 162, "gaze": 175,
|
||||
"arm_r": [-90, 180], "arm_l": [-20, -15],
|
||||
"leg_r": [-1, -1], "leg_l": [-1, -1],
|
||||
"pins": {"hand_r": [60, 152]}
|
||||
},
|
||||
{
|
||||
"hold": 1.8, "tween": 0.8,
|
||||
"root": [174, 137],
|
||||
"spine": [170, 170], "neck": 170, "gaze": 180,
|
||||
"arm_r": [-90, 180], "arm_l": [80, 80],
|
||||
"leg_r": [-10, -10], "leg_l": [-10, -10],
|
||||
"pins": {"hand_r": [60, 152]}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"neutral": {
|
||||
"headR": 10,
|
||||
"neck": 20,
|
||||
"spine1": 43,
|
||||
"spine2": 42,
|
||||
"upperArm": 30,
|
||||
"foreArm": 30,
|
||||
"thigh": 46,
|
||||
"shin": 40,
|
||||
"leftOffset": [6, 2]
|
||||
},
|
||||
"female": {
|
||||
"headR": 9,
|
||||
"neck": 19,
|
||||
"spine1": 40,
|
||||
"spine2": 39,
|
||||
"upperArm": 27,
|
||||
"foreArm": 27,
|
||||
"thigh": 47,
|
||||
"shin": 41,
|
||||
"leftOffset": [6, 2]
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ struct ScreenshotRootView: View {
|
||||
content
|
||||
.environment(services)
|
||||
.environment(services.syncEngine)
|
||||
.environment(services.liveRunState)
|
||||
.modelContainer(services.container)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ struct ExerciseAddEditView: View {
|
||||
@State private var sets: Int
|
||||
@State private var weightReminderWeeks: Int
|
||||
@State private var weightLastUpdated: Date?
|
||||
@State private var category: ExerciseCategory
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(exercise: Exercise, split: Split) {
|
||||
@@ -51,6 +52,7 @@ struct ExerciseAddEditView: View {
|
||||
_sets = State(initialValue: exercise.sets)
|
||||
_weightReminderWeeks = State(initialValue: exercise.weightReminderTimeIntervalWeeks)
|
||||
_weightLastUpdated = State(initialValue: exercise.weightLastUpdated)
|
||||
_category = State(initialValue: exercise.categoryEnum)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -98,6 +100,19 @@ struct ExerciseAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text("Category"),
|
||||
footer: Text("The split screen groups its exercises by category, with the warm-up listed first.")
|
||||
) {
|
||||
Picker("", selection: $category) {
|
||||
ForEach(ExerciseCategory.displayOrder, id: \.self) { cat in
|
||||
Text(cat.displayName)
|
||||
.tag(cat)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section(
|
||||
header: Text("Load Type"),
|
||||
footer: Text("For bodyweight exercises choose None. For resistance or weight training select Weight. For exercises that are time oriented (like plank or meditation) select Time.")
|
||||
@@ -204,6 +219,7 @@ struct ExerciseAddEditView: View {
|
||||
doc.exercises[idx].durationSeconds = durationSecs
|
||||
doc.exercises[idx].weightLastUpdated = updatedWeightDate
|
||||
doc.exercises[idx].weightReminderWeeks = weightReminderWeeks
|
||||
doc.exercises[idx].category = category.rawValue
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
|
||||
@@ -22,8 +22,9 @@ import UIKit
|
||||
/// reaches a **Finish** page offering **One More** (append a bonus set) and **Done**
|
||||
/// (which also auto-fires after a configurable countdown, completing the exercise).
|
||||
///
|
||||
/// The paged flow occupies the **top half** of the screen; the bottom half is reserved
|
||||
/// blank for a later iteration. A row of phase dots tracks progress: purple for work,
|
||||
/// The paged flow occupies the **top half** of the screen; the bottom half shows the
|
||||
/// animated form-guide figure when the exercise has a bundled motion
|
||||
/// (`ExerciseFigureSlot`). A row of phase dots tracks progress: purple for work,
|
||||
/// teal for rest, with the current phase drawn as a wider dash.
|
||||
struct ExerciseProgressView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -85,6 +86,11 @@ struct ExerciseProgressView: View {
|
||||
/// the transient TabView snap-to-0, so it isn't reset on open.
|
||||
@State private var startsResumed: Bool
|
||||
|
||||
/// True when the exercise was already completed when this screen opened — it shows a
|
||||
/// static Completed page instead of dropping back into the timer flow. Fixed at init
|
||||
/// so completing the exercise from inside the flow doesn't swap the page mid-dismiss.
|
||||
@State private var startsCompleted: Bool
|
||||
|
||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
||||
self._doc = doc
|
||||
self.logID = logID
|
||||
@@ -102,6 +108,7 @@ struct ExerciseProgressView: View {
|
||||
// the TabView's snap-to-0 on first layout.
|
||||
let notStarted = (log?.status ?? WorkoutStatus.notStarted.rawValue) == WorkoutStatus.notStarted.rawValue
|
||||
_startsResumed = State(initialValue: !notStarted)
|
||||
_startsCompleted = State(initialValue: log?.status == WorkoutStatus.completed.rawValue)
|
||||
|
||||
let base = 1
|
||||
// Resume on the first unfinished set's work page (clamped to the last set).
|
||||
@@ -181,6 +188,16 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if startsCompleted {
|
||||
CompletedPhaseView()
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
} else {
|
||||
flowBody
|
||||
}
|
||||
}
|
||||
|
||||
private var flowBody: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Paged flow — top half.
|
||||
TabView(selection: $currentPage) {
|
||||
@@ -198,9 +215,9 @@ struct ExerciseProgressView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved space for a later iteration (set log, history, chart, …).
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// Bottom half: the looping form-guide figure when a bundled motion
|
||||
// matches this exercise; empty space otherwise.
|
||||
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
|
||||
}
|
||||
.navigationTitle(log?.exerciseName ?? "")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -677,6 +694,22 @@ private extension View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Completed Phase
|
||||
|
||||
/// Shown instead of the run flow when the exercise was already completed on open.
|
||||
private struct CompletedPhaseView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 96))
|
||||
.foregroundStyle(.green)
|
||||
Text("Completed")
|
||||
.font(.system(.title, design: .rounded, weight: .heavy))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ready Phase
|
||||
|
||||
private struct ReadyPhaseView: View {
|
||||
|
||||
@@ -387,7 +387,7 @@ struct SplitExercisePickerSheet: View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(exercise.name)
|
||||
.foregroundColor(.primary)
|
||||
Text("\(exercise.sets) × \(exercise.reps) × \(weightUnit.format(exercise.weight))")
|
||||
Text(exercise.planSummary(weightUnit: weightUnit))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user