// // 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 /// Limbs fully occluded in this view — never drawn. let hide: Set 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) }