// // 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 near/far tag per limb: the /// member nearer the camera draws in the dark ink at the heavier width, the far member /// in the light ink one step thinner and nudged by the readability offset. Working parts /// swap the same 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 /// Equipment layer (see SYSTEM.md "The props layer"). let props: [MotionProp] init?(exerciseName: String) { guard let resources = ExerciseMotionLibrary.resources(for: exerciseName), 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 ?? [] } /// 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 @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) // 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 shade = geo.shade[limb] ?? .near stroke(&ctx, points, color: ink(part, shade: shade), width: shade == .near ? 6 : 5) } } } 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", shade: .near) 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 } /// Near parts take the dark ink, far parts the light one; working parts swap for the /// accent teals. The spine (passed `.near`) is always dark unless it's the working part. private func ink(_ part: String, shade: Shade) -> Color { if figure.working.contains(part) { return shade == .near ? .figureNearWorking : .figureFarWorking } return shade == .near ? .figureNear : .figureFar } 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 } /// Near-side limbs, spine, head, nose — the prominent stroke (`#3a3f4b`). static let figureNear = figure(light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83)) /// Far-side limbs, drawn behind — the recessive stroke (`#a9afba`). static let figureFar = figure(light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43)) /// Working near-side parts — teal accent (`#0d9488`). static let figureNearWorking = figure(light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) /// Working far-side parts — light teal (`#86cfc5`), kept more muted than the near /// so the near/far hierarchy holds in both modes. static let figureFarWorking = 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 }