Files
workouts/Workouts/ExerciseFigure/ExerciseFigureView.swift
T
rzen e12fe31152 Add Warm-Up and Stretching activity types
Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and
.stretching (.flexibility), and retags the six starter splits that were all
mislabeled as Functional Strength:

- Warm-Up:    Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up
- Stretching: Morning Mobility, Full Body Stretch, Evening Stretch

The split editor's activity picker surfaces them automatically (CaseIterable).
Older app versions decode the new raw values as the default type — additive and
not schema-gated, so no quarantine.
2026-07-09 15:45:31 -04:00

308 lines
14 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 anatomical rig data (see
/// `Exercise Library/SYSTEM.md` for the visual language).
///
/// The solver hands back a depth-sorted draw order per frame (far parts first, the head
/// always last so overhead arms pass behind the face) and a per-vertex nearness for each
/// limb: every joint is toned by its own camera depth, so each bone is stroked with a
/// gradient between its joints' tones — the ink flows along a limb reaching in depth
/// instead of snapping, and the far member sits lighter, thinner, and nudged by the
/// readability offset. Working parts swap the near/far inks for the accent teals; the
/// spine is always dark.
/// 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>
/// Equipment layer (see SYSTEM.md "The props layer").
let props: [MotionProp]
/// Presentation zoom about the ground-center anchor — standing motions author
/// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
let zoom: Double
init?(exerciseName: String, profile: FigureProfile = .neutral) {
guard
let resources = ExerciseMotionLibrary.resources(for: exerciseName, profile: profile),
let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
else { return nil }
self.timeline = timeline
self.working = Set(resources.motion.working ?? [])
self.props = resources.motion.props ?? []
self.zoom = resources.motion.camera?.zoom ?? 1
}
/// Seconds per full camera revolution.
private static let orbitPeriod: Double = 24
/// Drawable geometry at `time` — the figure plus the resolved equipment layers.
/// Every motion slowly orbits the camera while animating: the equipment has
/// world-space 3D form and rotates with the figure, so machines turn too.
func geometry(at time: Double) -> FigureGeometry {
let yaw = 360 * (time / Self.orbitPeriod)
return timeline.geometry(at: time, yawOffset: yaw, props: props)
}
}
/// 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
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
@State private var figure: FigureAnimation?
var body: some View {
ZStack {
if let figure {
ExerciseFigureView(figure: figure)
} else {
Color.clear
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Rebuild when either the exercise or the chosen body type changes.
.task(id: [exerciseName, figureProfile.rawValue]) {
figure = FigureAnimation(exerciseName: exerciseName, profile: figureProfile)
}
}
}
/// 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)
}
}
// Purely decorative: the paged flow already conveys the exercise and its
// progress, so the looping figure is skipped by VoiceOver.
.accessibilityHidden(true)
}
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)
// Presentation zoom about the ground-center anchor, mirroring the reference
// renderer's `apply_zoom` (stroke widths scale with the geometry).
if figure.zoom != 1 {
ctx.translateBy(x: Self.designSize.width / 2, y: Self.groundY)
ctx.scaleBy(x: figure.zoom, y: figure.zoom)
ctx.translateBy(x: -Self.designSize.width / 2, y: -Self.groundY)
}
let geo = figure.geometry(at: time)
// The exercise mat: a ground-plane quad sized to the motion's footprint,
// rotating with the camera about the figure.
if let floorQuad = geo.floor, let first = floorQuad.first {
var path = Path()
path.addLines(floorQuad)
path.addLine(to: first)
path.closeSubpath()
ctx.stroke(path, with: .color(.figureGround),
style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
}
// Equipment behind the figure: scene shapes and cables.
drawProps(&ctx, geo.propsBackground)
// Parts far-to-near. The head paints last (opaque), preceded by the joint-attached
// equipment so bars/pads sit over the limbs but behind the face.
for part in geo.order {
switch part {
case "head":
drawProps(&ctx, geo.propsForeground)
drawHead(&ctx, geo)
case "spine":
drawSpine(&ctx, geo)
default:
guard let limb = FigureLimb(rawValue: part), let points = geo.limbs[limb] else { continue }
let tones = geo.nearness[limb] ?? Array(repeating: 1, count: points.count)
strokeLimb(&ctx, points, tones: tones, part: part)
}
}
}
private func drawHead(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
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(.figureNear), lineWidth: 6)
if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
stroke(&ctx, [noseStart, noseEnd], color: .figureNear, width: 4)
}
}
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
let color = ink("spine", nearness: 1)
stroke(&ctx, geo.girdle, color: color, width: 5)
stroke(&ctx, geo.pelvisBar, color: color, width: 5)
var path = Path()
path.move(to: geo.spineStart)
path.addQuadCurve(to: geo.spineEnd, control: geo.spineControl)
ctx.stroke(path, with: .color(color),
style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
}
// MARK: Props
/// Draws one resolved equipment layer. The solver hands back ready canvas
/// primitives (`MotionSolver.resolveProps`, kept 1:1 with the reference
/// renderer); the view only maps inks to the adaptive palette.
private func drawProps(_ ctx: inout GraphicsContext, _ prims: [PropPrimitive]) {
for prim in prims {
switch prim {
case .line(let points, let width, let ink):
stroke(&ctx, points, color: color(for: ink), width: width)
case .poly(let points, let width, let ink):
// An extruded scene slab: filled and outlined, so it degenerates
// to the plain line whenever the sweep collapses edge-on.
guard let first = points.first else { continue }
var path = Path()
path.addLines(points)
path.addLine(to: first)
path.closeSubpath()
ctx.fill(path, with: .color(color(for: ink)))
ctx.stroke(path, with: .color(color(for: ink)),
style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
case .circle(let center, let radius, let width, let fill, let ink):
let rect = CGRect(x: center.x - radius, y: center.y - radius,
width: 2 * radius, height: 2 * radius)
if fill {
ctx.fill(Path(ellipseIn: rect), with: .color(color(for: ink)))
} else {
ctx.stroke(Path(ellipseIn: rect), with: .color(color(for: ink)), lineWidth: width)
}
}
}
}
private func color(for ink: PropInk) -> Color {
ink == .prop ? .figureProp : .figureEquipment
}
/// A limb's ink at `nearness` (1 = near/dark side, 0 = far/light side): the near
/// and far grays, or the accent teals for a working part. The spine (passed
/// nearness 1) is the near ink unless it's working.
private func ink(_ part: String, nearness t: Double) -> Color {
.figureShaded(working: figure.working.contains(part), nearness: t)
}
/// Strokes a limb chain with a per-vertex depth gradient: each bone segment is a
/// round-capped line filled with a linear gradient between its two joints' depth
/// tones, so the ink flows smoothly along a limb that reaches toward or away from
/// the camera instead of the bone snapping to one flat tone. Adjacent segments share
/// a joint tone, so their round caps meet seamlessly. Mirrors `render.py`.
private func strokeLimb(_ ctx: inout GraphicsContext, _ points: [CGPoint],
tones: [Double], part: String) {
guard points.count >= 2, tones.count == points.count else { return }
for i in 0..<(points.count - 1) {
let a = points[i], b = points[i + 1]
let t0 = tones[i], t1 = tones[i + 1]
let style = StrokeStyle(lineWidth: 5 + (t0 + t1) / 2, lineCap: .round)
var path = Path()
path.move(to: a)
path.addLine(to: b)
if t0 == t1 || (abs(a.x - b.x) < 0.01 && abs(a.y - b.y) < 0.01) {
ctx.stroke(path, with: .color(ink(part, nearness: t0)), style: style)
} else {
let gradient = Gradient(colors: [ink(part, nearness: t0), ink(part, nearness: t1)])
ctx.stroke(path, with: .linearGradient(gradient, startPoint: a, endPoint: b), style: style)
}
}
}
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 near
/// side stays strong (near-black → light gray), the recessive far side stays muted,
/// and the working teals brighten/desaturate so they read on black. watchOS has no
/// dynamic-provider `UIColor` and renders on black, so it takes the dark variant
/// verbatim.
private extension Color {
static func figure(light: (Double, Double, Double), dark: (Double, Double, Double)) -> Color {
#if os(watchOS)
Color(red: dark.0, green: dark.1, blue: dark.2)
#else
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: dark.0, green: dark.1, blue: dark.2, alpha: 1)
: UIColor(red: light.0, green: light.1, blue: light.2, alpha: 1)
})
#endif
}
/// The gradient endpoints as `(lightMode, darkMode)` RGB pairs — the near (dark,
/// prominent) and far (light, recessive) inks, plain and working. `figureShaded`
/// blends between the far and near endpoint of a pair by a limb's nearness.
private static let nearInk = (light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83)) // #3a3f4b
private static let farInk = (light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43)) // #a9afba
private static let nearWorkingInk = (light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) // #0d9488
private static let farWorkingInk = (light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52)) // #86cfc5
static func lerp3(_ a: (Double, Double, Double), _ b: (Double, Double, Double), _ t: Double) -> (Double, Double, Double) {
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, a.2 + (b.2 - a.2) * t)
}
/// A limb's ink blended by nearness (1 = near/dark side, 0 = far/light side),
/// interpolating both the light- and dark-mode endpoints so it stays adaptive.
static func figureShaded(working: Bool, nearness t: Double) -> Color {
let far = working ? farWorkingInk : farInk
let near = working ? nearWorkingInk : nearInk
return figure(light: lerp3(far.light, near.light, t), dark: lerp3(far.dark, near.dark, t))
}
/// Near-side ink for the head, nose, and non-gradient strokes (`#3a3f4b`).
static let figureNear = figure(light: nearInk.light, dark: nearInk.dark)
/// Scene equipment — seats, benches, frames, cables (`#c5cad4`); sits behind
/// the figure, one step lighter than the ground line.
static let figureEquipment = figure(light: (0.77, 0.79, 0.83), dark: (0.25, 0.27, 0.31))
/// Joint-attached items — bars, dumbbells, pads (`#6b7180`); darker than the
/// scene so the thing being moved reads over the limbs.
static let figureProp = figure(light: (0.42, 0.44, 0.50), dark: (0.55, 0.58, 0.63))
/// Ground line (`#b9bec9`).
static let figureGround = figure(light: (0.73, 0.75, 0.79), dark: (0.29, 0.31, 0.36))
/// Opaque head fill — the screen background, so limbs pass behind the face.
#if os(watchOS)
static let figureHeadFill = Color.black
#else
static let figureHeadFill = Color(.systemBackground)
#endif
}