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
+34 -126
View File
@@ -46,11 +46,12 @@ struct FigureAnimation {
/// describes one authored viewpoint, so machines keep their fixed camera.
var orbits: Bool { props.isEmpty && hide.isEmpty }
/// Drawable geometry at `time`, with limbs listed in `hide` dropped so neither they
/// nor any prop riding them are drawn.
/// Drawable geometry at `time` the figure plus the resolved equipment layers
/// with limbs listed in `hide` dropped so neither they nor any prop riding them
/// are drawn.
func geometry(at time: Double) -> FigureGeometry {
let yaw = orbits ? 360 * (time / Self.orbitPeriod) : 0
var geo = timeline.geometry(at: time, yawOffset: yaw)
var geo = timeline.geometry(at: time, yawOffset: yaw, props: props)
for name in hide {
if let limb = FigureLimb(rawValue: name) { geo.limbs[limb] = nil }
}
@@ -121,14 +122,14 @@ struct ExerciseFigureView: View {
}
// Equipment behind the figure: scene shapes and cables.
drawBackgroundProps(&ctx, geo)
drawProps(&ctx, geo.propsBackground)
// Parts far-to-near. The head paints last (opaque), preceded by the joint-attached
// equipment so bars/pads sit over the limbs but behind the face.
for part in geo.order {
switch part {
case "head":
drawAttachedProps(&ctx, geo)
drawProps(&ctx, geo.propsForeground)
drawHead(&ctx, geo)
case "spine":
drawSpine(&ctx, geo)
@@ -164,132 +165,39 @@ struct ExerciseFigureView: View {
// 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 now 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 func jointAnchor(_ geo: FigureGeometry, _ ref: PropJointRef) -> (point: CGPoint, direction: CGVector)? {
var points: [CGPoint] = []
var direction: CGVector?
for name in ref.names {
guard let joint = Self.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)
}
private func drawBackgroundProps(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
for prop in figure.props {
switch prop.type {
case "scene":
for shape in prop.shapes ?? [] { drawSceneShape(&ctx, shape) }
case "cable":
guard let from = prop.from, from.count == 2, let to = prop.to,
let anchor = jointAnchor(geo, to) else { continue }
stroke(&ctx, [CGPoint(x: from[0], y: from[1]), anchor.point],
color: .figureEquipment, width: prop.w ?? 2)
default:
break
}
}
}
/// Bars sit at a fixed world angle (default horizontal); dumbbells and pads
/// default to perpendicular to the lower bone. Kept 1:1 with the reference
/// renderer's `resolve_props` change them in lockstep.
private func drawAttachedProps(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
for prop in figure.props {
if prop.type == "roller" {
drawRoller(&ctx, geo, prop)
continue
}
let defaults: (halfLen: Double, width: Double, plateR: Double)
switch prop.type {
case "bar": defaults = (24, 4, 0)
case "dumbbell": defaults = (7, 3, 4.5)
case "pad": defaults = (8, 7, 0)
default: continue
}
guard let at = prop.at, let anchor = jointAnchor(geo, at) else { continue }
let axis: CGVector
if prop.type == "bar" || prop.angle != nil {
axis = MotionSolver.direction(prop.angle ?? 0)
} else {
axis = CGVector(dx: -anchor.direction.dy, dy: anchor.direction.dx)
}
let h = prop.halfLen ?? defaults.halfLen
let a = CGPoint(x: anchor.point.x - axis.dx * h, y: anchor.point.y - axis.dy * h)
let b = CGPoint(x: anchor.point.x + axis.dx * h, y: anchor.point.y + axis.dy * h)
stroke(&ctx, [a, b], color: .figureProp, width: prop.w ?? defaults.width)
let plateR = prop.plateR ?? defaults.plateR
if plateR > 0 {
for end in [a, b] {
let rect = CGRect(x: end.x - plateR, y: end.y - plateR,
width: 2 * plateR, height: 2 * plateR)
ctx.fill(Path(ellipseIn: rect), with: .color(.figureProp))
/// Draws one resolved equipment layer. The solver hands back ready canvas
/// primitives (`MotionSolver.resolveProps`, kept 1:1 with the reference
/// renderer); the view only maps inks to the adaptive palette.
private func drawProps(_ ctx: inout GraphicsContext, _ prims: [PropPrimitive]) {
for prim in prims {
switch prim {
case .line(let points, let width, let ink):
stroke(&ctx, points, color: color(for: ink), width: width)
case .poly(let points, let width, let ink):
// An extruded scene slab: filled and outlined, so it degenerates
// to the plain line whenever the sweep collapses edge-on.
guard let first = points.first else { continue }
var path = Path()
path.addLines(points)
path.addLine(to: first)
path.closeSubpath()
ctx.fill(path, with: .color(color(for: ink)))
ctx.stroke(path, with: .color(color(for: ink)),
style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
case .circle(let center, let radius, let width, let fill, let ink):
let rect = CGRect(x: center.x - radius, y: center.y - radius,
width: 2 * radius, height: 2 * radius)
if fill {
ctx.fill(Path(ellipseIn: rect), with: .color(color(for: ink)))
} else {
ctx.stroke(Path(ellipseIn: rect), with: .color(color(for: ink)), lineWidth: width)
}
}
}
}
/// 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. Kept 1:1 with the reference
/// renderer's `resolve_props`.
private func drawRoller(_ ctx: inout GraphicsContext, _ geo: FigureGeometry, _ prop: MotionProp) {
guard let at = prop.at, let anchor = jointAnchor(geo, at) else { return }
let r = prop.r ?? 5
let back = prop.back ?? 0
let side = prop.side ?? 1
let px = anchor.direction.dy * side, py = -anchor.direction.dx * side
let center = CGPoint(x: anchor.point.x - anchor.direction.dx * back + px * (r + 3),
y: anchor.point.y - anchor.direction.dy * back + py * (r + 3))
let rect = CGRect(x: center.x - r, y: center.y - r, width: 2 * r, height: 2 * r)
ctx.fill(Path(ellipseIn: rect), with: .color(.figureProp))
}
private func drawSceneShape(_ ctx: inout GraphicsContext, _ shape: PropSceneShape) {
let color: Color = shape.color == "prop" ? .figureProp : .figureEquipment
switch shape.kind {
case "line":
guard let pts = shape.pts else { return }
stroke(&ctx, pts.compactMap { $0.count == 2 ? CGPoint(x: $0[0], y: $0[1]) : nil },
color: color, width: shape.w ?? 4)
case "circle":
guard let c = shape.c, c.count == 2, let r = shape.r else { return }
let rect = CGRect(x: c[0] - r, y: c[1] - r, width: 2 * r, height: 2 * r)
if shape.fill ?? true {
ctx.fill(Path(ellipseIn: rect), with: .color(color))
} else {
ctx.stroke(Path(ellipseIn: rect), with: .color(color), lineWidth: shape.w ?? 3)
}
case "rect":
guard let x = shape.x, let y = shape.y, let w = shape.w, let h = shape.h else { return }
let path = Path(roundedRect: CGRect(x: x, y: y, width: w, height: h),
cornerRadius: shape.r ?? 2)
ctx.fill(path, with: .color(color))
default:
break
}
private func color(for ink: PropInk) -> Color {
ink == .prop ? .figureProp : .figureEquipment
}
/// Near parts take the dark ink, far parts the light one; working parts swap for the