react-native-magnetkit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/android/build.gradle +18 -0
  4. package/android/src/main/java/dev/libraries/magnetkit/MagnetDipole.kt +76 -0
  5. package/android/src/main/java/dev/libraries/magnetkit/MagnetModel.kt +189 -0
  6. package/android/src/main/java/dev/libraries/magnetkit/MagnetSource.kt +598 -0
  7. package/android/src/main/java/dev/libraries/magnetkit/MfxlNet.kt +92 -0
  8. package/android/src/main/java/dev/libraries/magnetkit/Vec3.kt +30 -0
  9. package/android/src/main/java/dev/libraries/magnetkit/rn/MagnetKitModule.kt +172 -0
  10. package/app.plugin.js +1 -0
  11. package/build/MagnetCalibration.d.ts +13 -0
  12. package/build/MagnetCalibration.js +58 -0
  13. package/build/MagnetKitModule.d.ts +52 -0
  14. package/build/MagnetKitModule.js +2 -0
  15. package/build/MagneticView.d.ts +15 -0
  16. package/build/MagneticView.js +129 -0
  17. package/build/core/calibration-core.d.ts +43 -0
  18. package/build/core/calibration-core.js +84 -0
  19. package/build/core/field.d.ts +64 -0
  20. package/build/core/field.js +341 -0
  21. package/build/core/fit.d.ts +13 -0
  22. package/build/core/fit.js +50 -0
  23. package/build/core/types.d.ts +37 -0
  24. package/build/core/types.js +2 -0
  25. package/build/hooks.d.ts +7 -0
  26. package/build/hooks.js +29 -0
  27. package/build/index.d.ts +9 -0
  28. package/build/index.js +7 -0
  29. package/build/magnet.d.ts +29 -0
  30. package/build/magnet.js +66 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/MagnetKit/MFXLNet.swift +89 -0
  33. package/ios/MagnetKit/MagnetLocator.swift +252 -0
  34. package/ios/MagnetKit/MagnetModel.swift +56 -0
  35. package/ios/MagnetKit/MagnetPresence.swift +81 -0
  36. package/ios/MagnetKit/MagnetSource.swift +967 -0
  37. package/ios/MagnetKit/MagnetTap.swift +53 -0
  38. package/ios/MagnetKitModule.swift +147 -0
  39. package/ios/MagnetKitRN.podspec +26 -0
  40. package/package.json +54 -4
  41. package/plugin/withMagnetKit.js +60 -0
  42. package/src/MagnetCalibration.tsx +87 -0
  43. package/src/MagnetKitModule.ts +42 -0
  44. package/src/MagneticView.tsx +153 -0
  45. package/src/core/calibration-core.ts +112 -0
  46. package/src/core/field.ts +295 -0
  47. package/src/core/fit.ts +55 -0
  48. package/src/core/types.ts +32 -0
  49. package/src/hooks.ts +32 -0
  50. package/src/index.ts +9 -0
  51. package/src/magnet.ts +77 -0
@@ -0,0 +1,967 @@
1
+ import SwiftUI
2
+ import CoreMotion
3
+ import simd
4
+
5
+ // MARK: - Shared CoreMotion manager
6
+
7
+ /// One `CMMotionManager` for the app (Apple asks for one per app): the
8
+ /// magnet reads the magnetometer; other libraries that need device motion
9
+ /// (MetalFxKit's tilt bend) share it rather than starting their own.
10
+ public final class MagnetMotion {
11
+ public static let shared = MagnetMotion()
12
+ public let manager = CMMotionManager()
13
+ private var deviceMotionUsers = 0
14
+ private var magnetometerUsers = 0
15
+
16
+ private init() {}
17
+
18
+ public func retainDeviceMotion() {
19
+ deviceMotionUsers += 1
20
+ guard deviceMotionUsers == 1, manager.isDeviceMotionAvailable, !manager.isDeviceMotionActive else { return }
21
+ manager.deviceMotionUpdateInterval = 1.0 / 60.0
22
+ manager.startDeviceMotionUpdates()
23
+ }
24
+
25
+ public func releaseDeviceMotion() {
26
+ deviceMotionUsers = max(0, deviceMotionUsers - 1)
27
+ if deviceMotionUsers == 0, manager.isDeviceMotionActive { manager.stopDeviceMotionUpdates() }
28
+ }
29
+
30
+ public func retainMagnetometer() {
31
+ magnetometerUsers += 1
32
+ guard magnetometerUsers == 1, manager.isMagnetometerAvailable, !manager.isMagnetometerActive else { return }
33
+ manager.magnetometerUpdateInterval = 1.0 / 100.0
34
+ manager.startMagnetometerUpdates()
35
+ }
36
+
37
+ public func releaseMagnetometer() {
38
+ magnetometerUsers = max(0, magnetometerUsers - 1)
39
+ if magnetometerUsers == 0, manager.isMagnetometerActive { manager.stopMagnetometerUpdates() }
40
+ }
41
+ }
42
+
43
+ /// Device axes (x right, y toward the top edge) onto screen axes (x right,
44
+ /// y down) for the current interface orientation.
45
+ func magnetScreenVector(_ x: Double, _ y: Double, _ orientation: UIInterfaceOrientation) -> CGVector {
46
+ switch orientation {
47
+ case .landscapeLeft: return CGVector(dx: -y, dy: -x)
48
+ case .landscapeRight: return CGVector(dx: y, dy: x)
49
+ case .portraitUpsideDown: return CGVector(dx: -x, dy: y)
50
+ default: return CGVector(dx: x, dy: -y)
51
+ }
52
+ }
53
+
54
+ // MARK: - Magnet
55
+
56
+ /// How `MagnetConfig.locatorEnabled` finds the magnet.
57
+ public enum MagnetLocatorMode: String, Sendable {
58
+ case network, dipole
59
+ }
60
+
61
+ /// Which way a magnet pushes what it drives: attract, repel, or by its face.
62
+ public enum MagnetPolarity: String, CaseIterable, Identifiable, Sendable {
63
+ /// From the field's vertical component: one face attracts, the other repels.
64
+ case auto
65
+ case attract
66
+ case repel
67
+ public var id: String { rawValue }
68
+ }
69
+
70
+ /// A trained direction: where the magnet was (its angle around the origin,
71
+ /// screen axes) and the field direction that put on the sensor.
72
+ public struct MagnetTemplate: Codable, Equatable, Sendable {
73
+ /// Radians, screen axes (x right, y down), from `MagnetConfig.templateOrigin`
74
+ /// (or from the driven view's centre when there is none).
75
+ public var angle: Double
76
+ /// Unit vector, device axes.
77
+ public var field: SIMD3<Double>
78
+ /// How far from the origin the magnet was, pt. With it (and an origin)
79
+ /// the magnet's position is estimated (`reading.position`).
80
+ public var distance: Double?
81
+ /// Field strength at this spot, µT. A magnet on a side of the phone
82
+ /// nearer the sensor reads stronger, which separates sides whose field
83
+ /// directions look alike.
84
+ public var strength: Double?
85
+
86
+ /// `field` may be the raw change in µT (its length becomes `strength`)
87
+ /// or a unit vector.
88
+ public init(angle: Double, field: SIMD3<Double>, distance: Double? = nil, strength: Double? = nil) {
89
+ self.angle = angle
90
+ let l = simd_length(field)
91
+ self.field = l > 0 ? field / l : SIMD3(1, 0, 0)
92
+ self.distance = distance
93
+ self.strength = strength ?? (l > 1.5 ? l : nil)
94
+ }
95
+ }
96
+
97
+ /// A quick magnet movement, read from how fast the field grows or fades
98
+ /// (and, with the locator, from how fast the located point moves).
99
+ public enum MagnetGesture: Equatable, Sendable {
100
+ /// Pushed quickly toward the phone; approach speed, cm/s.
101
+ case push(speed: Double)
102
+ /// Pulled quickly away; retreat speed, cm/s.
103
+ case pullAway(speed: Double)
104
+ /// An air tap: hovering over the glass, dipped quickly toward the screen
105
+ /// and brought back (the tap network, `config.tapModel`; without it, a
106
+ /// push and a pull-away in quick succession). `at`: the located point,
107
+ /// global screen points, when the locator is on.
108
+ case tap(at: CGPoint?)
109
+ /// Swept across the screen (needs the locator); unit direction and speed, pt/s.
110
+ case swipe(direction: CGVector, speed: Double)
111
+ }
112
+
113
+ /// How a magnetometer reading becomes a magnet reading.
114
+ public struct MagnetConfig: Equatable, Sendable {
115
+ /// Field change, µT, that reads as full pull. 60 suits a small fridge
116
+ /// magnet held close; a neodymium one reaches thousands.
117
+ public var fullScale: Double = 60
118
+ /// Knee of the log scale, µT: pull = log(1 + B/scale) / log(1 + full/scale).
119
+ /// Smaller = more of the range goes to weak, distant fields.
120
+ public var scale: Double = 10
121
+ /// Change below this, µT, is noise (Earth's field wobbles with handling).
122
+ public var deadzone: Double = 3
123
+ /// Low-pass time constant on the reading, s.
124
+ public var smoothing: Double = 0.06
125
+ public var polarity: MagnetPolarity = .auto
126
+ /// Swap which face attracts in `.auto`.
127
+ public var invertPolarity = false
128
+ /// Re-zero once the phone has settled in a new pose (picked up, turned).
129
+ public var autoRecalibrate = true
130
+ /// Attitude change, degrees, that counts as a new pose.
131
+ public var poseTolerance: Double = 10
132
+ /// Stillness needed before re-zeroing in a new pose, s.
133
+ public var settleSeconds: Double = 0.8
134
+ /// Where the magnetometer sits, in global (window) points. With it, views
135
+ /// can aim at the estimated magnet position instead of sharing the
136
+ /// sensor's bearing. Find it by sliding a magnet over the back of the
137
+ /// phone and watching for the peak.
138
+ public var sensorPosition: CGPoint? = nil
139
+ /// Distance estimate r(cm) = k · B(µT)^(-1/3) — a dipole's 1/r³ fall-off.
140
+ /// 16 fits a fridge magnet (~500 µT at 2 cm).
141
+ public var distanceK: Double = 16
142
+ /// Screen points per centimetre (an iPhone is ~60).
143
+ public var pointsPerCm: Double = 60
144
+ /// Trained directions (3+; 8 around the phone is good). With them the
145
+ /// bearing is read off your phone, sensor spot and grip instead of a
146
+ /// dipole guess, and a flipped magnet is told apart from one on the
147
+ /// opposite side by its full 3-D field. See `MagnetTemplate`.
148
+ public var templates: [MagnetTemplate] = []
149
+ /// Global point the template angles were measured from (the screen's
150
+ /// centre in the demo's trainer).
151
+ public var templateOrigin: CGPoint? = nil
152
+ /// How strongly the nearest trained spot wins over its neighbours when
153
+ /// matching (softmax on the squared feature distance).
154
+ public var templateSharpness: Double = 20
155
+ /// Weight of log field strength in the match (templates that carry a
156
+ /// `strength`). Far from the sensor, spots on different sides of the
157
+ /// phone give near-identical field directions but different strengths;
158
+ /// in a model of an iPhone 15 Pro with its sensor near the top this took
159
+ /// right-edge vs bottom-edge from coin-flip to ~65–100 % correct.
160
+ public var templateStrengthWeight: Double = 2
161
+ /// Weight of the vertical component when matching. Far from the sensor a
162
+ /// flat magnet's field is almost vertical and the position lives in the
163
+ /// small sideways part (1–3 µT next to 10–20 µT vertical on an iPhone 15
164
+ /// Pro with a fridge magnet at the bottom): down-weighting the vertical
165
+ /// part lets that sideways part decide, while how vertical the field is
166
+ /// still separates near from far.
167
+ public var templateVerticalWeight: Double = 0.35
168
+ /// Slow drift (temperature, parts of the phone magnetised by a magnet
169
+ /// that came too close) is re-zeroed with this time constant, s, while
170
+ /// the field is weak and steady. Measured on a real phone: ~4.5 µT left
171
+ /// behind after a magnet was taken away — as large as the signal.
172
+ public var driftSeconds: Double = 2.0
173
+ /// Below this change, µT, a steady field counts as drift (nil: the larger
174
+ /// of 5 µT and 8 % of `fullScale`).
175
+ public var driftThreshold: Double? = nil
176
+ /// Re-zero the offset an overload leaves: a magnet brought within ~2 cm
177
+ /// of the sensor (above `tooCloseStrength`) can magnetise parts of the
178
+ /// phone, shifting its no-magnet reading by 50–250 µT for minutes — a
179
+ /// phantom magnet that drowns a real one anywhere but next to the sensor.
180
+ /// It shows right after such an overload, when the magnet is yanked away:
181
+ /// the field falls by 85 %+ within a second and what remains settles
182
+ /// (within ~3.5 µT) a few seconds later. If that happens within 10 s of
183
+ /// the drop, it is zeroed (and the old zero restored if the field later
184
+ /// comes back to it).
185
+ /// A magnet simply set down near the phone is left alone.
186
+ public var steadyRezero = true
187
+ /// Above this field change, µT, `MagnetReading.tooClose` is set:
188
+ /// close enough to the sensor to start magnetising the phone.
189
+ public var tooCloseStrength = 2000.0
190
+ /// Run the presence network (`MagnetPresence`, bundled): readings
191
+ /// carry `presence` (is there really a magnet, from the last 15 s) and
192
+ /// `offsetEstimate`; the pull fades with presence, and a confident "no
193
+ /// magnet" with a steady field re-zeros the sensor (replacing the
194
+ /// overload rule of `steadyRezero`, which stays as the fallback).
195
+ public var presenceModel = true
196
+ /// Recognise air taps with the bundled network (`MagnetTap`):
197
+ /// a dip of the hovering magnet toward the glass and back fires
198
+ /// `.tap(at:)`, and the push-then-pull-away rule no longer does.
199
+ public var tapModel = true
200
+ /// Locate the magnet over the screen with the bundled network
201
+ /// (`MagnetLocator`); needs `sensorPosition`. Readings then carry
202
+ /// `screenPosition`, and views can aim at it.
203
+ public var locatorEnabled = false
204
+ /// The magnet's dipole constant, µT·cm³, if known (a 10×8×2 mm N52 ring
205
+ /// is ~6500, a 12×5 mm disc ~65000); nil lets the network assume any.
206
+ public var magnetStrength: Double? = nil
207
+ /// Which face of the magnet points at the screen: +1 north toward the
208
+ /// user, −1 south, nil unknown. Known, the locator is sure on every side
209
+ /// of the sensor; unknown, it assumes the magnet is below the sensor
210
+ /// when the reading could mean either side.
211
+ public var magnetFace: Int? = nil
212
+ /// How the locator works: `.network` (MagnetLocator, best over the
213
+ /// glass, copes without the face) or `.dipole` (MagnetDipole: the
214
+ /// physics solved directly; needs `magnetFace`, works over the glass and
215
+ /// beside the phone alike). `.dipole` falls back to the network while
216
+ /// the face is unknown.
217
+ public var locatorMode: MagnetLocatorMode = .network
218
+ /// `.dipole` only: the highest the magnet is taken to be above the
219
+ /// sensor, cm (see `MagnetDipole.locate(maxHeight:)`).
220
+ public var dipoleMaxHeight: Double? = 4
221
+ /// A magnet resting on the desk beside the phone sits at about the
222
+ /// sensor's height: its field is almost vertical and the sideways part
223
+ /// that carries the bearing is a few % — a slight tilt swings it by 50°
224
+ /// (measured on an iPhone 15 Pro). Its distance is still sure
225
+ /// (|B| ≈ K / r³) and so is its side (the sideways part's sign). With
226
+ /// this on, such a reading is placed beside that edge (~1.5 cm out, below
227
+ /// the sensor's height) at that distance, instead of trusting the bearing.
228
+ /// Needs `magnetFace` and `sensorPosition`; portrait only.
229
+ public var deskPrior = true
230
+ /// Gestures: a push or pull-away fires above this rate of change of
231
+ /// ln|B| per second (5 ≈ the distance shrinking or growing by ~1.8× a second).
232
+ public var gesturePushRate: Double = 5
233
+ public var gesturePullRate: Double = 5
234
+ /// A push then a pull-away within this many seconds is a tap.
235
+ public var gestureTapWindow: Double = 0.45
236
+ /// Gestures need at least this much pull (the magnet properly near).
237
+ public var gestureMinPull: Double = 0.2
238
+ /// With the locator: a sweep faster than this, pt/s, is a swipe.
239
+ public var gestureSwipeSpeed: Double = 1500
240
+ /// Views driven by the magnet (MetalFxKit's rings, for one) follow the
241
+ /// side of the phone it is on (the trained bearing from `templateOrigin`).
242
+ /// Within about this distance of a view, pt, they can turn to aim at the
243
+ /// magnet's estimated position instead.
244
+ /// Off (0) by default: where the sensor is far from the magnet the
245
+ /// position estimate is a blend of look-alike spots, and on a real
246
+ /// iPhone 15 Pro it landed beside a bottom-right button often enough to
247
+ /// pull a ring left or up. Turn it on only with a well-resolved training.
248
+ public var aimRadius: Double = 0
249
+
250
+ public init() {}
251
+ public static let `default` = MagnetConfig()
252
+ }
253
+
254
+ /// One magnetometer frame, relative to the calibrated baseline.
255
+ public struct MagnetReading: Equatable, Sendable {
256
+ /// A magnetometer (or a simulated field) is feeding the source.
257
+ public var available = false
258
+ /// A baseline has been taken.
259
+ public var calibrated = false
260
+ /// Calibrating right now — keep magnets away.
261
+ public var calibrating = false
262
+ /// The magnet is close enough to the sensor (see
263
+ /// `MagnetConfig.tooCloseStrength`) to leave an offset in the phone:
264
+ /// worth telling the user to keep it a little farther.
265
+ public var tooClose = false
266
+ /// The presence network's answer (0–1): is the field a real magnet, or
267
+ /// the phone's own leftover offset / drift. 1 when the network is off.
268
+ public var presence: Double = 1
269
+ /// The presence network's estimate of the part of `delta` that is not a
270
+ /// magnet, µT (reliable once the magnet is gone; rough while one is near).
271
+ public var offsetEstimate = SIMD3<Double>(0, 0, 0)
272
+ /// Raw field, µT, device axes (x right, y top, z out of the screen).
273
+ public var raw = SIMD3<Double>(0, 0, 0)
274
+ /// Low-passed field minus the baseline, µT, device axes.
275
+ public var delta = SIMD3<Double>(0, 0, 0)
276
+ /// |delta|, µT.
277
+ public var strength = 0.0
278
+ /// 0…1, log scale of `strength`.
279
+ public var pull = 0.0
280
+ /// Unit vector in screen axes from the sensor toward the magnet; zero when unknown.
281
+ public var toward = CGVector.zero
282
+ /// +1 attracts, −1 repels.
283
+ public var polarity = 1.0
284
+ /// Rough distance, cm (∞ without a field).
285
+ public var distance = Double.infinity
286
+ /// The phone has left its calibration pose; re-zeroing once it settles.
287
+ public var moving = false
288
+ /// The bearing comes from trained directions.
289
+ public var trained = false
290
+ /// Estimated magnet position, global points (trained with distances).
291
+ public var position: CGPoint?
292
+ /// With the locator: where the magnet is over the screen, global points
293
+ /// (smoothed), its 1-σ spread in points, and its height above the glass, cm.
294
+ public var screenPosition: CGPoint?
295
+ public var screenSpread: Double = 0
296
+ public var height: Double?
297
+ /// How fast the field is growing, ln|B| per second (+ approaching, − leaving).
298
+ public var approachRate: Double = 0
299
+ /// The same as a speed, cm/s (+ toward the phone), from the distance estimate.
300
+ public var approachSpeed: Double = 0
301
+ /// With the locator: how fast the located point moves over the screen, pt/s.
302
+ public var lateralVelocity: CGVector = .zero
303
+
304
+ public init() {}
305
+ }
306
+
307
+ /// The phone's magnetometer as a magnet detector. A nearby magnet bends the
308
+ /// field the sensor reads; against a baseline taken with no magnet near
309
+ /// (Earth plus the phone's own magnets, e.g. MagSafe), the change gives a
310
+ /// closeness (log scale), a rough bearing (±30°), and a polarity. No
311
+ /// permission is needed. Earth's 25–65 µT is small next to a fridge magnet a
312
+ /// few cm away (hundreds of µT); a dipole falls off as 1/r³, so the range is
313
+ /// a few cm for a fridge magnet and ~10 cm for a neodymium one.
314
+ ///
315
+ /// Bearing: a flat magnet on the table sits a little below the sensor, so
316
+ /// its in-plane field points along the line to the sensor and its vertical
317
+ /// field gives the face (north up reads Bz < 0 beside the phone, and
318
+ /// attracts). The direction toward the magnet is `sign(Bz) · B_xy` — it
319
+ /// survives flipping the magnet, which flips both.
320
+ public final class MagnetSource {
321
+ public static let shared = MagnetSource()
322
+
323
+ public var config = MagnetConfig.default
324
+ /// Feed a raw field by hand, µT in device axes (Simulator, tests, a
325
+ /// recorded trace). Takes precedence over the sensor.
326
+ public var simulatedField: SIMD3<Double>?
327
+ /// With a simulated field: the field with the simulated magnet lifted
328
+ /// away. Calibration averages this, so a magnet already in place still reads.
329
+ public var simulatedAmbient: SIMD3<Double>?
330
+ /// Interface orientation used to map device axes onto the screen.
331
+ public var orientation: UIInterfaceOrientation = .portrait
332
+ /// Pause the drift re-zero (a trainer holding a magnet still at a weak spot).
333
+ public var holdBaseline = false
334
+
335
+ public var isSensorAvailable: Bool { MagnetMotion.shared.manager.isMagnetometerAvailable }
336
+
337
+ private var subscribers = 0
338
+ private var baseline: SIMD3<Double>?
339
+ private var calStart: TimeInterval = -1
340
+ private var calSum = SIMD3<Double>(0, 0, 0)
341
+ private var calCount = 0
342
+ private var calAttitude: CMAttitude?
343
+ private var smoothed: SIMD3<Double>?
344
+ private var lastSign = 1.0
345
+ private var lastToward = CGVector.zero
346
+ private var lastPolarity = 1.0
347
+ private var lastFlipped = false
348
+ private var lastPosition: CGPoint?
349
+ private var located: CGPoint?
350
+ /// Recent raw locator targets, for the weak-field median.
351
+ private var targets: [(t: Double, p: CGPoint)] = []
352
+ private var locatedDistance: Double?
353
+ // Motion and gestures.
354
+ private var lnHistory: [(t: TimeInterval, ln: Double, r: Double)] = []
355
+ private var posHistory: [(t: TimeInterval, p: CGPoint)] = []
356
+ private var motionRate = 0.0
357
+ private var pullHistory: [(t: TimeInterval, pull: Double)] = []
358
+ private var pushed = false
359
+ private var lastPushT: TimeInterval = -10
360
+ private var lastPullT: TimeInterval = -10
361
+ private var lastSwipeT: TimeInterval = -10
362
+ private var gestureHandlers: [UUID: (MagnetGesture) -> Void] = [:]
363
+ private var lastT: TimeInterval = 0
364
+ /// The current overload episode (reading above `tooCloseStrength`): its
365
+ /// peak and when it was last over; and until when to watch for a leftover.
366
+ private var overloadPeak = 0.0
367
+ private var overloadAt: TimeInterval = -10
368
+ private var leftoverWatchUntil: TimeInterval = -10
369
+ private var stillSince: TimeInterval = -1
370
+ private var recent: [(t: TimeInterval, v: SIMD3<Double>)] = []
371
+ private var quietSince: TimeInterval = -1
372
+ /// Since when a strong reading has held perfectly still (a phantom).
373
+ private var phantomSince: TimeInterval = -1
374
+ /// Raw readings for the presence network (≈ its 15 s window), its last
375
+ /// answer, smoothed, and when it last ran.
376
+ private var rawHistory: [(t: TimeInterval, v: SIMD3<Double>)] = []
377
+ private var presence = 1.0
378
+ private var offsetEstimate = SIMD3<Double>(0, 0, 0)
379
+ private var presenceAt: TimeInterval = 0
380
+ /// When the phone was last overloaded, while it has not been zeroed since
381
+ /// (its parts may still be magnetised: a leftover of up to a few hundred
382
+ /// µT that lasts minutes), and the field's recent size — the presence
383
+ /// network is told about the overload only right after the field drops.
384
+ private var overloadedAt: TimeInterval?
385
+ private var recentSize: [(t: TimeInterval, s: Double)] = []
386
+ /// The tap network: its last answer, when it ran, whether it is still
387
+ /// above the threshold since firing, and recent located points (the tap
388
+ /// lands where the field was strongest — the bottom of the dip).
389
+ private var tapP = 0.0
390
+ private var tapAt: TimeInterval = 0
391
+ private var tapHigh = false
392
+ private var lastTapT: TimeInterval = -10
393
+ private var tapPoints: [(t: TimeInterval, p: CGPoint, s: Double)] = []
394
+ /// The zero from before the last phantom re-zero, and when: if the field
395
+ /// comes back to it, what was zeroed was a magnet set down (now lifted
396
+ /// away), not an offset — restore it at once.
397
+ private var zeroBeforePhantom: SIMD3<Double>?
398
+ private var zeroBeforePhantomAt: TimeInterval = 0
399
+ /// Recent no-magnet readings (from calibrations), kept across launches:
400
+ /// when the field settles back onto one, that is the zero — e.g. a
401
+ /// magnet that was lying next to the phone during a calibration and has
402
+ /// now been taken away.
403
+ private var knownZeros: [(v: SIMD3<Double>, t: Double)] = MagnetSource.loadZeros()
404
+ private static let zerosKey = "MagnetKit.zeros"
405
+
406
+ private static func loadZeros() -> [(v: SIMD3<Double>, t: Double)] {
407
+ guard let a = UserDefaults.standard.array(forKey: zerosKey) as? [[Double]] else { return [] }
408
+ let now = Date().timeIntervalSince1970
409
+ return a.compactMap { e in e.count == 4 && now - e[3] < 2 * 86400 ? (SIMD3(e[0], e[1], e[2]), e[3]) : nil }
410
+ }
411
+
412
+ private func rememberZero(_ z: SIMD3<Double>) {
413
+ let now = Date().timeIntervalSince1970
414
+ knownZeros.removeAll { simd_length($0.v - z) < 2 || now - $0.t > 2 * 86400 }
415
+ knownZeros.append((z, now))
416
+ if knownZeros.count > 6 { knownZeros.removeFirst(knownZeros.count - 6) }
417
+ UserDefaults.standard.set(knownZeros.map { [$0.v.x, $0.v.y, $0.v.z, $0.t] }, forKey: Self.zerosKey)
418
+ }
419
+ private var cachedNow: TimeInterval = -1
420
+ private var cached = MagnetReading()
421
+
422
+ private static let settleDelay = 0.25
423
+ private static let calDuration = 0.35
424
+
425
+ private init() {}
426
+
427
+ public func attach() {
428
+ subscribers += 1
429
+ guard subscribers == 1 else { return }
430
+ MagnetMotion.shared.retainMagnetometer()
431
+ MagnetMotion.shared.retainDeviceMotion()
432
+ if baseline == nil { calibrate() }
433
+ }
434
+
435
+ public func detach() {
436
+ subscribers = max(0, subscribers - 1)
437
+ guard subscribers == 0 else { return }
438
+ MagnetMotion.shared.releaseMagnetometer()
439
+ MagnetMotion.shared.releaseDeviceMotion()
440
+ smoothed = nil
441
+ }
442
+
443
+ /// Be told about quick movements (push, pull away, tap, swipe). Handlers
444
+ /// run on the main queue, outside view updates. Keep the id to remove it.
445
+ @discardableResult
446
+ public func addGestureHandler(_ handler: @escaping (MagnetGesture) -> Void) -> UUID {
447
+ let id = UUID()
448
+ gestureHandlers[id] = handler
449
+ return id
450
+ }
451
+
452
+ public func removeGestureHandler(_ id: UUID) { gestureHandlers.removeValue(forKey: id) }
453
+
454
+ private func fire(_ g: MagnetGesture) {
455
+ let handlers = Array(gestureHandlers.values)
456
+ DispatchQueue.main.async { handlers.forEach { $0(g) } }
457
+ }
458
+
459
+ /// Take a new baseline over the next ~0.6 s. Keep magnets away.
460
+ public func calibrate() {
461
+ zeroBeforePhantom = nil
462
+ calStart = 0 // starts on the next frame
463
+ calSum = .zero
464
+ calCount = 0
465
+ }
466
+
467
+ /// One locator answer: the magnet relative to the sensor (cm, device
468
+ /// axes) and a 1-σ spread, cm.
469
+ private func locate(_ d: SIMD3<Double>, _ cfg: MagnetConfig) -> (position: SIMD3<Double>, spread: Double)? {
470
+ let engine = engineLocate(d, cfg)
471
+ guard cfg.deskPrior, let desk = deskPlacement(d, cfg) else { return engine }
472
+ guard let e = engine else { return (desk.position, desk.spread) }
473
+ let w = desk.weight
474
+ return (e.position * (1 - w) + desk.position * w, e.spread * (1 - w) + desk.spread * w)
475
+ }
476
+
477
+ /// A magnet resting at desk height beside the phone (see `deskPrior`):
478
+ /// placed beside the edge its field's sideways part points to, at the
479
+ /// distance its strength gives. Weight 1 when the field is almost all
480
+ /// vertical, fading out as the sideways share grows to 30 %.
481
+ private func deskPlacement(_ d: SIMD3<Double>, _ cfg: MagnetConfig) -> (position: SIMD3<Double>, spread: Double, weight: Double)? {
482
+ guard let face = cfg.magnetFace, let sensor = cfg.sensorPosition,
483
+ orientation == .portrait || orientation == .unknown else { return nil }
484
+ let nb = simd_length(d)
485
+ guard nb > 1e-6 else { return nil }
486
+ let f: Double = face < 0 ? -1 : 1
487
+ // Beside the phone at its height the vertical part points against the face.
488
+ guard f * d.z < 0 else { return nil }
489
+ let inPlane = (d.x * d.x + d.y * d.y).squareRoot() / nb
490
+ let w = min(1, max(0, (0.3 - inPlane) / 0.15))
491
+ guard w > 0 else { return nil }
492
+ let r = pow((cfg.magnetStrength ?? 30000) / nb, 1.0 / 3.0)
493
+ // The sideways part points toward the magnet (north-up face, magnet a
494
+ // little above the sensor); its side is reliable, its angle is not.
495
+ var tx = f * d.x, ty = f * d.y
496
+ let tl = (tx * tx + ty * ty).squareRoot()
497
+ guard tl > 1e-6 else { return nil }
498
+ tx /= tl; ty /= tl
499
+ let b = UIScreen.main.bounds, ppcm = cfg.pointsPerCm
500
+ // Screen edges relative to the sensor, cm, device axes (y toward the top).
501
+ let left = -Double(sensor.x - b.minX) / ppcm, right = Double(b.maxX - sensor.x) / ppcm
502
+ let top = Double(sensor.y - b.minY) / ppcm
503
+ let gap = 1.5
504
+ var x: Double, y: Double
505
+ if ty > 0.85 {
506
+ // Above the top edge.
507
+ y = top + gap
508
+ if r <= y { y = r; x = 0 } else { x = (tx >= 0 ? 1 : -1) * (r * r - y * y).squareRoot() }
509
+ } else if ty < -0.85 {
510
+ // Below the bottom edge, far from the sensor: the bearing will do.
511
+ x = r * tx; y = r * ty
512
+ } else {
513
+ // Beside a long edge, below the sensor's height.
514
+ let side: Double = tx >= 0 ? 1 : -1
515
+ x = side > 0 ? right + gap : left - gap
516
+ if r <= abs(x) { x = side * r; y = 0 } else { y = -(r * r - x * x).squareRoot() }
517
+ }
518
+ return (SIMD3(x, y, 0.5), 0.3 + 0.15 * r, w)
519
+ }
520
+
521
+ private func engineLocate(_ d: SIMD3<Double>, _ cfg: MagnetConfig) -> (position: SIMD3<Double>, spread: Double)? {
522
+ if cfg.locatorMode == .dipole, let face = cfg.magnetFace,
523
+ let r = MagnetDipole.locate(field: d, face: face, strength: cfg.magnetStrength ?? 30000, maxHeight: cfg.dipoleMaxHeight) {
524
+ // The bearing rests on the field's in-plane share; a few degrees of
525
+ // tilt move it by ~0.12 of the field, so a small share is fragile.
526
+ let lateral = (r.position.x * r.position.x + r.position.y * r.position.y).squareRoot()
527
+ let bearingSD = min(1.2, 0.12 / max(0.05, r.inPlane))
528
+ let spread = 0.3 + 0.12 * simd_length(r.position) + lateral * bearingSD
529
+ return (r.position, spread)
530
+ }
531
+ guard let loc = MagnetModel.bundled?.locator else { return nil }
532
+ return loc.locate(field: d, strength: cfg.magnetStrength, face: cfg.magnetFace)
533
+ }
534
+
535
+ /// The current reading. Evaluated once per frame; every view shares it.
536
+ public func reading(now: TimeInterval) -> MagnetReading {
537
+ if now == cachedNow { return cached }
538
+ cachedNow = now
539
+ cached = compute(now: now)
540
+ return cached
541
+ }
542
+
543
+ /// Bearing from trained directions: a blend of the trained spots whose
544
+ /// field looks most like the reading (softmax on the cosine, sharpness
545
+ /// `templateSharpness`), averaged on the circle. Fields are compared with
546
+ /// the vertical part down-weighted (`templateVerticalWeight`) so the small
547
+ /// sideways part, which carries the position far from the sensor, decides.
548
+ /// The reversed field is tried too (a flipped magnet); the better match
549
+ /// wins, with hysteresis.
550
+ private func match(_ d: SIMD3<Double>, _ ts: [MagnetTemplate]) -> (angle: Double, flipped: Bool, distance: Double?)? {
551
+ guard ts.count >= 3 else { return nil }
552
+ let lam = max(0.01, config.templateVerticalWeight)
553
+ func feature(_ x: SIMD3<Double>) -> SIMD3<Double> {
554
+ let w = SIMD3(x.x, x.y, x.z * lam)
555
+ let l = simd_length(w)
556
+ return l > 0 ? w / l : w
557
+ }
558
+ let v = feature(d)
559
+ let fields = ts.map { feature($0.field) }
560
+ let kappa = max(1, config.templateSharpness)
561
+ let mu = max(0, config.templateStrengthWeight)
562
+ let useStrength = mu > 0 && ts.allSatisfy { $0.strength != nil }
563
+ let logB = log(max(1e-3, simd_length(d)))
564
+ let logs = ts.map { log(max(1e-3, $0.strength ?? 1)) }
565
+ // Score = minus the squared distance in (weighted direction, μ·ln|B|).
566
+ func blend(_ x: SIMD3<Double>) -> (score: Double, angle: Double, distance: Double?) {
567
+ var dists: [Double] = []
568
+ dists.reserveCapacity(ts.count)
569
+ for i in ts.indices {
570
+ var q = simd_length_squared(x - fields[i])
571
+ if useStrength { let e = mu * (logB - logs[i]); q += e * e }
572
+ dists.append(q)
573
+ }
574
+ let best = dists.min() ?? 0
575
+ var cx = 0.0, cy = 0.0, wsum = 0.0, dsum = 0.0
576
+ var hasDistance = true
577
+ for (t, q) in zip(ts, dists) {
578
+ let w = exp(-kappa * (q - best))
579
+ cx += w * cos(t.angle); cy += w * sin(t.angle); wsum += w
580
+ if let dist = t.distance { dsum += w * dist } else { hasDistance = false }
581
+ }
582
+ return (-best, atan2(cy, cx), hasDistance && wsum > 0 ? dsum / wsum : nil)
583
+ }
584
+ let p = blend(v), q = blend(-v)
585
+ let flipped = lastFlipped ? !(p.score > q.score + 0.24) : (q.score > p.score + 0.24)
586
+ return flipped ? (q.angle, true, q.distance) : (p.angle, false, p.distance)
587
+ }
588
+
589
+ private func rawField() -> SIMD3<Double>? {
590
+ if let f = simulatedField { return f }
591
+ guard let m = MagnetMotion.shared.manager.magnetometerData?.magneticField else { return nil }
592
+ return SIMD3(m.x, m.y, m.z)
593
+ }
594
+
595
+ private func compute(now: TimeInterval) -> MagnetReading {
596
+ var out = MagnetReading()
597
+ guard let raw = rawField() else { return out }
598
+ out.available = true
599
+ out.raw = raw
600
+ let cfg = config
601
+ let dt = lastT > 0 ? min(0.1, max(0.001, now - lastT)) : 1.0 / 60.0
602
+ lastT = now
603
+
604
+ var s = smoothed ?? raw
605
+ s += (raw - s) * (1 - exp(-dt / max(0.005, cfg.smoothing)))
606
+ smoothed = s
607
+
608
+ // Calibration: let the reading settle, then average.
609
+ if calStart >= 0 {
610
+ if calStart == 0 { calStart = now }
611
+ out.calibrating = true
612
+ out.calibrated = baseline != nil
613
+ let age = now - calStart
614
+ if age >= Self.settleDelay {
615
+ calSum += simulatedField != nil ? (simulatedAmbient ?? raw) : raw
616
+ calCount += 1
617
+ }
618
+ if age >= Self.settleDelay + Self.calDuration, calCount > 0 {
619
+ baseline = calSum / Double(calCount)
620
+ overloadedAt = nil
621
+ if simulatedField == nil, let b = baseline { rememberZero(b) }
622
+ smoothed = baseline
623
+ calAttitude = MagnetMotion.shared.manager.deviceMotion?.attitude.copy() as? CMAttitude
624
+ calStart = -1
625
+ stillSince = -1
626
+ }
627
+ return out
628
+ }
629
+ guard let base = baseline else { calibrate(); return out }
630
+ out.calibrated = true
631
+
632
+ // New pose (picked up, turned on the table): Earth's field now reads
633
+ // differently in device axes, so re-zero once the phone settles.
634
+ if simulatedField == nil, cfg.autoRecalibrate, let ref = calAttitude, let dm = MagnetMotion.shared.manager.deviceMotion {
635
+ let att = dm.attitude.copy() as! CMAttitude
636
+ att.multiply(byInverseOf: ref)
637
+ let q = att.quaternion
638
+ let angle = 2 * acos(min(1, abs(q.w))) * 180 / .pi
639
+ if angle > cfg.poseTolerance {
640
+ out.moving = true
641
+ let r = dm.rotationRate
642
+ let still = sqrt(r.x * r.x + r.y * r.y + r.z * r.z) < 0.12
643
+ if still {
644
+ if stillSince < 0 { stillSince = now }
645
+ if now - stillSince > cfg.settleSeconds { calibrate() }
646
+ } else {
647
+ stillSince = -1
648
+ }
649
+ } else {
650
+ stillSince = -1
651
+ }
652
+ }
653
+
654
+ // Drift: while the change is weak and steady, ease the baseline onto it.
655
+ recent.append((now, s))
656
+ recent.removeAll { now - $0.t > 0.8 }
657
+ var zero = base
658
+ if let prev = zeroBeforePhantom {
659
+ if now - zeroBeforePhantomAt > 120 {
660
+ zeroBeforePhantom = nil
661
+ } else if simd_length(s - prev) < max(2, 0.05 * simd_length(prev - zero)) {
662
+ zero = prev; baseline = prev; zeroBeforePhantom = nil
663
+ }
664
+ }
665
+ let away = simd_length(s - zero)
666
+ let weak = away < (cfg.driftThreshold ?? max(5, 0.08 * cfg.fullScale))
667
+ if away > cfg.tooCloseStrength {
668
+ overloadPeak = max(overloadPeak, away); overloadAt = now
669
+ overloadedAt = now
670
+ } else if overloadPeak > 0 {
671
+ if away < 0.15 * overloadPeak, now - overloadAt < 20 {
672
+ // Taken away after an overload (quickly or not): what settles
673
+ // next is the leftover (iPhone 15 Pro: still to ~2–3 µT in 1–8 s).
674
+ leftoverWatchUntil = now + 10
675
+ overloadPeak = 0
676
+ } else if now - overloadAt > 20 {
677
+ overloadPeak = 0
678
+ }
679
+ }
680
+ var devMax = Double.infinity
681
+ if recent.count >= 4 {
682
+ var mean = SIMD3<Double>(0, 0, 0)
683
+ for e in recent { mean += e.v }
684
+ mean /= Double(recent.count)
685
+ devMax = recent.map { simd_length($0.v - mean) }.max() ?? .infinity
686
+ }
687
+ // Presence: is this a magnet, or the sensor's own offset? (~30 Hz)
688
+ let net = cfg.presenceModel ? MagnetModel.bundled?.presence : nil
689
+ let tapNet = cfg.tapModel ? MagnetModel.bundled?.tap : nil
690
+ if net != nil || tapNet != nil {
691
+ rawHistory.append((now, raw))
692
+ let keep = max(net.map { $0.window + 1 } ?? 0, tapNet.map { Double($0.window) / $0.hz + 0.5 } ?? 0)
693
+ rawHistory.removeAll { now - $0.t > keep }
694
+ }
695
+ if let tapNet, now - tapAt >= 1.0 / 50 {
696
+ // Air tap: the last ~1.3 s at the network's rate. (~50 Hz)
697
+ tapAt = now
698
+ let n = tapNet.window
699
+ var h = [SIMD3<Double>](); h.reserveCapacity(n)
700
+ var j = 0
701
+ for k in 0..<n {
702
+ let tk = now - Double(n - 1 - k) / tapNet.hz
703
+ while j + 1 < rawHistory.count, rawHistory[j + 1].t <= tk { j += 1 }
704
+ h.append(rawHistory[j].v - zero)
705
+ }
706
+ tapP = tapNet.evaluate(history: h)
707
+ } else if tapNet == nil {
708
+ tapP = 0
709
+ }
710
+ if let net {
711
+ if now - presenceAt >= 1.0 / 30 {
712
+ presenceAt = now
713
+ let n = Int(net.window * net.hz)
714
+ var h = [SIMD3<Double>](); h.reserveCapacity(n)
715
+ var j = 0
716
+ for k in 0..<n {
717
+ let tk = now - Double(n - 1 - k) / net.hz
718
+ while j + 1 < rawHistory.count, rawHistory[j + 1].t <= tk { j += 1 }
719
+ h.append(rawHistory[j].v - zero)
720
+ }
721
+ // Told about an unzeroed overload only while the field has
722
+ // just dropped (the magnet moving away): steady, a leftover and
723
+ // a magnet lying still read alike, and a resting magnet must
724
+ // not be taken for one.
725
+ let size = simd_length(raw - zero)
726
+ recentSize.append((now, size))
727
+ recentSize.removeAll { now - $0.t > 10 }
728
+ let peak = recentSize.map(\.s).max() ?? size
729
+ let dropped = peak - size > max(15, 0.12 * peak)
730
+ let r = net.evaluate(history: h, overloadedFor: dropped ? overloadedAt.map { now - $0 } : nil)
731
+ presence += (r.presence - presence) * (1 - exp(-(1.0 / 30) / 0.1))
732
+ offsetEstimate = r.offset
733
+ }
734
+ } else {
735
+ presence = 1
736
+ }
737
+
738
+ // Settled back onto a remembered no-magnet reading: that is the zero.
739
+ var snapped = false
740
+ if !holdBaseline, !weak, devMax < 1.5,
741
+ let k = knownZeros.min(by: { simd_length($0.v - s) < simd_length($1.v - s) }),
742
+ simd_length(k.v - s) < 2.5 {
743
+ zero = k.v; baseline = k.v
744
+ zeroBeforePhantom = nil
745
+ overloadedAt = nil
746
+ snapped = true
747
+ }
748
+ if snapped {
749
+ quietSince = -1
750
+ phantomSince = -1
751
+ } else if !holdBaseline, weak, devMax < 0.8 {
752
+ // Slow drift (temperature, small magnetisation): ease it in.
753
+ phantomSince = -1
754
+ if quietSince < 0 { quietSince = now }
755
+ if now - quietSince > 0.6 {
756
+ zero += (s - zero) * (1 - exp(-dt / max(0.2, cfg.driftSeconds)))
757
+ baseline = zero
758
+ }
759
+ } else if !holdBaseline, net != nil ? presence < 0.25 : (cfg.steadyRezero && now < leftoverWatchUntil),
760
+ !weak, devMax < max(3.5, 0.03 * away) {
761
+ // Not a magnet (the presence network, or right after an overload
762
+ // without it) and holding still: the sensor's own offset.
763
+ quietSince = -1
764
+ if phantomSince < 0 { phantomSince = now }
765
+ if now - phantomSince > 0.6 {
766
+ if zeroBeforePhantom == nil || now - zeroBeforePhantomAt > 2 { zeroBeforePhantom = zero; zeroBeforePhantomAt = now }
767
+ zero += (s - zero) * (1 - exp(-dt / 0.25))
768
+ baseline = zero
769
+ // Zeroed onto the leftover: the overload is dealt with.
770
+ if simd_length(s - zero) < 2 { overloadedAt = nil }
771
+ }
772
+ } else {
773
+ quietSince = -1
774
+ phantomSince = -1
775
+ }
776
+
777
+ let d = s - zero
778
+ out.delta = d
779
+ let strength = simd_length(d)
780
+ out.strength = strength
781
+ out.tooClose = strength > cfg.tooCloseStrength
782
+ let b = max(0, strength - cfg.deadzone)
783
+ let k = max(0.5, cfg.scale)
784
+ out.pull = min(1, log10(1 + b / k) / max(0.01, log10(1 + max(1, cfg.fullScale - cfg.deadzone) / k)))
785
+ // No magnet, just the sensor's own offset: nothing to pull.
786
+ out.presence = presence
787
+ out.offsetEstimate = offsetEstimate
788
+ if net != nil { out.pull *= min(1, max(0, (presence - 0.15) / 0.35)) }
789
+ if strength > 1 { out.distance = cfg.distanceK * pow(strength, -1.0 / 3.0) }
790
+
791
+ // Locator: where over the screen, smoothed more when the network is unsure.
792
+ if cfg.locatorEnabled, strength > 2.5, let sensor = cfg.sensorPosition,
793
+ let r = locate(d, cfg) {
794
+ let v = magnetScreenVector(r.position.x, r.position.y, orientation)
795
+ var target = CGPoint(x: Double(sensor.x) + Double(v.dx) * cfg.pointsPerCm, y: Double(sensor.y) + Double(v.dy) * cfg.pointsPerCm)
796
+ // Far from the sensor the field is weak and a hand's wobble can
797
+ // flip the bearing for a moment: a median over a short window
798
+ // (longer when weaker) drops those blips without lagging a real move.
799
+ let window = strength < 15 ? 0.2 : strength < 40 ? 0.12 : 0.04
800
+ targets.append((now, target))
801
+ targets.removeAll { now - $0.t > window }
802
+ if targets.count >= 3 {
803
+ let xs = targets.map(\.p.x).sorted(), ys = targets.map(\.p.y).sorted()
804
+ target = CGPoint(x: xs[xs.count / 2], y: ys[ys.count / 2])
805
+ }
806
+ let spreadCm = r.spread
807
+ let tau = 0.04 + 0.22 * min(1, spreadCm / 1.5)
808
+ if let cur = located {
809
+ let k = 1 - exp(-dt / tau)
810
+ located = CGPoint(x: cur.x + (target.x - cur.x) * k, y: cur.y + (target.y - cur.y) * k)
811
+ } else {
812
+ located = target
813
+ }
814
+ out.screenPosition = located
815
+ out.screenSpread = spreadCm * cfg.pointsPerCm
816
+ out.height = r.position.z - 0.4
817
+ locatedDistance = simd_length(r.position)
818
+ } else if strength <= 2.5 {
819
+ located = nil
820
+ targets.removeAll()
821
+ locatedDistance = nil
822
+ }
823
+
824
+ // Motion: the slope of ln|B| over the last ~0.12 s. The field goes as
825
+ // 1/r³, so d ln r/dt = −⅓ d ln|B|/dt: the approach speed in cm/s.
826
+ let rEst = locatedDistance ?? out.distance
827
+ if strength > max(3, cfg.deadzone * 1.5) {
828
+ lnHistory.append((now, log(strength), rEst))
829
+ }
830
+ lnHistory.removeAll { now - $0.t > 0.12 }
831
+ var rate = 0.0
832
+ if lnHistory.count >= 3 {
833
+ let n = Double(lnHistory.count)
834
+ let mt = lnHistory.reduce(0) { $0 + $1.t } / n, ml = lnHistory.reduce(0) { $0 + $1.ln } / n
835
+ var num = 0.0, den = 0.0
836
+ for e in lnHistory { num += (e.t - mt) * (e.ln - ml); den += (e.t - mt) * (e.t - mt) }
837
+ if den > 1e-9 { rate = num / den }
838
+ }
839
+ motionRate += (rate - motionRate) * (1 - exp(-dt / 0.03))
840
+ out.approachRate = motionRate
841
+ let rNow = lnHistory.last?.r ?? rEst
842
+ out.approachSpeed = rNow.isFinite ? motionRate * rNow / 3 : 0
843
+
844
+ // Lateral motion of the located point.
845
+ if let p = out.screenPosition {
846
+ posHistory.append((now, p))
847
+ posHistory.removeAll { now - $0.t > 0.1 }
848
+ if let first = posHistory.first, now - first.t > 0.03 {
849
+ let span = now - first.t
850
+ out.lateralVelocity = CGVector(dx: (p.x - first.p.x) / span, dy: (p.y - first.p.y) / span)
851
+ }
852
+ } else {
853
+ posHistory.removeAll()
854
+ }
855
+
856
+ // Gestures.
857
+ pullHistory.append((now, out.pull))
858
+ pullHistory.removeAll { now - $0.t > 0.5 }
859
+ let recentPull = pullHistory.map(\.pull).max() ?? 0
860
+ if motionRate > cfg.gesturePushRate, out.pull >= cfg.gestureMinPull, !pushed, now - lastPushT > 0.3 {
861
+ pushed = true
862
+ lastPushT = now
863
+ fire(.push(speed: max(0, out.approachSpeed)))
864
+ } else if motionRate < cfg.gesturePushRate * 0.3 {
865
+ pushed = false
866
+ }
867
+ if motionRate < -cfg.gesturePullRate, recentPull >= cfg.gestureMinPull, now - lastPullT > 0.3 {
868
+ lastPullT = now
869
+ fire(.pullAway(speed: max(0, -out.approachSpeed)))
870
+ if tapNet == nil, now - lastPushT < cfg.gestureTapWindow { fire(.tap(at: out.screenPosition)) }
871
+ }
872
+ if tapNet != nil {
873
+ if let p = out.screenPosition { tapPoints.append((now, p, strength)) }
874
+ tapPoints.removeAll { now - $0.t > 1.0 }
875
+ // No presence gate: a tap is a quick rise and fall, which a
876
+ // leftover offset never makes — and right after an overload the
877
+ // presence model may doubt a magnet that is plainly there.
878
+ if tapP > 0.5, !tapHigh, now - lastTapT > 0.35, strength > cfg.deadzone {
879
+ tapHigh = true
880
+ lastTapT = now
881
+ fire(.tap(at: tapPoints.max(by: { $0.s < $1.s })?.p))
882
+ } else if tapP < 0.3 {
883
+ tapHigh = false
884
+ }
885
+ }
886
+ let lv = out.lateralVelocity
887
+ let lspeed = hypot(lv.dx, lv.dy)
888
+ if lspeed > cfg.gestureSwipeSpeed, now - lastSwipeT > 0.4 {
889
+ lastSwipeT = now
890
+ fire(.swipe(direction: CGVector(dx: lv.dx / lspeed, dy: lv.dy / lspeed), speed: lspeed))
891
+ }
892
+
893
+ // Face: the vertical component's sign, with hysteresis near zero.
894
+ if abs(d.z) > 0.15 * strength + 0.5 { lastSign = d.z >= 0 ? 1 : -1 }
895
+ let inPlane = hypot(d.x, d.y)
896
+ var flippedFace = lastFlipped
897
+ if out.pull > 0, cfg.templates.count >= 3 {
898
+ out.trained = true
899
+ // Trained: read the bearing off the templates, and tell a flipped
900
+ // magnet from one on the far side by the full 3-D match.
901
+ if let m = match(d, cfg.templates) {
902
+ lastToward = CGVector(dx: cos(m.angle), dy: sin(m.angle))
903
+ flippedFace = m.flipped
904
+ lastFlipped = m.flipped
905
+ if let o = cfg.templateOrigin, let dist = m.distance {
906
+ lastPosition = CGPoint(x: Double(o.x) + cos(m.angle) * dist, y: Double(o.y) + sin(m.angle) * dist)
907
+ } else {
908
+ lastPosition = nil
909
+ }
910
+ }
911
+ out.position = lastPosition
912
+ } else if out.pull > 0, inPlane > 0.06 * strength + 1.5 {
913
+ // Untrained: a magnet lying flat (vertical field) gives its side by
914
+ // sign(Bz); one facing the phone's edge (in-plane field) does not,
915
+ // so keep the side it had instead of flipping on noise.
916
+ let flat = abs(d.z) > 0.6 * strength
917
+ var side: Double
918
+ if flat {
919
+ side = lastSign
920
+ } else {
921
+ let raw = magnetScreenVector(d.x / inPlane, d.y / inPlane, orientation)
922
+ side = lastToward == .zero ? -1 : (raw.dx * lastToward.dx + raw.dy * lastToward.dy >= 0 ? 1 : -1)
923
+ }
924
+ lastToward = magnetScreenVector(side * d.x / inPlane, side * d.y / inPlane, orientation)
925
+ if flat { lastPolarity = -lastSign }
926
+ } else if out.pull == 0 {
927
+ lastToward = .zero
928
+ lastPosition = nil
929
+ }
930
+ out.toward = lastToward
931
+
932
+ let inv = cfg.invertPolarity ? -1.0 : 1.0
933
+ switch cfg.polarity {
934
+ case .attract: out.polarity = 1
935
+ case .repel: out.polarity = -1
936
+ case .auto:
937
+ // Trained: the face you trained with attracts, the other repels.
938
+ // Untrained: a flat magnet north face up (Bz < 0) attracts.
939
+ out.polarity = (out.trained ? (flippedFace ? -1 : 1) : lastPolarity) * inv
940
+ }
941
+ return out
942
+ }
943
+ }
944
+
945
+ // MARK: - Simulated magnet
946
+
947
+ /// A dipole on the table, for the Simulator and for tuning without a magnet:
948
+ /// the field it puts on a sensor, in the magnetometer's device axes.
949
+ public enum MagnetSimulator {
950
+ /// - Parameters:
951
+ /// - magnet / sensor: global points on the screen plane.
952
+ /// - northUp: which face points up.
953
+ /// - moment: µT·cm³ (4800 ≈ 600 µT 2 cm to the side — a small neodymium disc).
954
+ /// - depth: how far below the sensor the magnet's centre sits, cm.
955
+ public static func field(magnet: CGPoint, sensor: CGPoint, northUp: Bool, moment: Double = 4800,
956
+ depth: Double = 0.3, pointsPerCm: Double = 60) -> SIMD3<Double> {
957
+ // Device axes: x right, y toward the top edge, z out of the screen.
958
+ let r = SIMD3(Double(sensor.x - magnet.x) / pointsPerCm, -Double(sensor.y - magnet.y) / pointsPerCm, depth)
959
+ let len = max(0.4, simd_length(r))
960
+ let rh = r / simd_length(r)
961
+ let m = SIMD3<Double>(0, 0, northUp ? 1 : -1)
962
+ return moment * (3 * simd_dot(m, rh) * rh - m) / (len * len * len)
963
+ }
964
+
965
+ /// Earth's field plus a little hard-iron bias, as an unmoving phone reads it.
966
+ public static let ambient = SIMD3<Double>(14, -21, -38)
967
+ }