Draw the ground as an exercise mat that rotates with the figure
The floor rectangle was screen-locked, which broke the illusion the moment the camera orbited. It is now a world-space quad on the ground plane, sized to each motion's projected footprint across its key frames and rotated through the same camera as the figure - a long rectangle in profile, a parallelogram mid-orbit, end-on when face-on. Both renderers in lockstep; fixtures unaffected (the mat is a pure addition). Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
@@ -109,13 +109,16 @@ struct ExerciseFigureView: View {
|
||||
|
||||
let geo = figure.geometry(at: time)
|
||||
|
||||
// The floor plane under the elevated camera: a rectangle straddling the
|
||||
// ground line (a horizontal line is the pitch-zero degenerate case).
|
||||
let floorHalf = MotionSolver.floorHalfDepth * sin(figure.timeline.pitch * .pi / 180)
|
||||
let floor = CGRect(x: 16, y: Self.groundY + 4 - floorHalf,
|
||||
width: Self.designSize.width - 32, height: 2 * floorHalf)
|
||||
ctx.stroke(Path(roundedRect: floor, cornerRadius: 3), with: .color(.figureGround),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
|
||||
// The exercise mat: a ground-plane quad sized to the motion's footprint,
|
||||
// rotating with the camera about the figure.
|
||||
if let floorQuad = geo.floor, let first = floorQuad.first {
|
||||
var path = Path()
|
||||
path.addLines(floorQuad)
|
||||
path.addLine(to: first)
|
||||
path.closeSubpath()
|
||||
ctx.stroke(path, with: .color(.figureGround),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
|
||||
// Equipment behind the figure: scene shapes and cables.
|
||||
drawBackgroundProps(&ctx, geo)
|
||||
|
||||
@@ -163,6 +163,9 @@ struct FigureGeometry {
|
||||
/// right attach, far offsets included so the bars meet the drawn limbs exactly.
|
||||
var girdle: [CGPoint]
|
||||
var pelvisBar: [CGPoint]
|
||||
/// The exercise mat: a world-space quad on the ground plane, sized to the
|
||||
/// motion's footprint and rotating with the camera. Nil when no mat was given.
|
||||
var floor: [CGPoint]?
|
||||
/// Attach → elbow/knee → hand (arms: 3 points); hip → knee → ankle → toe (legs: 4).
|
||||
var limbs: [FigureLimb: [CGPoint]]
|
||||
/// Parts far-to-near, `"head"` always last (`"spine"`, `"arm_r"`, … then `"head"`).
|
||||
@@ -403,11 +406,14 @@ enum MotionSolver {
|
||||
/// The standard slightly-elevated viewpoint: the camera pitches down a touch so
|
||||
/// the floor reads as a plane. Kept 1:1 with the reference renderer's CAMERA_PITCH.
|
||||
static let defaultPitch: Double = 10
|
||||
/// Floor plane half-depth; its projected height is `floorHalfDepth · sin(pitch)`.
|
||||
/// Exercise-mat half-depth (into the screen) and the ground line's canvas y,
|
||||
/// shared with the reference renderer.
|
||||
static let floorHalfDepth: Double = 30
|
||||
static let groundY: Double = 152
|
||||
|
||||
static func frameGeometry(_ nf: NormalizedFrame, prof: SkeletonProfile, cam: Double,
|
||||
pitch: Double = MotionSolver.defaultPitch) -> (NormalizedFrame, FigureGeometry) {
|
||||
pitch: Double = MotionSolver.defaultPitch,
|
||||
mat: (lo: Double, hi: Double)? = nil) -> (NormalizedFrame, FigureGeometry) {
|
||||
let p0 = pose(nf, prof: prof, cam: cam, camPitch: pitch)
|
||||
var shade: [FigureLimb: Shade] = [:]
|
||||
for (right, left) in pairs {
|
||||
@@ -475,12 +481,23 @@ enum MotionSolver {
|
||||
func attachPoint(_ v: Vec3, _ limb: FigureLimb) -> CGPoint {
|
||||
scr(v, shade[limb] == .far ? off : .zero)
|
||||
}
|
||||
// The exercise mat: a ground-plane quad rotating with the camera about the
|
||||
// figure's vertical axis (`mat` = footprint bounds in the authored view).
|
||||
var floor: [CGPoint]?
|
||||
if let mat {
|
||||
let yg = -(groundY + 4 - anchor.y)
|
||||
let rc = Mat3.rotX(pitch).times(Mat3.rotY(-cam))
|
||||
floor = [(mat.lo, floorHalfDepth), (mat.hi, floorHalfDepth),
|
||||
(mat.hi, -floorHalfDepth), (mat.lo, -floorHalfDepth)].map { dx, dz in
|
||||
scr(rc.apply(Vec3(dx - anchor.x, yg, dz)))
|
||||
}
|
||||
}
|
||||
let geo = FigureGeometry(
|
||||
headCenter: head, headRadius: prof.headR, noseStart: noseStart, noseEnd: noseEnd,
|
||||
spineStart: pelvis, spineControl: control, spineEnd: neckB,
|
||||
girdle: [attachPoint(p.shoulderL, .armL), neckB, attachPoint(p.shoulderR, .armR)],
|
||||
pelvisBar: [attachPoint(p.hipL, .legL), pelvis, attachPoint(p.hipR, .legR)],
|
||||
limbs: limbs, order: order, shade: shade)
|
||||
floor: floor, limbs: limbs, order: order, shade: shade)
|
||||
return (resolved, geo)
|
||||
}
|
||||
}
|
||||
@@ -494,13 +511,29 @@ struct MotionTimeline {
|
||||
let cam: Double
|
||||
let pitch: Double
|
||||
let duration: Double
|
||||
/// The motion's footprint in the authored view (min/max screen x of every figure
|
||||
/// point across the key frames, padded) — the exercise mat spans it.
|
||||
let mat: (lo: Double, hi: Double)
|
||||
|
||||
init?(motion: ExerciseMotion, profile: SkeletonProfile) {
|
||||
let cam = motion.camera?.yaw ?? 0
|
||||
let pitch = motion.camera?.pitch ?? MotionSolver.defaultPitch
|
||||
let norms = motion.frames.map { MotionSolver.normalize($0) }
|
||||
guard !norms.isEmpty else { return nil }
|
||||
let resolved = norms.map { MotionSolver.frameGeometry($0, prof: profile, cam: cam, pitch: pitch).0 }
|
||||
var resolved: [NormalizedFrame] = []
|
||||
var lo = Double.infinity, hi = -Double.infinity
|
||||
for norm in norms {
|
||||
let (frame, geo) = MotionSolver.frameGeometry(norm, prof: profile, cam: cam, pitch: pitch)
|
||||
resolved.append(frame)
|
||||
var xs: [Double] = [Double(geo.headCenter.x) - geo.headRadius,
|
||||
Double(geo.headCenter.x) + geo.headRadius,
|
||||
Double(geo.spineStart.x), Double(geo.spineControl.x),
|
||||
Double(geo.spineEnd.x)]
|
||||
xs += (geo.girdle + geo.pelvisBar).map { Double($0.x) }
|
||||
xs += geo.limbs.values.flatMap { $0.map { Double($0.x) } }
|
||||
lo = min(lo, xs.min() ?? lo)
|
||||
hi = max(hi, xs.max() ?? hi)
|
||||
}
|
||||
let duration = resolved.reduce(0) { $0 + $1.hold + $1.tween }
|
||||
guard duration > 0 else { return nil }
|
||||
self.resolved = resolved
|
||||
@@ -508,6 +541,7 @@ struct MotionTimeline {
|
||||
self.cam = cam
|
||||
self.pitch = pitch
|
||||
self.duration = duration
|
||||
self.mat = (lo - 12, hi + 12)
|
||||
}
|
||||
|
||||
/// The resolved frame (or eased tween) at wall-clock `time`, looping every
|
||||
@@ -535,10 +569,10 @@ struct MotionTimeline {
|
||||
func geometry(at time: Double, yawOffset: Double = 0) -> FigureGeometry {
|
||||
let frame = frame(at: time)
|
||||
guard yawOffset != 0 else {
|
||||
return MotionSolver.frameGeometry(frame, prof: profile, cam: cam, pitch: pitch).1
|
||||
return MotionSolver.frameGeometry(frame, prof: profile, cam: cam, 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).1
|
||||
return MotionSolver.frameGeometry(posed, prof: profile, cam: cam + yawOffset, pitch: pitch, mat: mat).1
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user