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