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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user