Add Warm-Up and Stretching activity types
Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and .stretching (.flexibility), and retags the six starter splits that were all mislabeled as Functional Strength: - Warm-Up: Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up - Stretching: Morning Mobility, Full Body Stretch, Evening Stretch The split editor's activity picker surfaces them automatically (CaseIterable). Older app versions decode the new raw values as the default type — additive and not schema-gated, so no quarantine.
This commit is contained in:
@@ -12,10 +12,12 @@ import SwiftUI
|
||||
/// `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.
|
||||
/// always last so overhead arms pass behind the face) and a per-vertex nearness for each
|
||||
/// limb: every joint is toned by its own camera depth, so each bone is stroked with a
|
||||
/// gradient between its joints' tones — the ink flows along a limb reaching in depth
|
||||
/// instead of snapping, and the far member sits lighter, thinner, and nudged by the
|
||||
/// readability offset. Working parts swap the 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 {
|
||||
@@ -28,9 +30,9 @@ struct FigureAnimation {
|
||||
/// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
|
||||
let zoom: Double
|
||||
|
||||
init?(exerciseName: String) {
|
||||
init?(exerciseName: String, profile: FigureProfile = .neutral) {
|
||||
guard
|
||||
let resources = ExerciseMotionLibrary.resources(for: exerciseName),
|
||||
let resources = ExerciseMotionLibrary.resources(for: exerciseName, profile: profile),
|
||||
let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
|
||||
else { return nil }
|
||||
self.timeline = timeline
|
||||
@@ -56,6 +58,7 @@ struct FigureAnimation {
|
||||
struct ExerciseFigureSlot: View {
|
||||
let exerciseName: String
|
||||
|
||||
@AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
|
||||
@State private var figure: FigureAnimation?
|
||||
|
||||
var body: some View {
|
||||
@@ -67,8 +70,9 @@ struct ExerciseFigureSlot: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task(id: exerciseName) {
|
||||
figure = FigureAnimation(exerciseName: exerciseName)
|
||||
// Rebuild when either the exercise or the chosen body type changes.
|
||||
.task(id: [exerciseName, figureProfile.rawValue]) {
|
||||
figure = FigureAnimation(exerciseName: exerciseName, profile: figureProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,8 +140,8 @@ struct ExerciseFigureView: View {
|
||||
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)
|
||||
let tones = geo.nearness[limb] ?? Array(repeating: 1, count: points.count)
|
||||
strokeLimb(&ctx, points, tones: tones, part: part)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +158,7 @@ struct ExerciseFigureView: View {
|
||||
}
|
||||
|
||||
private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
|
||||
let color = ink("spine", shade: .near)
|
||||
let color = ink("spine", nearness: 1)
|
||||
stroke(&ctx, geo.girdle, color: color, width: 5)
|
||||
stroke(&ctx, geo.pelvisBar, color: color, width: 5)
|
||||
var path = Path()
|
||||
@@ -201,13 +205,35 @@ struct ExerciseFigureView: View {
|
||||
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
|
||||
/// A limb's ink at `nearness` (1 = near/dark side, 0 = far/light side): the near
|
||||
/// and far grays, or the accent teals for a working part. The spine (passed
|
||||
/// nearness 1) is the near ink unless it's working.
|
||||
private func ink(_ part: String, nearness t: Double) -> Color {
|
||||
.figureShaded(working: figure.working.contains(part), nearness: t)
|
||||
}
|
||||
|
||||
/// Strokes a limb chain with a per-vertex depth gradient: each bone segment is a
|
||||
/// round-capped line filled with a linear gradient between its two joints' depth
|
||||
/// tones, so the ink flows smoothly along a limb that reaches toward or away from
|
||||
/// the camera instead of the bone snapping to one flat tone. Adjacent segments share
|
||||
/// a joint tone, so their round caps meet seamlessly. Mirrors `render.py`.
|
||||
private func strokeLimb(_ ctx: inout GraphicsContext, _ points: [CGPoint],
|
||||
tones: [Double], part: String) {
|
||||
guard points.count >= 2, tones.count == points.count else { return }
|
||||
for i in 0..<(points.count - 1) {
|
||||
let a = points[i], b = points[i + 1]
|
||||
let t0 = tones[i], t1 = tones[i + 1]
|
||||
let style = StrokeStyle(lineWidth: 5 + (t0 + t1) / 2, lineCap: .round)
|
||||
var path = Path()
|
||||
path.move(to: a)
|
||||
path.addLine(to: b)
|
||||
if t0 == t1 || (abs(a.x - b.x) < 0.01 && abs(a.y - b.y) < 0.01) {
|
||||
ctx.stroke(path, with: .color(ink(part, nearness: t0)), style: style)
|
||||
} else {
|
||||
let gradient = Gradient(colors: [ink(part, nearness: t0), ink(part, nearness: t1)])
|
||||
ctx.stroke(path, with: .linearGradient(gradient, startPoint: a, endPoint: b), style: style)
|
||||
}
|
||||
}
|
||||
return shade == .near ? .figureNear : .figureFar
|
||||
}
|
||||
|
||||
private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
|
||||
@@ -238,18 +264,28 @@ private extension Color {
|
||||
#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))
|
||||
/// The gradient endpoints as `(lightMode, darkMode)` RGB pairs — the near (dark,
|
||||
/// prominent) and far (light, recessive) inks, plain and working. `figureShaded`
|
||||
/// blends between the far and near endpoint of a pair by a limb's nearness.
|
||||
private static let nearInk = (light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83)) // #3a3f4b
|
||||
private static let farInk = (light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43)) // #a9afba
|
||||
private static let nearWorkingInk = (light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) // #0d9488
|
||||
private static let farWorkingInk = (light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52)) // #86cfc5
|
||||
|
||||
/// 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))
|
||||
static func lerp3(_ a: (Double, Double, Double), _ b: (Double, Double, Double), _ t: Double) -> (Double, Double, Double) {
|
||||
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, a.2 + (b.2 - a.2) * t)
|
||||
}
|
||||
|
||||
/// 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))
|
||||
/// A limb's ink blended by nearness (1 = near/dark side, 0 = far/light side),
|
||||
/// interpolating both the light- and dark-mode endpoints so it stays adaptive.
|
||||
static func figureShaded(working: Bool, nearness t: Double) -> Color {
|
||||
let far = working ? farWorkingInk : farInk
|
||||
let near = working ? nearWorkingInk : nearInk
|
||||
return figure(light: lerp3(far.light, near.light, t), dark: lerp3(far.dark, near.dark, t))
|
||||
}
|
||||
|
||||
/// 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))
|
||||
/// Near-side ink for the head, nose, and non-gradient strokes (`#3a3f4b`).
|
||||
static let figureNear = figure(light: nearInk.light, dark: nearInk.dark)
|
||||
|
||||
/// Scene equipment — seats, benches, frames, cables (`#c5cad4`); sits behind
|
||||
/// the figure, one step lighter than the ground line.
|
||||
|
||||
@@ -19,7 +19,26 @@ import Foundation
|
||||
/// exported by `render.py --export` into `Resources/ExerciseMotions/` — `skeleton.json`
|
||||
/// plus one `<Exercise Name>.motion.json` per library entry.
|
||||
|
||||
/// Bone lengths for one figure profile (`neutral` is the only one the app renders).
|
||||
/// The figure build the exercise animation renders. A presentation-only choice of
|
||||
/// skeleton profile (bone proportions) — the motion scripts are identical across builds,
|
||||
/// so switching never touches an exercise's animation, only its dimensions. The raw value
|
||||
/// is the `skeleton.json` profile key; persisted via `@AppStorage("figureProfile")`.
|
||||
enum FigureProfile: String, CaseIterable {
|
||||
case neutral
|
||||
case feminine = "female"
|
||||
case masculine = "male"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .neutral: "Neutral"
|
||||
case .feminine: "Feminine"
|
||||
case .masculine: "Masculine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bone lengths for one figure profile (`neutral`, `female`, or `male`; the app picks
|
||||
/// one per the `figureProfile` setting — see `FigureProfile`).
|
||||
struct SkeletonProfile: Codable {
|
||||
let headR: Double
|
||||
let neck: Double
|
||||
@@ -270,10 +289,10 @@ enum ExerciseMotionLibrary {
|
||||
.sorted()
|
||||
}()
|
||||
|
||||
/// The motion script plus the neutral skeleton profile for `exerciseName`, or `nil`
|
||||
/// when no bundled motion matches (most exercises have none — the caller keeps
|
||||
/// its space empty).
|
||||
static func resources(for exerciseName: String) -> Resources? {
|
||||
/// The motion script plus the requested skeleton `profile` for `exerciseName`, or
|
||||
/// `nil` when no bundled motion matches (most exercises have none — the caller keeps
|
||||
/// its space empty). An unknown profile key falls back to `neutral`.
|
||||
static func resources(for exerciseName: String, profile: FigureProfile = .neutral) -> Resources? {
|
||||
guard
|
||||
let motionURL = Bundle.main.url(forResource: exerciseName, withExtension: "motion.json"),
|
||||
let skeletonURL = Bundle.main.url(forResource: "skeleton", withExtension: "json"),
|
||||
@@ -281,7 +300,7 @@ enum ExerciseMotionLibrary {
|
||||
let skeletonData = try? Data(contentsOf: skeletonURL),
|
||||
let motion = try? JSONDecoder().decode(ExerciseMotion.self, from: motionData),
|
||||
let skeleton = try? JSONDecoder().decode(Skeleton.self, from: skeletonData),
|
||||
let profile = skeleton.profiles["neutral"]
|
||||
let profile = skeleton.profiles[profile.rawValue] ?? skeleton.profiles[FigureProfile.neutral.rawValue]
|
||||
else { return nil }
|
||||
return Resources(motion: motion, profile: profile)
|
||||
}
|
||||
|
||||
@@ -190,6 +190,12 @@ struct FigureGeometry {
|
||||
/// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, … then `"head"`).
|
||||
var order: [String]
|
||||
var shade: [FigureLimb: Shade]
|
||||
/// Per-vertex nearness in [0, 1] (1 = near/dark side), one value per joint of each
|
||||
/// limb chain, driving the shading gradient: each joint is toned by its own camera
|
||||
/// depth so the ink flows along a limb that reaches in depth. Unlike `shade` (which
|
||||
/// stays binary for geometry and draw order) this fades to a shared mid-tone as a
|
||||
/// pair crosses, so the ink never snaps. See `frameGeometry`.
|
||||
var nearness: [FigureLimb: [Double]]
|
||||
/// The equipment layer resolved for this frame: scene shapes and cables drawn
|
||||
/// behind the figure, joint-attached items drawn over the limbs (before the head).
|
||||
var propsBackground: [PropPrimitive] = []
|
||||
@@ -216,6 +222,12 @@ struct FigurePose {
|
||||
enum MotionSolver {
|
||||
private static let depthBucket = 3.0
|
||||
private static let pairs: [(FigureLimb, FigureLimb)] = [(.armR, .armL), (.legR, .legL)]
|
||||
/// Depth shading is a smooth per-vertex gradient: a joint reaches full near/far
|
||||
/// contrast once it sits this fraction of the shoulder/pelvis half-width in front of
|
||||
/// (or behind) its pair's central depth plane, so a straight limb in profile is still
|
||||
/// fully dark/light. See `frameGeometry` and `render.py`'s `SHADE_SPAN_FRAC` — the
|
||||
/// two must stay in sync.
|
||||
private static let shadeSpanFrac = 0.6
|
||||
/// Fixed draw rank breaking depth-bucket ties (far parts first, `spine` mid-stack).
|
||||
private static let fixedRank: [String: Int] = ["arm_l": 0, "leg_l": 1, "spine": 2, "arm_r": 3, "leg_r": 4]
|
||||
|
||||
@@ -354,17 +366,44 @@ enum MotionSolver {
|
||||
let perp = normal.cross(dirTarget)
|
||||
let along = (a * a + d * d - b * b) / (2 * d)
|
||||
let h = max(a * a - along * along, 0).squareRoot()
|
||||
var best: (distance: Double, mid: Vec3)?
|
||||
for sign in [1.0, -1.0] {
|
||||
let mid = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
|
||||
let distance = (mid - guessMid).length
|
||||
if best == nil || distance < best!.distance { best = (distance, mid) }
|
||||
let mid: Vec3
|
||||
if limb.isArm {
|
||||
var best: (distance: Double, mid: Vec3)?
|
||||
for sign in [1.0, -1.0] {
|
||||
let m = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
|
||||
let distance = (m - guessMid).length
|
||||
if best == nil || distance < best!.distance { best = (distance, m) }
|
||||
}
|
||||
mid = best!.mid
|
||||
} else {
|
||||
// A knee bends one way only. Near full extension the two knee solutions
|
||||
// straddle the hip→ankle line and the authored guess (also near it) can't
|
||||
// reliably pick a side — the bend plane's normal is a cross of two
|
||||
// near-parallel vectors, so its sign is noise and the knee can flip behind
|
||||
// the leg, swinging the thigh backward. When the authored knee sits within
|
||||
// `kneeStraightFrac` of the line, treat the leg as straight and bend the
|
||||
// knee anatomically forward (anterior); otherwise honor the authored side.
|
||||
let gm = guessMid - attach
|
||||
let gmPerp = gm - dirTarget.scaled(gm.dot(dirTarget))
|
||||
let ref = gmPerp.length < Self.kneeStraightFrac * a ? parent.apply(Vec3(1, 0, 0)) : gmPerp
|
||||
let sign = perp.dot(ref) >= 0 ? 1.0 : -1.0
|
||||
mid = attach + (dirTarget.scaled(along) + perp.scaled(sign * h))
|
||||
}
|
||||
let mid = best!.mid
|
||||
let end = mid + (target - mid).normalized.scaled(b)
|
||||
return invertLimb(limb, attach: attach, mid: mid, end: end, parent: parent)
|
||||
}
|
||||
|
||||
/// Axial rotation of a two-bone limb is recoverable only from the lower bone's
|
||||
/// lateral tip; below this magnitude the limb is effectively in-plane and its
|
||||
/// rotation is left at 0 (see `invertLimb`).
|
||||
private static let rotMinLateral = 0.08
|
||||
|
||||
/// When a leg's authored knee sits within this fraction of the thigh length off
|
||||
/// the hip→ankle line (~sin 8.6°), the leg is treated as straight and its IK knee
|
||||
/// is bent anatomically forward rather than trusting the (unreliable) authored
|
||||
/// side (see `solveLimb`).
|
||||
private static let kneeStraightFrac = 0.15
|
||||
|
||||
/// Recover anatomical angles from limb joint positions (the inverse of `fkLimb`,
|
||||
/// ignoring the foot). Assumes |abduction| < 90; the leg's rotation sign flips
|
||||
/// because knees hinge backward.
|
||||
@@ -377,11 +416,18 @@ enum MotionSolver {
|
||||
let peel = chain(Mat3.rotZ(flexion), Mat3.rotX(-sigma * abduction)).transposed
|
||||
let w = peel.apply(parentT.apply(end - mid)).normalized
|
||||
let bend = acos(clampUnit(-w.y)) * 180 / .pi
|
||||
// Axial rotation is observable only through the lower bone's lateral tip
|
||||
// (w.z); a near-sagittal limb carries no recoverable rotation. `bend`
|
||||
// alone is too weak a guard: a limb can straighten through this degeneracy
|
||||
// while still visibly bent, and atan2 then snaps to ±180 on the sign of a
|
||||
// near-zero anterior component — twisting the limb a half-turn and
|
||||
// flipping a pinned hand/foot backward.
|
||||
let twist = bend > 0.5 && abs(w.z) > Self.rotMinLateral
|
||||
let rotation: Double
|
||||
if limb.isArm {
|
||||
rotation = bend > 0.5 ? sigma * atan2(w.z, w.x) * 180 / .pi : 0
|
||||
rotation = twist ? sigma * atan2(w.z, w.x) * 180 / .pi : 0
|
||||
} else {
|
||||
rotation = bend > 0.5 ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0
|
||||
rotation = twist ? sigma * atan2(-w.z, -w.x) * 180 / .pi : 0
|
||||
}
|
||||
return (BallJoint(flexion: flexion, abduction: abduction, rotation: rotation), Hinge(flexion: bend))
|
||||
}
|
||||
@@ -485,6 +531,24 @@ enum MotionSolver {
|
||||
depths[limb.rawValue] = chainDepth(pts)
|
||||
}
|
||||
|
||||
// Per-vertex depth shading: tone each drawn joint by its own camera depth
|
||||
// relative to its pair's central plane, normalized by the profile half-width
|
||||
// (× shadeSpanFrac), so the ink flows along a limb that reaches toward or away
|
||||
// from the camera instead of the bone snapping to one flat tone. The per-vertex
|
||||
// average reproduces the old flat per-limb tone. Kept 1:1 with render.py.
|
||||
var nearness: [FigureLimb: [Double]] = [:]
|
||||
for (right, left) in pairs {
|
||||
guard let rp = p.limbs[right], let lp = p.limbs[left] else { continue }
|
||||
let ref = (chainDepth(rp) + chainDepth(lp)) / 2
|
||||
let half = (right == .armR ? prof.shoulderHalf : prof.hipHalf) * p.k
|
||||
let span = half * shadeSpanFrac
|
||||
func tone(_ z: Double) -> Double {
|
||||
span != 0 ? 0.5 + 0.5 * max(-1, min(1, (z - ref) / span)) : 0.5
|
||||
}
|
||||
nearness[right] = rp.map { tone($0.z) }
|
||||
nearness[left] = lp.map { tone($0.z) }
|
||||
}
|
||||
|
||||
let head = scr(p.head)
|
||||
var noseStart: CGPoint?, noseEnd: CGPoint?
|
||||
let nose = p.noseDir
|
||||
@@ -520,7 +584,7 @@ enum MotionSolver {
|
||||
spineStart: pelvis, spineControl: control, spineEnd: neckB,
|
||||
girdle: [attachPoint(p.shoulderL, .armL), neckB, attachPoint(p.shoulderR, .armR)],
|
||||
pelvisBar: [attachPoint(p.hipL, .legL), pelvis, attachPoint(p.hipR, .legR)],
|
||||
floor: floor, limbs: limbs, order: order, shade: shade)
|
||||
floor: floor, limbs: limbs, order: order, shade: shade, nearness: nearness)
|
||||
return (resolved, geo)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user