Fix backward-bending joints in the five legacy motions

Abdominal's pinned hands used elbow -40 as the IK plane hint, drawing
the arms hyperextended (user-reported). Flipping the hint bends the
elbows the natural way while the hands stay on the handles. Same class
of fix for the milder cases: Arm Curl and Shoulder Press elbows and
Calfs knees clamp to -8, Side Plank's raised arm to the -70 ROM cap.
The whole library now passes render.py --strict with zero warnings,
making it a valid verification gate. Fixtures regenerated; 48 tests
green.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-07 13:06:18 -04:00
parent 7c241ec44c
commit bb234c198f
22 changed files with 172 additions and 53 deletions
@@ -18,7 +18,7 @@ struct ExerciseLibraryView: View {
ExerciseLibraryDetailView(exerciseName: name)
}
}
.navigationTitle("Exercises")
.navigationTitle("\(ExerciseMotionLibrary.exerciseNames.count) Exercises")
}
}
@@ -27,12 +27,21 @@ struct ExerciseLibraryView: View {
/// reference content (summary, targets, setup/execution/cues), and the weight
/// progression chart; the looping form-guide figure holds the bottom.
struct ExerciseLibraryDetailView: View {
@Environment(SyncEngine.self) private var sync
let exerciseName: String
/// This exercise wherever it appears in the user's splits the source of the
/// recorded machine settings.
@Query private var exercises: [Exercise]
/// Presents the settings editor; carries the splits the edit writes back to.
@State private var settingsEdit: SettingsEditRoute?
/// True while content extends below the scroll area drives the bottom fade
/// that makes the reference copy read as scrollable.
@State private var moreBelow = false
/// One completed weighted log is enough to justify the progression chart;
/// bodyweight and timed exercises never accrue one, so they skip the chart
/// instead of plotting a flat zero line.
@@ -53,18 +62,33 @@ struct ExerciseLibraryDetailView: View {
}
/// Distinct recorded machine-settings lists for this exercise. Usually one; when
/// splits disagree, each distinct list keeps its split's name as a label.
private var settingsGroups: [(label: String?, settings: [MachineSetting])] {
var groups: [(label: String?, settings: [MachineSetting])] = []
/// splits disagree, each distinct list keeps its split's name as a label. Each
/// group carries every split showing that list, so an edit updates them all and
/// they stay deduped.
private var settingsGroups: [SettingsGroup] {
var groups: [SettingsGroup] = []
for exercise in exercises {
guard let settings = exercise.machineSettings, !settings.isEmpty else { continue }
guard !groups.contains(where: { $0.settings == settings }) else { continue }
groups.append((exercise.split?.name, settings))
if let i = groups.firstIndex(where: { $0.settings == settings }) {
if let id = exercise.split?.id { groups[i].splitIDs.append(id) }
continue
}
groups.append(SettingsGroup(
label: exercise.split?.name,
settings: settings,
splitIDs: exercise.split.map { [$0.id] } ?? []
))
}
if groups.count == 1 { groups[0].label = nil }
return groups
}
/// Every split containing this exercise the write targets when settings are
/// recorded here for the first time.
private var containingSplitIDs: [String] {
exercises.compactMap { $0.split?.id }
}
var body: some View {
VStack(spacing: 0) {
ScrollView {
@@ -73,9 +97,17 @@ struct ExerciseLibraryDetailView: View {
// exercise the authored info identifies as machine-based, as an
// empty state, so the feature is discoverable before first use.
if !settingsGroups.isEmpty {
MachineSettingsCard(groups: settingsGroups)
MachineSettingsCard(groups: settingsGroups, onEdit: edit(group:))
} else if info?.isMachineBased == true {
MachineSettingsCard(groups: [])
MachineSettingsCard(
groups: [],
onEdit: edit(group:),
// Recording needs a split exercise to write to without
// one the card explains instead of offering a dead button.
onAdd: containingSplitIDs.isEmpty ? nil : {
settingsEdit = SettingsEditRoute(initial: [], targetSplitIDs: containingSplitIDs)
}
)
}
if let info {
ExerciseInfoContent(info: info)
@@ -88,36 +120,105 @@ struct ExerciseLibraryDetailView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Fade the last lines while more content lies below, so the reference
// copy visibly continues past the figure boundary; gone at scroll end.
.onScrollGeometryChange(for: Bool.self) { geo in
geo.contentOffset.y + geo.containerSize.height < geo.contentSize.height - 12
} action: { _, more in
withAnimation(.easeInOut(duration: 0.2)) { moreBelow = more }
}
.overlay(alignment: .bottom) {
if moreBelow {
LinearGradient(
colors: [Color(.systemBackground).opacity(0), Color(.systemBackground)],
startPoint: .top, endPoint: .bottom
)
.frame(height: 28)
.allowsHitTesting(false)
.transition(.opacity)
}
}
ExerciseFigureSlot(exerciseName: exerciseName)
}
.navigationTitle(exerciseName)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $settingsEdit) { route in
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
let targets = route.targetSplitIDs
Task {
for splitID in targets {
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, splitID: splitID)
}
}
}
}
}
private func edit(group: SettingsGroup) {
settingsEdit = SettingsEditRoute(initial: group.settings, targetSplitIDs: group.splitIDs)
}
}
/// One distinct recorded settings list and the splits it came from (the edit targets).
fileprivate struct SettingsGroup {
var label: String?
var settings: [MachineSetting]
var splitIDs: [String]
}
/// Sheet route: the settings to seed the editor with and the splits a save writes to.
private struct SettingsEditRoute: Identifiable {
let id = UUID()
var initial: [MachineSetting]
var targetSplitIDs: [String]
}
/// The user's recorded machine comfort settings, set apart from the reference text
/// as a card: namevalue rows, grouped per split when the recorded lists differ.
/// Empty `groups` renders the not-yet-recorded state (shown for machine-based
/// exercises so the feature is visible before first use).
/// exercises so the feature is visible before first use), with an Add button when
/// the exercise lives in at least one split (`onAdd` non-nil). A single group edits
/// from the header; disagreeing groups edit per group.
private struct MachineSettingsCard: View {
let groups: [(label: String?, settings: [MachineSetting])]
let groups: [SettingsGroup]
var onEdit: (SettingsGroup) -> Void
var onAdd: (() -> Void)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Your Machine Settings")
.font(.headline)
HStack {
Text("Your Machine Settings")
.font(.headline)
Spacer()
if groups.count == 1 {
Button("Edit") { onEdit(groups[0]) }
.font(.subheadline)
} else if groups.isEmpty, let onAdd {
Button("Add") { onAdd() }
.font(.subheadline)
}
}
if groups.isEmpty {
Text("None recorded yet. Mark the exercise as machine-based in its editor, or use the settings button on the workout screen.")
Text(onAdd != nil
? "None recorded yet — save the seat, pad, and pin positions once and they'll be here next time."
: "None recorded yet. Add this exercise to a split, then record its settings here or from the workout screen.")
.font(.callout)
.foregroundStyle(.secondary)
}
ForEach(Array(groups.enumerated()), id: \.offset) { _, group in
VStack(alignment: .leading, spacing: 4) {
if let label = group.label {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if groups.count > 1 {
Button("Edit") { onEdit(group) }
.font(.caption)
}
}
}
ForEach(Array(group.settings.enumerated()), id: \.offset) { _, setting in
HStack(alignment: .firstTextBaseline) {
@@ -79,9 +79,12 @@ struct WorkoutLogListView: View {
Section {
LabeledContent("Started", value: doc.start.formattedRelativeDateTime())
LabeledContent("Status") {
// Emphasize the states that mean the workout amounted to
// something a running or finished one over notStarted.
let emphasized = statusEnum == .inProgress || statusEnum == .completed
Text(statusEnum.displayName)
.fontWeight(statusEnum == .inProgress ? .bold : nil)
.foregroundStyle(statusEnum == .inProgress ? Color.accentColor : Color.secondary)
.fontWeight(emphasized ? .bold : nil)
.foregroundStyle(emphasized ? Color.accentColor : Color.secondary)
}
}
@@ -401,6 +404,17 @@ private struct WorkoutLogRow: View {
private var showsWeight: Bool { loadType == .weight }
/// Compact glanceable summary of the recorded machine settings ("Seat 4 · Back 3")
/// so the values are readable while setting up the machine, without opening the
/// sheet. Nil when nothing is recorded yet the button then reads "Settings".
private var settingsSummary: String? {
guard let settings = log.machineSettings, !settings.isEmpty else { return nil }
let parts = settings
.map { "\($0.name) \($0.value)".trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
var body: some View {
HStack(alignment: .top) {
Button {
@@ -443,9 +457,11 @@ private struct WorkoutLogRow: View {
if log.machineSettings != nil
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
Button(action: onSettingsTap) {
Label("Settings", systemImage: "slider.horizontal.3")
Label(settingsSummary ?? "Settings", systemImage: "slider.horizontal.3")
.font(.footnote.weight(.medium))
.foregroundStyle(Color.accentColor)
.multilineTextAlignment(.trailing)
.lineLimit(2)
}
.buttonStyle(.plain)
}