Slowly orbit the camera around every form guide

With the equipment layer carrying world-space 3D form, the prop-free
gate comes off: machines now get the same slow orbit as the bodyweight
moves, their seats, cables, bars, and rollers turning with the figure.
Closes out the orbit-for-all-exercises plan.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-06 22:22:31 -04:00
parent b8c3b326c1
commit 20b8e6d82e
6 changed files with 31 additions and 111 deletions
+2
View File
@@ -1,5 +1,7 @@
**July 2026**
Machine form guides now slowly circle around the figure too — seats, cables, and pads turn with the body, showing every exercise from all sides.
Form guides are now viewed from a slight elevation, with an exercise mat under the figure that turns with the camera.
The stick figure now has broader, more human proportions with a visible shoulder girdle and pelvis.
+3 -1
View File
@@ -77,7 +77,9 @@ never touches a motion script** — proportions are the skeleton's problem.
side view, 90 views the figure face-on — real foreshortening, not faked
proportions. Face-on machines (abductor/adductor, rotary torso) author
genuine abduction or spine rotation and let projection do the rest. The
camera can also orbit while the motion loops (`--orbit`).
camera also orbits while the motion loops (`--orbit`; the in-app renderer
slowly orbits **every** exercise, machines included — props have
world-space 3D form and turn with the figure).
- **Elevation & the mat** — the default viewpoint pitches down 10°
(`CAMERA_PITCH`; override per motion via `"camera": {"pitch": ...}`), and
the ground is drawn as an **exercise mat**: a world-space quad sized to the
-90
View File
@@ -1,90 +0,0 @@
# Handoff: complete camera rotation (slow orbit) for all exercises
Repo: `~/Documents/indie/Workouts`. Read `Exercise Library/SYSTEM.md` first
(format spec + visual language), then `Exercise Library/kinematics.py` and
`render.py` (reference renderer), and `Workouts/ExerciseFigure/`
(`MotionSolver.swift`, `ExerciseFigureView.swift`, `ExerciseMotion.swift`
the Swift port, kept in lockstep with Python; shared by the iOS and watch
targets).
## Current state
The figure rig is an anatomical 3D skeleton with a per-exercise orthographic
camera (`camera: {yaw, pitch}`, default pitch 10°). In the app,
`FigureAnimation` (ExerciseFigureView.swift) slowly orbits the camera (360°
per 24 s, `orbitPeriod`) — but only when `props.isEmpty && hide.isEmpty`,
i.e. the 8 bodyweight moves. The 14 machine exercises stay at their authored
view because their equipment can't rotate. The goal: **every exercise
orbits.**
## What blocks it, in order of work
1. **`scene` props are 2D canvas billboards** (seats, masts, benches —
`line`/`rect`/`circle` in canvas coords). They need world-space 3D form.
Follow the exercise-mat pattern (`geo["floor"]` in `render.py` /
`FigureGeometry.floor` in Swift — a quad built in model space relative to
the root anchor, rotated through `Rx(pitch)·Ry(yaw)`, then projected):
extend the scene-shape schema so each shape carries depth — suggest an
optional `"z"` (depth plane, default 0) plus `"depth"` (extrusion
half-width) so a seat rect becomes a slab and a mast line a post; author
full 3D endpoints only where needed. Convert authored canvas coords to
model space by subtracting the motion's root anchor (same as the mat).
Re-author all 14 machines' props minimally — schematic silhouettes, not
scale models; check each at yaw 0 (must look identical to today), ±45°,
and 90°.
2. **`cable`** `from` anchors and **`bar`** fixed world angles are
authored-view constructs: make both world-space (the anchor gets a depth,
the bar becomes a segment defined in the authored view and rotated like
the mat — it must stay welded to the hands).
3. **`roller`** and **`pad`/`dumbbell`** perpendiculars are computed in
screen space per frame — under orbit compute them in the flat authored
view, then rotate (the resolve-then-rotate invariant below).
4. **`hide` lists** name limbs occluded in one authored view — wrong under
orbit. Only Side Plank uses one; re-author it with genuine 3D (root
`roll`, real abduction) instead, then delete the `hide` mechanism if
nothing else uses it.
5. Relax the `orbits` gate in `FigureAnimation` to always-on once 14 hold
up visually.
## Invariants — do not break
- **Resolve flat, then rotate.** IK pins and prop attachment logic operate
in the *authored* view (yaw as authored, pitch 0); the *posed result* is
rotated for presentation. Never re-solve pins or recompute screen-space
perpendiculars in the rotated view — that class of bug ("hands stuck
mid-orbit") was already fixed twice.
- **Both renderers in lockstep.** Every geometry change lands in
`render.py`/`kinematics.py` *and* the Swift port, same conventions and
constants. `WorkoutsTests/Fixtures/figure-fixtures.json` pins Swift to
Python (0.5 px); regenerate it from the Python pipeline whenever projected
geometry changes (per exercise/keyframe snapshot: order, shade, spine,
head, nose, limb chains, plus tween + orbit samples — extend the orbit
samples to a machine exercise once props rotate).
- **Visual parity at the authored view**: rendering any exercise at its
authored yaw must look the same as today. Verify with
`python3 render.py --sheet` before/after, and orbit strips
(`--orbit <name>`, or a multi-yaw contact sheet) for each converted
machine.
## Verification workflow
`python3 render.py` + `--sheet`/`--orbit` and eyeball the PNGs;
`python3 render.py --export` (verbatim copy into
`Workouts/Resources/ExerciseMotions/`); `xcodegen generate` if project.yml
changes; `xcodebuild test -scheme Workouts -destination 'platform=iOS
Simulator,name=iPhone 17 Pro' -only-testing:WorkoutsTests` (48 tests must
stay green, plus whatever you add); `xcodebuild build -scheme "Workouts
Watch App" -destination 'generic/platform=watchOS Simulator'`. Update
`SYSTEM.md` (props section) and add one end-user CHANGELOG.md entry per the
app-changelog skill (one sentence, no bullets, top of the July 2026 block —
e.g. machine form guides now rotate too). Commit per feature with the
repo's existing message style and push. Delete this file in the final
commit.
## Judgment calls you're empowered to make
Exact 3D prop schema shape (keep it minimal and hand-authorable),
per-machine depth values, and whether a couple of visually-degenerate
machines (e.g. cable stacks) look better with a reduced orbit sweep (±40°
pendulum instead of full 360°) — if so, make sweep style a per-motion
`camera` option with full-360 the default.
+4 -3
View File
@@ -26,9 +26,10 @@ your own iCloud Drive.
- **Animated form guide** — every library exercise shows a looping stick-figure
demonstration on the run screen's bottom half, posed by real joint angles on an
anatomical 3D skeleton (complete with feet) and rendered live from the Exercise
Library's rig data with the working limbs highlighted — machine exercises
include schematic equipment (seats, bars, cables, roller pads) that moves with
the figure, and face-on machines show true hip abduction and torso rotation.
Library's rig data with the working limbs highlighted — the camera slowly
orbits every exercise while it moves, machine equipment (seats, bars, cables,
roller pads) has world-space 3D form that turns with the figure, and face-on
machines show true hip abduction and torso rotation.
- **Machine comfort settings** — machine-based exercises remember your setup
(seat height, back-rest position, pin position, …), shown on the workout
screen and editable mid-workout; changes save back to the split for next time.
@@ -35,16 +35,14 @@ struct FigureAnimation {
self.props = resources.motion.props ?? []
}
/// Seconds per full camera revolution for orbiting motions.
/// Seconds per full camera revolution.
private static let orbitPeriod: Double = 24
/// Prop-free motions slowly orbit the camera while animating the bodyweight
/// moves. Machines keep their fixed camera until their equipment rotates too.
var orbits: Bool { props.isEmpty }
/// 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 = orbits ? 360 * (time / Self.orbitPeriod) : 0
let yaw = 360 * (time / Self.orbitPeriod)
return timeline.geometry(at: time, yawOffset: yaw, props: props)
}
}
+18 -11
View File
@@ -80,23 +80,30 @@ struct ExerciseMotionTests {
#expect(hypot(releasedHand.x - releasedHand0.x, releasedHand.y - releasedHand0.y) > 1)
}
/// Prop-free bodyweight motions slowly orbit the camera while looping; motions
/// with equipment keep their authored view (until machines orbit too).
@Test func orbitAppliesOnlyToPropFreeMotions() throws {
let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog"))
#expect(birdDog.orbits)
/// Every motion slowly orbits the camera while looping bodyweight moves and
/// machines alike, since the equipment layer rotates with the figure.
@Test func everyMotionOrbits() throws {
// Same loop phase seconds apart: the orbit yaw differs, so the projected head
// moves even though the pose is identical.
let duration = birdDog.timeline.duration
let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog"))
let head0 = birdDog.geometry(at: 0).headCenter
let head1 = birdDog.geometry(at: duration * 2).headCenter
let head1 = birdDog.geometry(at: birdDog.timeline.duration * 2).headCenter
#expect(hypot(head0.x - head1.x, head0.y - head1.y) > 1)
// A machine orbits too, and its equipment turns with the figure: the seat
// slab's projected points move between the same two loop phases.
let legPress = try #require(FigureAnimation(exerciseName: "Leg Press"))
#expect(!legPress.orbits)
let fixed0 = legPress.geometry(at: 0).headCenter
let fixed1 = legPress.geometry(at: legPress.timeline.duration * 2).headCenter
#expect(abs(fixed0.x - fixed1.x) < 1e-9 && abs(fixed0.y - fixed1.y) < 1e-9)
let geo0 = legPress.geometry(at: 0)
let geo1 = legPress.geometry(at: legPress.timeline.duration * 2)
#expect(hypot(geo0.headCenter.x - geo1.headCenter.x,
geo0.headCenter.y - geo1.headCenter.y) > 1)
guard case .poly(let seat0, _, _) = geo0.propsBackground.first,
case .poly(let seat1, _, _) = geo1.propsBackground.first,
let s0 = seat0.first, let s1 = seat1.first else {
Issue.record("expected the Leg Press backrest slab as the first background prop")
return
}
#expect(hypot(s0.x - s1.x, s0.y - s1.y) > 1)
}
/// Every exported motion in the bundle decodes and builds a playable timeline.