From 20b8e6d82e11113c9e9d5387505c2be08098e59c Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 6 Jul 2026 22:22:31 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + Exercise Library/SYSTEM.md | 4 +- Exercise Library/TODO-orbit.md | 90 ------------------- README.md | 7 +- .../ExerciseFigure/ExerciseFigureView.swift | 10 +-- WorkoutsTests/ExerciseMotionTests.swift | 29 +++--- 6 files changed, 31 insertions(+), 111 deletions(-) delete mode 100644 Exercise Library/TODO-orbit.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b88023..e08bb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Exercise Library/SYSTEM.md b/Exercise Library/SYSTEM.md index d505fec..f67e280 100644 --- a/Exercise Library/SYSTEM.md +++ b/Exercise Library/SYSTEM.md @@ -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 diff --git a/Exercise Library/TODO-orbit.md b/Exercise Library/TODO-orbit.md deleted file mode 100644 index bb7ffb6..0000000 --- a/Exercise Library/TODO-orbit.md +++ /dev/null @@ -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 1–4 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 `, 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. diff --git a/README.md b/README.md index b4ebd15..aff7dc7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift index f33fab9..ed12ffb 100644 --- a/Workouts/ExerciseFigure/ExerciseFigureView.swift +++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift @@ -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) } } diff --git a/WorkoutsTests/ExerciseMotionTests.swift b/WorkoutsTests/ExerciseMotionTests.swift index f0b8264..45904f5 100644 --- a/WorkoutsTests/ExerciseMotionTests.swift +++ b/WorkoutsTests/ExerciseMotionTests.swift @@ -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.