Skip to content

Commit b78560b

Browse files
authored
fix: correct UnitCurve cubic interpolation (#1218)
1 parent 9c53328 commit b78560b

3 files changed

Lines changed: 100 additions & 54 deletions

File tree

‎Sources/OpenSwiftUICore/Animation/Animation/UnitCurve.swift‎

Lines changed: 42 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// OpenSwiftUICore
44
//
55
// Audited for 6.5.4
6-
// Status: Complete (solveX needs verification)
6+
// Status: Complete
77
// ID: 54864F491103B6AE5CAC10D2D922245F (SwiftUICore)
88

99
package import Foundation
@@ -80,11 +80,11 @@ public struct UnitCurve {
8080
let solver = CubicSolver(
8181
startControlPoint: UnitPoint(
8282
x: startControlPoint.x.clamp(min: 0.0, max: 1.0),
83-
y: startControlPoint.y.clamp(min: 0.0, max: 1.0)
83+
y: startControlPoint.y
8484
),
8585
endControlPoint: UnitPoint(
8686
x: endControlPoint.x.clamp(min: 0.0, max: 1.0),
87-
y: endControlPoint.y.clamp(min: 0.0, max: 1.0)
87+
y: endControlPoint.y
8888
)
8989
)
9090
return solver.value(at: clampValue)
@@ -292,80 +292,69 @@ extension UnitCurve {
292292
return round((y / x) * pow(2, 20)) * pow(2, -20)
293293
}
294294

295-
// TODO: Implemented by Copilot. Verify this via unit test later
296295
fileprivate func solveX(_ time: Double, epsilon: Double) -> Double {
297-
// Helper function to evaluate cubic polynomial: ax*t³ + bx*t² + cx*t
298296
func evaluateX(_ t: Double) -> Double {
299-
return ((ax * t + bx) * t + cx) * t
297+
return (cx + t * (bx + ax * t)) * t
300298
}
301299

302-
// Helper function to evaluate derivative: 3*ax*t² + 2*bx*t + cx
303300
func evaluateDerivativeX(_ t: Double) -> Double {
304-
return (3.0 * ax * t + 2.0 * bx) * t + cx
301+
return cx + ((bx + bx) + (ax * 3.0) * t) * t
305302
}
306303

307-
// Initial guess using direct evaluation
308-
let initialGuess = evaluateX(time)
309-
let initialError = abs(initialGuess - time)
310-
311-
// If initial guess is close enough, return it
312-
if initialError < epsilon {
304+
let initialValue = evaluateX(time)
305+
let initialError = initialValue - time
306+
if abs(initialError) < epsilon {
313307
return time
314308
}
315309

316-
let derivative = evaluateDerivativeX(time)
317-
318-
// Try Newton's method if derivative is large enough
319-
if abs(derivative) >= epsilon {
320-
var t = time - (initialGuess - time) / derivative
321-
322-
// Perform up to 7 Newton iterations
323-
for _ in 0..<7 {
324-
let value = evaluateX(t)
325-
let error = abs(value - time)
326-
327-
if error < epsilon {
328-
return t
329-
}
330-
331-
let deriv = evaluateDerivativeX(t)
332-
if abs(deriv) < epsilon {
333-
break // Derivative too small, fall back to bisection
334-
}
335-
336-
t = t - (value - time) / deriv
310+
var t = time
311+
var value = initialValue
312+
for _ in 0..<7 {
313+
let derivative = evaluateDerivativeX(t)
314+
// Only an ordered comparison triggers fallback. NaN continues.
315+
if abs(derivative) < epsilon {
316+
break
317+
}
318+
t = t - (value - time) / derivative
319+
value = evaluateX(t)
320+
if abs(value - time) < epsilon {
321+
return t
337322
}
338323
}
339324

340-
// Fall back to bisection method if Newton's method fails or isn't applicable
341-
if time < 0.0 || time > 1.0 {
342-
return time // Outside valid range
325+
if time < 0.0 {
326+
return 0.0
327+
}
328+
if time > 1.0 {
329+
return 1.0
343330
}
344331

332+
// Restart bisection with the original input and signed residual.
345333
var low = 0.0
346334
var high = 1.0
347-
var t = time
348-
var iterationCount = 0
349-
350-
while low < high && iterationCount >= 0 {
351-
let value = evaluateX(t)
352-
let error = abs(value - time)
353-
354-
if error < epsilon {
355-
return t
356-
}
335+
t = time
336+
var error = initialError
337+
var remainingIterations = 1023
357338

358-
let diff = value - time
359-
if diff < 0.0 {
339+
while true {
340+
if error < 0.0 {
360341
low = t
361342
} else {
362343
high = t
363344
}
364-
365345
t = low + (high - low) * 0.5
366-
iterationCount += 1
346+
// Return this next midpoint when the bracket or limit ends,
347+
// before another evaluation. An unordered bracket also ends.
348+
guard low < high, remainingIterations != 0 else {
349+
return t
350+
}
351+
value = evaluateX(t)
352+
error = value - time
353+
remainingIterations -= 1
354+
if abs(error) < epsilon {
355+
return t
356+
}
367357
}
368-
return t
369358
}
370359
}
371360
}

‎Tests/OpenSwiftUICompatibilityTests/Animation/Animation/UnitCurveCompatibilityTests.swift‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// UnitCurveCompatibilityTests.swift
33
// OpenSwiftUICompatibilityTests
44

5-
import Testing
65
import Numerics
6+
import Testing
77

88
// MARK: - UnitCurveTests
99

@@ -150,9 +150,24 @@ struct UnitCurveCompatibilityTests {
150150
#expect(curve.value(at: 0.0).isApproximatelyEqual(to: 0.0))
151151
#expect(curve.value(at: 1.0).isApproximatelyEqual(to: 1.0))
152152
#expect(curve.value(at: 0.5).isApproximatelyEqual(to: 0.5))
153+
#expect(curve.value(at: 0.15625).isApproximatelyEqual(to: 0.71875, absoluteTolerance: 0.00001))
154+
#expect(curve.value(at: 0.84375).isApproximatelyEqual(to: 0.28125, absoluteTolerance: 0.00001))
153155
#expect(curve.velocity(at: 0.5).isApproximatelyEqual(to: -1.0))
154156
}
155157

158+
@Test(arguments: [
159+
(-1.0, -0.625),
160+
(2.0, 1.625),
161+
])
162+
func bezierValuePreservesUnclampedY(controlY: Double, expected: Double) {
163+
let curve = UnitCurve.bezier(
164+
startControlPoint: UnitPoint(x: 0.25, y: controlY),
165+
endControlPoint: UnitPoint(x: 0.75, y: controlY)
166+
)
167+
168+
#expect(curve.value(at: 0.5) == expected)
169+
}
170+
156171
// MARK: - Value Function
157172

158173
@Test

‎Tests/OpenSwiftUICoreTests/Animation/Animation/UnitCurveTests.swift‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,48 @@ struct UnitCurveTests {
4141
#expect(solver.velocity(at: 0.5).isApproximatelyEqual(to: 1.199, absoluteTolerance: 0.001))
4242
}
4343

44+
@Test(arguments: [
45+
(0.0, 1.0, -0.0001, 0.0),
46+
(1.0, 0.0, 1.0001, 1.0),
47+
])
48+
func cubicSolverClampsFallbackEndpoints(x: Double, y: Double, progress: Double, expected: Double) {
49+
let point = UnitPoint(x: x, y: y)
50+
let solver = UnitCurve.CubicSolver(startControlPoint: point, endControlPoint: point)
51+
52+
#expect(solver.value(at: progress) == expected)
53+
}
54+
55+
@Test
56+
func cubicSolverDoesNotClampAcceptedInput() {
57+
let solver = UnitCurve.CubicSolver(
58+
startControlPoint: UnitPoint(x: 1.0 / 3.0, y: 1.0 / 3.0),
59+
endControlPoint: UnitPoint(x: 2.0 / 3.0, y: 2.0 / 3.0)
60+
)
61+
62+
#expect(solver.value(at: -1.0) == -1.0)
63+
#expect(solver.value(at: 2.0) == 2.0)
64+
}
65+
66+
@Test
67+
func cubicSolverBoundsUnorderedBisection() {
68+
// NaN x controls prevent convergence. A large finite y scale keeps
69+
// the final midpoint visible after output rounding.
70+
let point = UnitPoint(x: .nan, y: 0x1p1022)
71+
let solver = UnitCurve.CubicSolver(startControlPoint: point, endControlPoint: point)
72+
73+
#expect(solver.value(at: 0.5) == 0.375)
74+
}
75+
76+
@Test
77+
func cubicSolverPreservesNaNInput() {
78+
let solver = UnitCurve.CubicSolver(
79+
startControlPoint: UnitPoint(x: 0.25, y: 0.1),
80+
endControlPoint: UnitPoint(x: 0.75, y: 0.9)
81+
)
82+
83+
#expect(solver.value(at: .nan).isNaN)
84+
}
85+
4486
// MARK: - Edge Cases
4587

4688
@Test

0 commit comments

Comments
 (0)