Files
workouts/Workouts/ExerciseFigure/ExerciseFigureView.swift
T
rzen 7274f155e9 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.
2026-07-06 01:15:52 -04:00

207 lines
8.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
}