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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user