Library detail screens now lead with the user's recorded machine settings (per-split when they disagree, empty-state card for machine-based entries) and append the weight progression chart. Starter seeds mark machine exercises with an empty machineSettings list so the settings UI lights up before first use. The figure rig gains a frontal body profile for face-on machines, props that can ride mid joints (knees/elbows), and an alternating four-frame Bird Dog loop. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
317 lines
13 KiB
Swift
317 lines
13 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).
|
||
|
||
/// 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>
|
||
/// 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
|
||
}
|