Author the machine exercise circuit with props and corrected hip machine motions

Fourteen Planet Fitness machine exercises join the rig library, each with
authored motion, info page, and schematic equipment via the new props layer
(scene shapes, cables, bars, pads) rendered in lockstep by render.py and the
in-app figure renderer. Abductor and Adductor are authored face-on with the
real seated machine motion — knees-bent legs swinging apart/together against
knee pads — replacing the earlier middle-split depiction. The watch target
now bundles the figure renderer and motion rigs too.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 16:29:31 -04:00
parent 888852cc2e
commit fce8fa4c17
130 changed files with 4116 additions and 82 deletions
+142 -43
View File
@@ -13,7 +13,7 @@ import SwiftUI
///
/// 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.
/// nose tick (gaze).
/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
@@ -22,6 +22,8 @@ struct FigureAnimation {
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) {
@@ -32,6 +34,7 @@ struct FigureAnimation {
self.timeline = timeline
self.working = Set(resources.motion.working ?? [])
self.hide = Set(resources.motion.hide ?? [])
self.props = resources.motion.props ?? []
self.bodyProfile = resources.body
}
@@ -96,6 +99,9 @@ struct ExerciseFigureView: View {
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)
@@ -103,6 +109,9 @@ struct ExerciseFigureView: View {
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,
@@ -110,9 +119,9 @@ struct ExerciseFigureView: View {
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)
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) {
@@ -129,19 +138,103 @@ struct ExerciseFigureView: View {
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)
// MARK: Props
/// The extremity point a prop follows (plus the lower-bone unit direction 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 limb = FigureLimb.allCases.first(where: { $0.pinKey == name }),
let chain = geo.limbs[limb], chain.count >= 2 else { return nil }
let a = chain[chain.count - 2]
let b = chain[chain.count - 1]
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 {
@@ -163,44 +256,50 @@ struct ExerciseFigureView: View {
/// 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.
/// 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 = 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)
})
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 = 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)
})
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 = 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)
})
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 = 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)
})
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 = 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)
})
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
}
+148
View File
@@ -0,0 +1,148 @@
//
// ExerciseInfo.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
/// Parsed form of an exercise's authored `info.md` (see `Exercise Library/SYSTEM.md`):
/// a one-paragraph summary, the Category/Type/Targets metadata bullets, then the
/// instructional sections (Setup, Execution, Cues, Common Mistakes, Progression).
/// The library's markdown is uniform, so this parses the known shape rather than
/// general Markdown SwiftUI's inline-Markdown `Text` can't render headers/lists,
/// and the app wants the structure (target chips, numbered steps) anyway.
struct ExerciseInfo {
/// The intro paragraph(s) between the `# Title` and the metadata bullets.
var summary: String
var category: String?
var type: String?
/// The comma-separated `**Targets:**` values, split for chip rendering.
var targets: [String]
var sections: [Section]
struct Section {
var title: String
var items: [Item]
}
/// One block within a section, preserving the authored list flavor: numbered
/// steps render with their ordinal, bullets with a dot, paragraphs plain.
enum Item {
case step(String)
case bullet(String)
case paragraph(String)
var text: String {
switch self {
case .step(let s), .bullet(let s), .paragraph(let s): s
}
}
}
}
extension ExerciseInfo {
/// Parse the library's `info.md` shape. Hard-wrapped lines are rejoined: a line
/// that doesn't start a new block (heading, list marker, blank) continues the
/// previous one, matching how Markdown renders soft breaks.
static func parse(markdown: String) -> ExerciseInfo {
var summaryLines: [String] = []
var category: String?
var type: String?
var targets: [String] = []
var sections: [Section] = []
var currentSection: Section?
var currentItem: Item?
func metadataValue(_ line: String, key: String) -> String? {
let prefix = "- **\(key):**"
guard line.hasPrefix(prefix) else { return nil }
return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
}
func flushItem() {
guard let item = currentItem else { return }
currentItem = nil
if currentSection != nil {
currentSection?.items.append(item)
} else if case .paragraph(let text) = item {
summaryLines.append(text)
}
}
func flushSection() {
flushItem()
if let section = currentSection { sections.append(section) }
currentSection = nil
}
func append(to item: Item?, _ text: String) -> Item? {
switch item {
case .step(let s): .step(s + " " + text)
case .bullet(let s): .bullet(s + " " + text)
case .paragraph(let s): .paragraph(s + " " + text)
case nil: .paragraph(text)
}
}
for rawLine in markdown.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.isEmpty {
flushItem()
} else if line.hasPrefix("## ") {
flushSection()
currentSection = Section(title: String(line.dropFirst(3)), items: [])
} else if line.hasPrefix("# ") {
continue // the document title duplicates the exercise name
} else if currentSection == nil,
let value = metadataValue(line, key: "Category") {
flushItem()
category = value
} else if currentSection == nil,
let value = metadataValue(line, key: "Type") {
flushItem()
type = value
} else if currentSection == nil,
let value = metadataValue(line, key: "Targets") {
flushItem()
targets = value.components(separatedBy: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
} else if let range = line.range(of: #"^\d+\.\s+"#, options: .regularExpression) {
flushItem()
currentItem = .step(String(line[range.upperBound...]))
} else if line.hasPrefix("- ") {
flushItem()
currentItem = .bullet(String(line.dropFirst(2)))
} else {
// Continuation of a hard-wrapped block (or the start of a paragraph).
currentItem = append(to: currentItem, line)
}
}
flushSection()
return ExerciseInfo(
summary: summaryLines.joined(separator: "\n\n"),
category: category,
type: type,
targets: targets,
sections: sections
)
}
}
/// Finds and parses the bundled `<Exercise Name>.info.md` exported next to the
/// motion rigs by `render.py --export`.
enum ExerciseInfoLibrary {
/// The parsed info for `exerciseName`, or `nil` when none is bundled.
static func info(for exerciseName: String) -> ExerciseInfo? {
guard
let url = Bundle.main.url(forResource: exerciseName, withExtension: "info.md"),
let markdown = try? String(contentsOf: url, encoding: .utf8)
else { return nil }
return ExerciseInfo.parse(markdown: markdown)
}
}
+75 -2
View File
@@ -39,9 +39,81 @@ struct ExerciseMotion: Codable {
let working: [String]?
/// Limbs fully occluded in this view never drawn.
let hide: [String]?
/// Equipment layer: scene shapes and cables behind the figure, joint-attached
/// items (bar/dumbbell/pad) over the limbs. See SYSTEM.md "The props layer".
let props: [MotionProp]?
let frames: [MotionKeyFrame]
}
/// A prop's joint reference: one extremity (`"hand_r"`) or the midpoint of two
/// (`["foot_r", "foot_l"]`). Extremity keys match the pin keys.
enum PropJointRef: Codable {
case single(String)
case midpoint([String])
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let name = try? container.decode(String.self) {
self = .single(name)
} else {
self = .midpoint(try container.decode([String].self))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .single(let name): try container.encode(name)
case .midpoint(let names): try container.encode(names)
}
}
var names: [String] {
switch self {
case .single(let name): [name]
case .midpoint(let names): names
}
}
}
/// One static shape of a `scene` prop, in canvas coordinates.
struct PropSceneShape: Codable {
let kind: String // "line" | "circle" | "rect"
/// line: polyline points; `w` is the stroke width.
let pts: [[Double]]?
let w: Double?
/// circle: center + radius; `fill` false draws an outline.
let c: [Double]?
let r: Double?
let fill: Bool?
/// rect: origin + size (`w`/`h`), corner radius `r`; always filled.
let x: Double?
let y: Double?
let h: Double?
/// Optional palette override: `"prop"` for the darker attached-item gray.
let color: String?
}
/// One equipment prop. `type` selects the flavor; the other fields apply per type
/// (mirroring the reference renderer's `resolve_props`).
struct MotionProp: Codable {
let type: String // "scene" | "cable" | "bar" | "dumbbell" | "pad"
/// scene: the static shapes.
let shapes: [PropSceneShape]?
/// cable: fixed anchor `[x, y]` moving joint `to`.
let from: [Double]?
let to: PropJointRef?
/// bar/dumbbell/pad: the joint(s) the item is centered on.
let at: PropJointRef?
/// Fixed world angle (degrees, y-up). Default: bars are horizontal;
/// dumbbells/pads sit perpendicular to the lower bone.
let angle: Double?
let halfLen: Double?
let w: Double?
/// End-disc radius (dumbbell plates default 4.5; bars none).
let plateR: Double?
}
/// A key frame of absolute joint angles. Limbs listed in the motion's `hide` may be
/// absent entirely; a two-element array is `[upper, lower]` bone angles.
struct MotionKeyFrame: Codable {
@@ -56,8 +128,9 @@ struct MotionKeyFrame: Codable {
let spine: [Double]
/// Head direction from the neck joint.
let neck: Double
/// Nose-tick direction (the belly is on that side).
let gaze: Double
/// Nose-tick direction (the belly is on that side). Absent when the figure
/// faces the viewer no nose is drawn.
let gaze: Double?
let armR: [Double]?
let armL: [Double]?
let legR: [Double]?
+17 -11
View File
@@ -60,7 +60,8 @@ struct FigurePose {
var root: CGPoint
var spine: [Double]
var neck: Double
var gaze: Double
/// Absent for figures facing the viewer no nose tick.
var gaze: Double?
var limbs: [FigureLimb: [Double]]
var pins: [String: CGPoint]
var hold: Double
@@ -89,8 +90,9 @@ struct FigureAttachments {
struct FigureGeometry {
var headCenter: CGPoint
var headRadius: Double
var noseStart: CGPoint
var noseEnd: CGPoint
/// Nil when the pose has no gaze (face-on view).
var noseStart: CGPoint?
var noseEnd: CGPoint?
/// Quadratic Bézier through pelvis mid neck (control = 2·mid (pelvis+neck)/2).
var spineStart: CGPoint
var spineControl: CGPoint
@@ -213,7 +215,7 @@ enum MotionSolver {
y: a.root.y + (b.root.y - a.root.y) * t),
spine: zip(a.spine, b.spine).map { lerpAngle($0, $1, t) },
neck: lerpAngle(a.neck, b.neck, t),
gaze: lerpAngle(a.gaze, b.gaze, t),
gaze: (a.gaze != nil && b.gaze != nil) ? lerpAngle(a.gaze!, b.gaze!, t) : nil,
limbs: limbs, pins: pins, hold: a.hold, tween: a.tween
)
}
@@ -223,12 +225,16 @@ enum MotionSolver {
static func geometry(of pose: FigurePose, body: ExerciseBodyProfile, hide: Set<String>) -> FigureGeometry {
let at = attachments(root: pose.root, spine: pose.spine, body: body)
let head = walk(from: at.neck, angles: [pose.neck], lengths: [body.neck])[1]
let noseTip = walk(from: head, angles: [pose.gaze], lengths: [body.headR + 7])[1]
// Nose tick: 7pt outward from the head rim along the gaze direction.
let d = max(hypot(noseTip.x - head.x, noseTip.y - head.y), 1)
let ux = (noseTip.x - head.x) / d
let uy = (noseTip.y - head.y) / d
let r = body.headR
// Nose tick: 7pt outward from the head rim along the gaze direction
// (omitted for face-on poses without a gaze).
var noseStart: CGPoint?
var noseEnd: CGPoint?
if let gaze = pose.gaze {
let d = direction(gaze)
noseStart = CGPoint(x: head.x + d.dx * r, y: head.y + d.dy * r)
noseEnd = CGPoint(x: head.x + d.dx * (r + 7), y: head.y + d.dy * (r + 7))
}
var limbs: [FigureLimb: [CGPoint]] = [:]
for (limb, authored) in pose.limbs where !hide.contains(limb.rawValue) {
@@ -244,8 +250,8 @@ enum MotionSolver {
return FigureGeometry(
headCenter: head,
headRadius: r,
noseStart: CGPoint(x: head.x + ux * r, y: head.y + uy * r),
noseEnd: CGPoint(x: head.x + ux * (r + 7), y: head.y + uy * (r + 7)),
noseStart: noseStart,
noseEnd: noseEnd,
spineStart: at.pelvis,
spineControl: CGPoint(x: 2 * at.mid.x - (at.pelvis.x + at.neck.x) / 2,
y: 2 * at.mid.y - (at.pelvis.y + at.neck.y) / 2),