Give machine props world-space 3D form that rotates with the camera

Scene shapes, cable anchors, bar angles, pad perpendiculars, and roller
offsets all resolve in the authored view exactly as before, then rotate
about the world-vertical axis through the root anchor - the same
resolve-then-rotate pattern as the figure's pins and the mat - so at the
authored yaw every exercise renders bit-identically to today, and under
an orbiting camera the equipment turns with the figure while staying
welded to its hands and feet. Scene lines gain an optional depth plane
(z) and slab extrusion (depth) so seats, backrests, and platforms keep
form edge-on; the rect shape is retired (re-authored as slab lines).
All 14 machines' props re-authored with depths and verified at eight
orbit angles. The fixture snapshots move into the pipeline as
render.py --fixtures and now cover orbit-presentation samples with
resolved prop primitives for a spread of prop flavors; the in-app
renderer resolves props in MotionSolver (lockstep with resolve_props)
and the view just draws primitives.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-06 22:15:45 -04:00
parent ed906535b6
commit 81186c51b1
92 changed files with 723 additions and 403 deletions
+198 -6
View File
@@ -150,6 +150,25 @@ struct NormalizedFrame {
/// the far member draws light, behind, and nudged by the readability offset.
enum Shade { case near, far }
/// The two equipment inks: recessive `equipment` gray for scene shapes and cables
/// behind the figure, darker `prop` gray for joint-attached items over the limbs.
enum PropInk {
case equipment, prop
init(_ name: String?) { self = name == "prop" ? .prop : .equipment }
}
/// One resolved equipment drawing primitive, in canvas coordinates the shape the
/// prop layer reduces to for a single frame (mirroring the reference renderer's
/// `resolve_props` output; kept 1:1, change them in lockstep).
enum PropPrimitive {
case line(points: [CGPoint], width: Double, ink: PropInk)
/// A closed, filled, outlined polygon an extruded scene slab. Degenerates to
/// the plain line whenever the sweep collapses edge-on (the authored view).
case poly(points: [CGPoint], width: Double, ink: PropInk)
case circle(center: CGPoint, radius: Double, width: Double, fill: Bool, ink: PropInk)
}
/// A pose resolved to drawable canvas points (plus the depth-sorted draw order and
/// per-limb shading the view needs).
struct FigureGeometry {
@@ -171,6 +190,10 @@ struct FigureGeometry {
/// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, then `"head"`).
var order: [String]
var shade: [FigureLimb: Shade]
/// 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] = []
var propsForeground: [PropPrimitive] = []
}
/// FK output in view space (x right, y up, z toward the camera; origin at the root).
@@ -500,6 +523,160 @@ enum MotionSolver {
floor: floor, limbs: limbs, order: order, shade: shade)
return (resolved, geo)
}
// MARK: Props
/// Prop joint refs (limb, chain index): extremities are index 2, mid joints
/// (elbows/knees) index 1, so equipment can ride either joint. Kept 1:1 with
/// the reference renderer's `JOINT_LIMB` (legs end in a foot bone, so a foot
/// prop rides the ankle at index 2).
private static let propJoints: [String: (limb: FigureLimb, index: Int)] = [
"hand_r": (.armR, 2), "elbow_r": (.armR, 1),
"hand_l": (.armL, 2), "elbow_l": (.armL, 1),
"foot_r": (.legR, 2), "knee_r": (.legR, 1),
"foot_l": (.legL, 2), "knee_l": (.legL, 1),
]
/// The joint point a prop follows (plus the unit direction of the bone ending
/// at the first joint, for perpendicular items). Nil when any referenced limb
/// isn't drawn this frame.
private static func jointAnchor(_ geo: FigureGeometry, _ ref: PropJointRef) -> (point: CGPoint, direction: CGVector)? {
var points: [CGPoint] = []
var direction: CGVector?
for name in ref.names {
guard let joint = propJoints[name],
let chain = geo.limbs[joint.limb], chain.count > joint.index else { return nil }
let a = chain[joint.index - 1]
let b = chain[joint.index]
points.append(b)
if direction == nil {
let d = max(hypot(b.x - a.x, b.y - a.y), 1)
direction = CGVector(dx: (b.x - a.x) / d, dy: (b.y - a.y) / d)
}
}
guard let direction, !points.isEmpty else { return nil }
let center = CGPoint(x: points.reduce(0) { $0 + $1.x } / CGFloat(points.count),
y: points.reduce(0) { $0 + $1.y } / CGFloat(points.count))
return (center, direction)
}
/// View-space rotation carrying authored-view prop geometry to the camera
/// `yawOffset` degrees past the authored yaw: a rotation about the world-vertical
/// axis through the root anchor, conjugated by the camera elevation. Nil (the
/// identity) at offset 0, so the authored view stays bit-exact.
static func propRotation(pitch: Double, yawOffset: Double) -> Mat3? {
guard yawOffset != 0 else { return nil }
return Mat3.rotX(pitch).times(Mat3.rotY(-yawOffset)).times(Mat3.rotX(-pitch))
}
/// Props drawable primitives for one frame: (background, foreground).
///
/// `geo` is the frame's drawn (possibly orbit-rotated) geometry, `authored` the
/// same frame at the authored camera (pass `geo` itself when not orbiting),
/// `anchor` the frame's root canvas anchor, and `rotation` the `propRotation`
/// between them. Joint positions come from `geo`; everything authored scene
/// points, cable anchors, bar angles, pad perpendiculars, roller offsets
/// resolves against `authored` and rotates. Kept 1:1 with the reference
/// renderer's `resolve_props` change them in lockstep.
static func resolveProps(_ props: [MotionProp], geo: FigureGeometry, authored: FigureGeometry,
anchor: CGPoint, rotation: Mat3?) -> (bg: [PropPrimitive], fg: [PropPrimitive]) {
/// Authored canvas point (+ depth toward the camera) drawn canvas.
func place(_ x: Double, _ y: Double, _ z: Double) -> CGPoint {
guard let rotation else { return CGPoint(x: x, y: y) }
let v = rotation.apply(Vec3(x - anchor.x, anchor.y - y, z))
return CGPoint(x: anchor.x + v.x, y: anchor.y - v.y)
}
/// Authored canvas-space direction/offset drawn canvas (y-down).
func swing(_ vec: CGVector) -> CGVector {
guard let rotation else { return vec }
let v = rotation.apply(Vec3(vec.dx, -vec.dy, 0))
return CGVector(dx: v.x, dy: -v.y)
}
var bg: [PropPrimitive] = []
var fg: [PropPrimitive] = []
for prop in props {
switch prop.type {
case "scene":
for shape in prop.shapes ?? [] {
let ink = PropInk(shape.color)
let z = shape.z ?? 0
switch shape.kind {
case "line":
guard let pts = shape.pts else { continue }
let lifted = pts.compactMap { pt -> (x: Double, y: Double, z: Double)? in
pt.count >= 2 ? (pt[0], pt[1], z + (pt.count > 2 ? pt[2] : 0)) : nil
}
if let depth = shape.depth, depth != 0 {
// An extruded slab: the polyline swept through ±depth,
// filled and outlined so it degenerates to the plain
// line whenever the sweep collapses edge-on.
let quad = lifted.map { place($0.x, $0.y, $0.z + depth) }
+ lifted.reversed().map { place($0.x, $0.y, $0.z - depth) }
bg.append(.poly(points: quad, width: shape.w ?? 4, ink: ink))
} else {
bg.append(.line(points: lifted.map { place($0.x, $0.y, $0.z) },
width: shape.w ?? 4, ink: ink))
}
case "circle":
guard let c = shape.c, c.count == 2, let r = shape.r else { continue }
bg.append(.circle(center: place(c[0], c[1], z), radius: r,
width: shape.w ?? 3, fill: shape.fill ?? false, ink: ink))
default:
break
}
}
case "cable":
guard let from = prop.from, from.count >= 2, let to = prop.to,
let end = jointAnchor(geo, to) else { continue }
let start = place(from[0], from[1], from.count > 2 ? from[2] : 0)
bg.append(.line(points: [start, end.point], width: prop.w ?? 2, ink: .equipment))
case "roller":
// A machine roller pad seen end-on: a disc riding the limb's lower
// bone near the joint, on the `side` (+1/1) of the bone it presses.
// The offset resolves along the authored-view bone, then rotates.
guard let at = prop.at, let now = jointAnchor(geo, at),
let auth = jointAnchor(authored, at) else { continue }
let r = prop.r ?? 5
let back = prop.back ?? 0
let side = prop.side ?? 1
let d = auth.direction
let px = d.dy * side, py = -d.dx * side
let off = swing(CGVector(dx: -d.dx * back + px * (r + 3),
dy: -d.dy * back + py * (r + 3)))
fg.append(.circle(center: CGPoint(x: now.point.x + off.dx, y: now.point.y + off.dy),
radius: r, width: 3, fill: true, ink: .prop))
case "bar", "dumbbell", "pad":
let defaults: (halfLen: Double, width: Double, plateR: Double) = switch prop.type {
case "bar": (24, 4, 0)
case "dumbbell": (7, 3, 4.5)
default: (8, 7, 0)
}
guard let at = prop.at, let now = jointAnchor(geo, at),
let auth = jointAnchor(authored, at) else { continue }
let axis: CGVector
if prop.type == "bar" || prop.angle != nil {
axis = direction(prop.angle ?? 0) // fixed authored-view angle
} else {
axis = CGVector(dx: -auth.direction.dy, dy: auth.direction.dx)
}
let u = swing(axis) // foreshortens under orbit
let h = prop.halfLen ?? defaults.halfLen
let a = CGPoint(x: now.point.x - u.dx * h, y: now.point.y - u.dy * h)
let b = CGPoint(x: now.point.x + u.dx * h, y: now.point.y + u.dy * h)
fg.append(.line(points: [a, b], width: prop.w ?? defaults.width, ink: .prop))
let plateR = prop.plateR ?? defaults.plateR
if plateR > 0 {
for end in [a, b] {
fg.append(.circle(center: end, radius: plateR, width: 3, fill: true, ink: .prop))
}
}
default:
break
}
}
return (bg, fg)
}
}
/// The full looping animation for one motion: key frames resolved to anatomical angles,
@@ -566,13 +743,28 @@ struct MotionTimeline {
/// targets in the *authored* view, so the pose resolves there first and the posed
/// body is then rotated; re-pinning in a rotated view would glue hands to screen
/// points that no longer correspond to anything (arms visibly "stuck" mid-orbit).
func geometry(at time: Double, yawOffset: Double = 0) -> FigureGeometry {
/// `props` are resolved into the geometry's primitive layers the same way: joint
/// anchors follow the rotated figure, while authored constructs (scene points,
/// cable anchors, bar angles, pad perpendiculars) resolve in the authored view
/// and rotate about the root anchor's vertical axis.
func geometry(at time: Double, yawOffset: Double = 0, props: [MotionProp] = []) -> FigureGeometry {
let frame = frame(at: time)
guard yawOffset != 0 else {
return MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch, mat: mat).1
var geo: FigureGeometry
let authored: FigureGeometry
if yawOffset == 0 {
geo = MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch, mat: mat).1
authored = geo
} else {
let (posedFrame, authoredGeo) = MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch)
authored = authoredGeo
var posed = posedFrame
posed.pins = [:]
geo = MotionSolver.frameGeometry(posed, prof: profile, cam: cam + yawOffset, pitch: pitch, mat: mat).1
}
var posed = MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch).0
posed.pins = [:]
return MotionSolver.frameGeometry(posed, prof: profile, cam: cam + yawOffset, pitch: pitch, mat: mat).1
guard !props.isEmpty else { return geo }
let rotation = MotionSolver.propRotation(pitch: pitch, yawOffset: yawOffset)
(geo.propsBackground, geo.propsForeground) = MotionSolver.resolveProps(
props, geo: geo, authored: authored, anchor: frame.rootPos, rotation: rotation)
return geo
}
}