Shoulder and pelvis widths grow to human-like proportions per profile (shoulders wider than hips for neutral/male, reversed for female) and are now drawn — bars across the attach points that read near-full-width face-on and as a shoulder/hip nub in profile, so limbs visibly hang from a torso instead of a point. Orbiting no longer re-solves IK pins in the rotated view (pins are canvas targets in the authored camera): the pose resolves first and the posed body rotates, which fixes hands sticking to stale screen points mid-orbit (Cat-Cow, Bird Dog, Plank). Leg Extension and Leg Curl swap their ankle bars for a machine roller disc — a new `roller` prop riding the shin's press side. Fixtures regenerated; both renderers updated in lockstep. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
357 lines
16 KiB
Swift
357 lines
16 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 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<String>
|
||
/// Limbs fully occluded in this view — never drawn.
|
||
let hide: Set<String>
|
||
/// 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.hide = Set(resources.motion.hide ?? [])
|
||
self.props = resources.motion.props ?? []
|
||
}
|
||
|
||
/// Seconds per full camera revolution for orbiting motions.
|
||
private static let orbitPeriod: Double = 24
|
||
|
||
/// Prop-free, hide-free motions slowly orbit the camera while animating — the
|
||
/// bodyweight moves. Scene equipment is a view-locked billboard and a `hide` list
|
||
/// describes one authored viewpoint, so machines keep their fixed camera.
|
||
var orbits: Bool { props.isEmpty && hide.isEmpty }
|
||
|
||
/// Drawable geometry at `time`, with limbs listed in `hide` dropped so neither they
|
||
/// nor any prop riding them are drawn.
|
||
func geometry(at time: Double) -> FigureGeometry {
|
||
let yaw = orbits ? 360 * (time / Self.orbitPeriod) : 0
|
||
var geo = timeline.geometry(at: time, yawOffset: yaw)
|
||
for name in hide {
|
||
if let limb = FigureLimb(rawValue: name) { geo.limbs[limb] = nil }
|
||
}
|
||
return geo
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
|
||
// 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":
|
||
drawAttachedProps(&ctx, geo)
|
||
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
|
||
|
||
/// 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` (legs now end in a foot bone, so a foot
|
||
/// prop rides the ankle at index 2).
|
||
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 {
|
||
if prop.type == "roller" {
|
||
drawRoller(&ctx, geo, prop)
|
||
continue
|
||
}
|
||
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))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A machine roller pad seen end-on: a disc riding the limb's lower bone near the
|
||
/// joint, on the `side` (+1/−1) of the bone it presses. Kept 1:1 with the reference
|
||
/// renderer's `resolve_props`.
|
||
private func drawRoller(_ ctx: inout GraphicsContext, _ geo: FigureGeometry, _ prop: MotionProp) {
|
||
guard let at = prop.at, let anchor = jointAnchor(geo, at) else { return }
|
||
let r = prop.r ?? 5
|
||
let back = prop.back ?? 0
|
||
let side = prop.side ?? 1
|
||
let px = anchor.direction.dy * side, py = -anchor.direction.dx * side
|
||
let center = CGPoint(x: anchor.point.x - anchor.direction.dx * back + px * (r + 3),
|
||
y: anchor.point.y - anchor.direction.dy * back + py * (r + 3))
|
||
let rect = CGRect(x: center.x - r, y: center.y - r, width: 2 * r, height: 2 * r)
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|