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:
2026-07-06 01:15:52 -04:00
parent ddd5631ef2
commit 7274f155e9
79 changed files with 2521 additions and 11 deletions
@@ -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)
}