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.
207 lines
8.3 KiB
Swift
207 lines
8.3 KiB
Swift
//
|
||
// 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)
|
||
}
|