// // 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). /// 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 /// Equipment layer (see SYSTEM.md "The props layer"). let props: [MotionProp] 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.props = resources.motion.props ?? [] 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) // Equipment behind the figure: scene shapes and cables. drawBackgroundProps(&ctx, geo) // 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) // Joint-attached equipment over the limbs (bars, dumbbells, pads). drawAttachedProps(&ctx, geo) // 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) if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd { stroke(&ctx, [noseStart, noseEnd], color: .figureRight, width: 4) } } 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)) } // MARK: Props /// Prop joint refs → (limb, chain index): extremities are index 2, mid joints /// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with /// the reference renderer's `JOINT_LIMB`. private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [ "hand_r": (.armR, 2), "elbow_r": (.armR, 1), "hand_l": (.armL, 2), "elbow_l": (.armL, 1), "foot_r": (.legR, 2), "knee_r": (.legR, 1), "foot_l": (.legL, 2), "knee_l": (.legL, 1), ] /// The joint point a prop follows (plus the unit direction of the bone ending /// at the first joint, for perpendicular items). Nil when any referenced limb /// isn't drawn this frame. private func jointAnchor(_ geo: FigureGeometry, _ ref: PropJointRef) -> (point: CGPoint, direction: CGVector)? { var points: [CGPoint] = [] var direction: CGVector? for name in ref.names { guard let joint = Self.propJoints[name], let chain = geo.limbs[joint.limb], chain.count > joint.index else { return nil } let a = chain[joint.index - 1] let b = chain[joint.index] points.append(b) if direction == nil { let d = max(hypot(b.x - a.x, b.y - a.y), 1) direction = CGVector(dx: (b.x - a.x) / d, dy: (b.y - a.y) / d) } } guard let direction, !points.isEmpty else { return nil } let center = CGPoint(x: points.reduce(0) { $0 + $1.x } / CGFloat(points.count), y: points.reduce(0) { $0 + $1.y } / CGFloat(points.count)) return (center, direction) } private func drawBackgroundProps(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) { for prop in figure.props { switch prop.type { case "scene": for shape in prop.shapes ?? [] { drawSceneShape(&ctx, shape) } case "cable": guard let from = prop.from, from.count == 2, let to = prop.to, let anchor = jointAnchor(geo, to) else { continue } stroke(&ctx, [CGPoint(x: from[0], y: from[1]), anchor.point], color: .figureEquipment, width: prop.w ?? 2) default: break } } } /// Bars sit at a fixed world angle (default horizontal); dumbbells and pads /// default to perpendicular to the lower bone. Kept 1:1 with the reference /// renderer's `resolve_props` — change them in lockstep. private func drawAttachedProps(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) { for prop in figure.props { let defaults: (halfLen: Double, width: Double, plateR: Double) switch prop.type { case "bar": defaults = (24, 4, 0) case "dumbbell": defaults = (7, 3, 4.5) case "pad": defaults = (8, 7, 0) default: continue } guard let at = prop.at, let anchor = jointAnchor(geo, at) else { continue } let axis: CGVector if prop.type == "bar" || prop.angle != nil { axis = MotionSolver.direction(prop.angle ?? 0) } else { axis = CGVector(dx: -anchor.direction.dy, dy: anchor.direction.dx) } let h = prop.halfLen ?? defaults.halfLen let a = CGPoint(x: anchor.point.x - axis.dx * h, y: anchor.point.y - axis.dy * h) let b = CGPoint(x: anchor.point.x + axis.dx * h, y: anchor.point.y + axis.dy * h) stroke(&ctx, [a, b], color: .figureProp, width: prop.w ?? defaults.width) let plateR = prop.plateR ?? defaults.plateR if plateR > 0 { for end in [a, b] { let rect = CGRect(x: end.x - plateR, y: end.y - plateR, width: 2 * plateR, height: 2 * plateR) ctx.fill(Path(ellipseIn: rect), with: .color(.figureProp)) } } } } private func drawSceneShape(_ ctx: inout GraphicsContext, _ shape: PropSceneShape) { let color: Color = shape.color == "prop" ? .figureProp : .figureEquipment switch shape.kind { case "line": guard let pts = shape.pts else { return } stroke(&ctx, pts.compactMap { $0.count == 2 ? CGPoint(x: $0[0], y: $0[1]) : nil }, color: color, width: shape.w ?? 4) case "circle": guard let c = shape.c, c.count == 2, let r = shape.r else { return } let rect = CGRect(x: c[0] - r, y: c[1] - r, width: 2 * r, height: 2 * r) if shape.fill ?? true { ctx.fill(Path(ellipseIn: rect), with: .color(color)) } else { ctx.stroke(Path(ellipseIn: rect), with: .color(color), lineWidth: shape.w ?? 3) } case "rect": guard let x = shape.x, let y = shape.y, let w = shape.w, let h = shape.h else { return } let path = Path(roundedRect: CGRect(x: x, y: y, width: w, height: h), cornerRadius: shape.r ?? 2) ctx.fill(path, with: .color(color)) default: break } } 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. 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 } /// Right-side limbs, head, nose — the prominent stroke (`#3a3f4b`). static let figureRight = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83)) /// Left-side limbs, drawn behind — the recessive stroke (`#a9afba`). static let figureLeft = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43)) /// Working right-side parts — teal accent (`#0d9488`). static let figureRightWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) /// 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 = figure(light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52)) /// 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 }