Files
workouts/Workouts/ExerciseFigure/ExerciseFigureView.swift
T
rzen 6d9eff1a71 Draw the ground as an exercise mat that rotates with the figure
The floor rectangle was screen-locked, which broke the illusion the
moment the camera orbited. It is now a world-space quad on the ground
plane, sized to each motion's projected footprint across its key frames
and rotated through the same camera as the figure - a long rectangle in
profile, a parallelogram mid-orbit, end-on when face-on. Both renderers
in lockstep; fixtures unaffected (the mat is a pure addition).

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 21:31:36 -04:00

363 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
// 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.
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
}