The rig is ~211 canvas units tall standing but the canvas has 152 above
the ground line - every motion to date is seated or on the floor. A
per-motion "camera": {"zoom": ...} now scales the drawn output (figure,
props, mat, stroke widths) about the ground-center anchor in both the
reference renderer and the in-app view. Pure view transform: pins, prop
coordinates, and the Swift-solver fixtures stay in full-size authored
units; zoom 1 is byte-identical to before.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
271 lines
12 KiB
Swift
271 lines
12 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>
|
||
/// Equipment layer (see SYSTEM.md "The props layer").
|
||
let props: [MotionProp]
|
||
/// Presentation zoom about the ground-center anchor — standing motions author
|
||
/// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
|
||
let zoom: Double
|
||
|
||
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.props = resources.motion.props ?? []
|
||
self.zoom = resources.motion.camera?.zoom ?? 1
|
||
}
|
||
|
||
/// Seconds per full camera revolution.
|
||
private static let orbitPeriod: Double = 24
|
||
|
||
/// Drawable geometry at `time` — the figure plus the resolved equipment layers.
|
||
/// Every motion slowly orbits the camera while animating: the equipment has
|
||
/// world-space 3D form and rotates with the figure, so machines turn too.
|
||
func geometry(at time: Double) -> FigureGeometry {
|
||
let yaw = 360 * (time / Self.orbitPeriod)
|
||
return timeline.geometry(at: time, yawOffset: yaw, props: props)
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
|
||
// Presentation zoom about the ground-center anchor, mirroring the reference
|
||
// renderer's `apply_zoom` (stroke widths scale with the geometry).
|
||
if figure.zoom != 1 {
|
||
ctx.translateBy(x: Self.designSize.width / 2, y: Self.groundY)
|
||
ctx.scaleBy(x: figure.zoom, y: figure.zoom)
|
||
ctx.translateBy(x: -Self.designSize.width / 2, y: -Self.groundY)
|
||
}
|
||
|
||
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.
|
||
drawProps(&ctx, geo.propsBackground)
|
||
|
||
// 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":
|
||
drawProps(&ctx, geo.propsForeground)
|
||
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
|
||
|
||
/// Draws one resolved equipment layer. The solver hands back ready canvas
|
||
/// primitives (`MotionSolver.resolveProps`, kept 1:1 with the reference
|
||
/// renderer); the view only maps inks to the adaptive palette.
|
||
private func drawProps(_ ctx: inout GraphicsContext, _ prims: [PropPrimitive]) {
|
||
for prim in prims {
|
||
switch prim {
|
||
case .line(let points, let width, let ink):
|
||
stroke(&ctx, points, color: color(for: ink), width: width)
|
||
case .poly(let points, let width, let ink):
|
||
// An extruded scene slab: filled and outlined, so it degenerates
|
||
// to the plain line whenever the sweep collapses edge-on.
|
||
guard let first = points.first else { continue }
|
||
var path = Path()
|
||
path.addLines(points)
|
||
path.addLine(to: first)
|
||
path.closeSubpath()
|
||
ctx.fill(path, with: .color(color(for: ink)))
|
||
ctx.stroke(path, with: .color(color(for: ink)),
|
||
style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
|
||
case .circle(let center, let radius, let width, let fill, let ink):
|
||
let rect = CGRect(x: center.x - radius, y: center.y - radius,
|
||
width: 2 * radius, height: 2 * radius)
|
||
if fill {
|
||
ctx.fill(Path(ellipseIn: rect), with: .color(color(for: ink)))
|
||
} else {
|
||
ctx.stroke(Path(ellipseIn: rect), with: .color(color(for: ink)), lineWidth: width)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func color(for ink: PropInk) -> Color {
|
||
ink == .prop ? .figureProp : .figureEquipment
|
||
}
|
||
|
||
/// 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
|
||
}
|