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:
@@ -1,5 +1,7 @@
|
|||||||
**July 2026**
|
**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.
|
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.
|
The stick figure now has broader, more human proportions with a visible shoulder girdle and pelvis.
|
||||||
|
|||||||
@@ -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
|
side view, 90 views the figure face-on — real foreshortening, not faked
|
||||||
proportions. Face-on machines (abductor/adductor, rotary torso) author
|
proportions. Face-on machines (abductor/adductor, rotary torso) author
|
||||||
genuine abduction or spine rotation and let projection do the rest. The
|
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°
|
- **Elevation & the mat** — the default viewpoint pitches down 10°
|
||||||
(`CAMERA_PITCH`; override per motion via `"camera": {"pitch": ...}`), and
|
(`CAMERA_PITCH`; override per motion via `"camera": {"pitch": ...}`), and
|
||||||
the ground is drawn as an **exercise mat**: a world-space quad sized to the
|
the ground is drawn as an **exercise mat**: a world-space quad sized to the
|
||||||
|
|||||||
@@ -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 <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.
|
|
||||||
@@ -26,9 +26,10 @@ your own iCloud Drive.
|
|||||||
- **Animated form guide** — every library exercise shows a looping stick-figure
|
- **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
|
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
|
anatomical 3D skeleton (complete with feet) and rendered live from the Exercise
|
||||||
Library's rig data with the working limbs highlighted — machine exercises
|
Library's rig data with the working limbs highlighted — the camera slowly
|
||||||
include schematic equipment (seats, bars, cables, roller pads) that moves with
|
orbits every exercise while it moves, machine equipment (seats, bars, cables,
|
||||||
the figure, and face-on machines show true hip abduction and torso rotation.
|
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
|
- **Machine comfort settings** — machine-based exercises remember your setup
|
||||||
(seat height, back-rest position, pin position, …), shown on the workout
|
(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.
|
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 ?? []
|
self.props = resources.motion.props ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seconds per full camera revolution for orbiting motions.
|
/// Seconds per full camera revolution.
|
||||||
private static let orbitPeriod: Double = 24
|
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.
|
/// 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 {
|
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)
|
return timeline.geometry(at: time, yawOffset: yaw, props: props)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,23 +80,30 @@ struct ExerciseMotionTests {
|
|||||||
#expect(hypot(releasedHand.x - releasedHand0.x, releasedHand.y - releasedHand0.y) > 1)
|
#expect(hypot(releasedHand.x - releasedHand0.x, releasedHand.y - releasedHand0.y) > 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prop-free bodyweight motions slowly orbit the camera while looping; motions
|
/// Every motion slowly orbits the camera while looping — bodyweight moves and
|
||||||
/// with equipment keep their authored view (until machines orbit too).
|
/// machines alike, since the equipment layer rotates with the figure.
|
||||||
@Test func orbitAppliesOnlyToPropFreeMotions() throws {
|
@Test func everyMotionOrbits() throws {
|
||||||
let birdDog = try #require(FigureAnimation(exerciseName: "Bird Dog"))
|
|
||||||
#expect(birdDog.orbits)
|
|
||||||
// Same loop phase seconds apart: the orbit yaw differs, so the projected head
|
// Same loop phase seconds apart: the orbit yaw differs, so the projected head
|
||||||
// moves even though the pose is identical.
|
// 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 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)
|
#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"))
|
let legPress = try #require(FigureAnimation(exerciseName: "Leg Press"))
|
||||||
#expect(!legPress.orbits)
|
let geo0 = legPress.geometry(at: 0)
|
||||||
let fixed0 = legPress.geometry(at: 0).headCenter
|
let geo1 = legPress.geometry(at: legPress.timeline.duration * 2)
|
||||||
let fixed1 = legPress.geometry(at: legPress.timeline.duration * 2).headCenter
|
#expect(hypot(geo0.headCenter.x - geo1.headCenter.x,
|
||||||
#expect(abs(fixed0.x - fixed1.x) < 1e-9 && abs(fixed0.y - fixed1.y) < 1e-9)
|
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.
|
/// Every exported motion in the bundle decodes and builds a playable timeline.
|
||||||
|
|||||||
Reference in New Issue
Block a user