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,598 @@
1
+ package dev.libraries.magnetkit
2
+
3
+ import android.content.Context
4
+ import android.hardware.Sensor
5
+ import android.hardware.SensorEvent
6
+ import android.hardware.SensorEventListener
7
+ import android.hardware.SensorManager
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import org.json.JSONArray
11
+ import kotlin.math.abs
12
+ import kotlin.math.acos
13
+ import kotlin.math.exp
14
+ import kotlin.math.hypot
15
+ import kotlin.math.ln
16
+ import kotlin.math.log10
17
+ import kotlin.math.max
18
+ import kotlin.math.min
19
+ import kotlin.math.pow
20
+ import kotlin.math.sqrt
21
+
22
+ /** How `MagnetConfig.locatorEnabled` finds the magnet. */
23
+ enum class MagnetLocatorMode { NETWORK, DIPOLE }
24
+
25
+ /** A quick magnet movement. */
26
+ sealed class MagnetGesture {
27
+ /** Pushed quickly toward the phone; approach speed, cm/s. */
28
+ data class Push(val speed: Double) : MagnetGesture()
29
+ /** Pulled quickly away; retreat speed, cm/s. */
30
+ data class PullAway(val speed: Double) : MagnetGesture()
31
+ /** An air tap: hovering over the glass, dipped toward the screen and back. `at`: the located point, dp, with the locator on. */
32
+ data class Tap(val at: Pt?) : MagnetGesture()
33
+ /** Swept across the screen (needs the locator); unit direction and speed, dp/s. */
34
+ data class Swipe(val direction: Pt, val speed: Double) : MagnetGesture()
35
+ }
36
+
37
+ /** How a magnetometer reading becomes a magnet reading. Lengths on screen are dp. */
38
+ data class MagnetConfig(
39
+ /** Field change, µT, that reads as full pull. */
40
+ var fullScale: Double = 60.0,
41
+ /** Knee of the log scale, µT. */
42
+ var scale: Double = 10.0,
43
+ /** Change below this, µT, is noise. */
44
+ var deadzone: Double = 3.0,
45
+ /** Low-pass time constant on the reading, s. */
46
+ var smoothing: Double = 0.06,
47
+ /** Re-zero once the phone has settled in a new pose. */
48
+ var autoRecalibrate: Boolean = true,
49
+ var poseTolerance: Double = 10.0,
50
+ var settleSeconds: Double = 0.8,
51
+ /** Where the magnetometer sits, dp from the window's top left. */
52
+ var sensorPosition: Pt? = null,
53
+ /** The window's size, dp (for placing a magnet resting beside an edge). */
54
+ var screenSize: Pt? = null,
55
+ var distanceK: Double = 16.0,
56
+ /** dp per centimetre (160 dp per inch). */
57
+ var pointsPerCm: Double = 160.0 / 2.54,
58
+ var driftSeconds: Double = 2.0,
59
+ var driftThreshold: Double? = null,
60
+ var steadyRezero: Boolean = true,
61
+ /** Above this, µT, the magnet is close enough to the sensor to magnetise the phone. */
62
+ var tooCloseStrength: Double = 2000.0,
63
+ var presenceModel: Boolean = true,
64
+ var tapModel: Boolean = true,
65
+ var locatorEnabled: Boolean = false,
66
+ /** The magnet's dipole constant, µT·cm³, if known. */
67
+ var magnetStrength: Double? = null,
68
+ /** +1 north toward the user, −1 south, null unknown. */
69
+ var magnetFace: Int? = null,
70
+ var locatorMode: MagnetLocatorMode = MagnetLocatorMode.NETWORK,
71
+ var dipoleMaxHeight: Double? = 4.0,
72
+ var deskPrior: Boolean = true,
73
+ var gesturePushRate: Double = 5.0,
74
+ var gesturePullRate: Double = 5.0,
75
+ var gestureTapWindow: Double = 0.45,
76
+ var gestureMinPull: Double = 0.2,
77
+ var gestureSwipeSpeed: Double = 1500.0,
78
+ )
79
+
80
+ /** One frame, relative to the calibrated zero. */
81
+ data class MagnetReading(
82
+ var available: Boolean = false,
83
+ var calibrated: Boolean = false,
84
+ var calibrating: Boolean = false,
85
+ var tooClose: Boolean = false,
86
+ var presence: Double = 1.0,
87
+ var offsetEstimate: Vec3 = Vec3.ZERO,
88
+ /** Raw field, µT, device axes. */
89
+ var raw: Vec3 = Vec3.ZERO,
90
+ /** Low-passed field minus the zero, µT. */
91
+ var delta: Vec3 = Vec3.ZERO,
92
+ var strength: Double = 0.0,
93
+ /** 0…1, log scale of `strength`. */
94
+ var pull: Double = 0.0,
95
+ /** Unit vector on screen from the sensor toward the magnet; zero when unknown. */
96
+ var toward: Pt = Pt.ZERO,
97
+ var polarity: Double = 1.0,
98
+ var distance: Double = Double.POSITIVE_INFINITY,
99
+ var moving: Boolean = false,
100
+ /** With the locator: where the magnet is, dp (smoothed), its 1-σ spread, dp, and height above the glass, cm. */
101
+ var screenPosition: Pt? = null,
102
+ var screenSpread: Double = 0.0,
103
+ var height: Double? = null,
104
+ var approachRate: Double = 0.0,
105
+ var approachSpeed: Double = 0.0,
106
+ var lateralVelocity: Pt = Pt.ZERO,
107
+ )
108
+
109
+ /**
110
+ * The phone's magnetometer as a magnet detector — the Android twin of
111
+ * MagnetKit's MagnetSource. It reads the uncalibrated magnetometer (Android's
112
+ * own calibration would slowly take a nearby magnet for part of the phone),
113
+ * keeps its own zero, and runs the magnet model. Call [reading] once per frame.
114
+ */
115
+ class MagnetSource private constructor(private val app: Context) : SensorEventListener {
116
+ companion object {
117
+ @Volatile private var instance: MagnetSource? = null
118
+ fun get(context: Context): MagnetSource = instance ?: synchronized(this) {
119
+ instance ?: MagnetSource(context.applicationContext).also { instance = it }
120
+ }
121
+ private const val SETTLE_DELAY = 0.25
122
+ private const val CAL_DURATION = 0.35
123
+ private const val ZEROS_KEY = "zeros"
124
+ }
125
+
126
+ var config = MagnetConfig()
127
+ /** Feed a raw field by hand, µT in device axes (emulator, tests). */
128
+ var simulatedField: Vec3? = null
129
+ var simulatedAmbient: Vec3? = null
130
+ /** Pause the drift re-zero. */
131
+ var holdBaseline = false
132
+
133
+ private val sensors = app.getSystemService(Context.SENSOR_SERVICE) as SensorManager
134
+ private val magSensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED)
135
+ ?: sensors.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
136
+ private val poseSensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
137
+ private val gyroSensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
138
+ val isSensorAvailable: Boolean get() = magSensor != null
139
+ val model: MagnetModel? by lazy { MagnetModel.bundled(app) }
140
+
141
+ @Volatile private var latestRaw: Vec3? = null
142
+ @Volatile private var latestPose: FloatArray? = null
143
+ @Volatile private var gyroRate = 0.0
144
+
145
+ private var subscribers = 0
146
+ private var baseline: Vec3? = null
147
+ private var calStart = -1.0
148
+ private var calSum = Vec3.ZERO
149
+ private var calCount = 0
150
+ private var calPose: FloatArray? = null
151
+ private var smoothed: Vec3? = null
152
+ private var lastSign = 1.0
153
+ private var lastToward = Pt.ZERO
154
+ private var lastPolarity = 1.0
155
+ private var located: Pt? = null
156
+ private val targets = ArrayDeque<Pair<Double, Pt>>()
157
+ private var locatedDistance: Double? = null
158
+ private val lnHistory = ArrayDeque<Triple<Double, Double, Double>>()
159
+ private val posHistory = ArrayDeque<Pair<Double, Pt>>()
160
+ private var motionRate = 0.0
161
+ private val pullHistory = ArrayDeque<Pair<Double, Double>>()
162
+ private var pushed = false
163
+ private var lastPushT = -10.0
164
+ private var lastPullT = -10.0
165
+ private var lastSwipeT = -10.0
166
+ private val listeners = LinkedHashMap<Int, (MagnetGesture) -> Unit>()
167
+ private var nextListenerId = 1
168
+ private val main = Handler(Looper.getMainLooper())
169
+ private var lastT = 0.0
170
+ private var overloadPeak = 0.0
171
+ private var overloadAt = -10.0
172
+ private var leftoverWatchUntil = -10.0
173
+ private var stillSince = -1.0
174
+ private val recent = ArrayDeque<Pair<Double, Vec3>>()
175
+ private var quietSince = -1.0
176
+ private var phantomSince = -1.0
177
+ private val rawHistory = ArrayList<Pair<Double, Vec3>>()
178
+ private var presence = 1.0
179
+ private var offsetEstimate = Vec3.ZERO
180
+ private var presenceAt = 0.0
181
+ /** When the phone was last overloaded, while it has not been zeroed since. */
182
+ private var overloadedAt: Double? = null
183
+ private val recentSize = ArrayDeque<Pair<Double, Double>>()
184
+ private var tapP = 0.0
185
+ private var tapAt = 0.0
186
+ private var tapHigh = false
187
+ private var lastTapT = -10.0
188
+ private val tapPoints = ArrayDeque<Triple<Double, Pt, Double>>()
189
+ private var zeroBeforePhantom: Vec3? = null
190
+ private var zeroBeforePhantomAt = 0.0
191
+ private val prefs = app.getSharedPreferences("magnetkit", Context.MODE_PRIVATE)
192
+ private val knownZeros = loadZeros()
193
+ private var cachedNow = -1.0
194
+ private var cached = MagnetReading()
195
+
196
+ // MARK: Sensors
197
+
198
+ fun attach() {
199
+ subscribers += 1
200
+ if (subscribers != 1) return
201
+ magSensor?.let { sensors.registerListener(this, it, 10_000) }
202
+ poseSensor?.let { sensors.registerListener(this, it, 16_000) }
203
+ gyroSensor?.let { sensors.registerListener(this, it, 16_000) }
204
+ if (baseline == null) calibrate()
205
+ }
206
+
207
+ fun detach() {
208
+ subscribers = max(0, subscribers - 1)
209
+ if (subscribers != 0) return
210
+ sensors.unregisterListener(this)
211
+ smoothed = null
212
+ }
213
+
214
+ override fun onSensorChanged(e: SensorEvent) {
215
+ when (e.sensor.type) {
216
+ Sensor.TYPE_MAGNETIC_FIELD_UNCALIBRATED, Sensor.TYPE_MAGNETIC_FIELD ->
217
+ latestRaw = Vec3(e.values[0].toDouble(), e.values[1].toDouble(), e.values[2].toDouble())
218
+ Sensor.TYPE_GAME_ROTATION_VECTOR -> {
219
+ val q = FloatArray(4); SensorManager.getQuaternionFromVector(q, e.values); latestPose = q
220
+ }
221
+ Sensor.TYPE_GYROSCOPE -> gyroRate = sqrt((e.values[0] * e.values[0] + e.values[1] * e.values[1] + e.values[2] * e.values[2]).toDouble())
222
+ }
223
+ }
224
+
225
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
226
+
227
+ /** Be told about quick movements (push, pull away, tap, swipe), on the main thread. Returns an id to remove it. */
228
+ fun addGestureListener(l: (MagnetGesture) -> Unit): Int { val id = nextListenerId++; listeners[id] = l; return id }
229
+ fun removeGestureListener(id: Int) { listeners.remove(id) }
230
+ private fun fire(g: MagnetGesture) { val ls = listeners.values.toList(); main.post { ls.forEach { it(g) } } }
231
+
232
+ /** Take a new zero over the next ~0.6 s. Keep magnets away. */
233
+ fun calibrate() {
234
+ zeroBeforePhantom = null
235
+ calStart = 0.0
236
+ calSum = Vec3.ZERO
237
+ calCount = 0
238
+ }
239
+
240
+ // MARK: Remembered zeros
241
+
242
+ private fun loadZeros(): MutableList<Pair<Vec3, Double>> {
243
+ val now = System.currentTimeMillis() / 1000.0
244
+ val out = ArrayList<Pair<Vec3, Double>>()
245
+ val a = runCatching { JSONArray(prefs.getString(ZEROS_KEY, "[]")) }.getOrNull() ?: return out
246
+ for (i in 0 until a.length()) {
247
+ val e = a.optJSONArray(i) ?: continue
248
+ if (e.length() == 4 && now - e.getDouble(3) < 2 * 86400) out.add(Pair(Vec3(e.getDouble(0), e.getDouble(1), e.getDouble(2)), e.getDouble(3)))
249
+ }
250
+ return out
251
+ }
252
+
253
+ private fun rememberZero(z: Vec3) {
254
+ val now = System.currentTimeMillis() / 1000.0
255
+ knownZeros.removeAll { (it.first - z).length < 2 || now - it.second > 2 * 86400 }
256
+ knownZeros.add(Pair(z, now))
257
+ while (knownZeros.size > 6) knownZeros.removeAt(0)
258
+ val a = JSONArray()
259
+ knownZeros.forEach { a.put(JSONArray(listOf(it.first.x, it.first.y, it.first.z, it.second))) }
260
+ prefs.edit().putString(ZEROS_KEY, a.toString()).apply()
261
+ }
262
+
263
+ // MARK: Locator
264
+
265
+ private fun screenVector(x: Double, y: Double) = Pt(x, -y) // portrait: device y up → screen y down
266
+
267
+ private fun locate(d: Vec3, cfg: MagnetConfig): Pair<Vec3, Double>? {
268
+ val engine = engineLocate(d, cfg)
269
+ val desk = if (cfg.deskPrior) deskPlacement(d, cfg) else null
270
+ if (desk == null) return engine
271
+ if (engine == null) return Pair(desk.first, desk.second)
272
+ val w = desk.third
273
+ return Pair(engine.first * (1 - w) + desk.first * w, engine.second * (1 - w) + desk.second * w)
274
+ }
275
+
276
+ /** A magnet resting at desk height beside the phone: placed beside the edge its sideways field points to. */
277
+ private fun deskPlacement(d: Vec3, cfg: MagnetConfig): Triple<Vec3, Double, Double>? {
278
+ val face = cfg.magnetFace ?: return null
279
+ val sensor = cfg.sensorPosition ?: return null
280
+ val screen = cfg.screenSize ?: return null
281
+ val nb = d.length
282
+ if (nb <= 1e-6) return null
283
+ val f = if (face < 0) -1.0 else 1.0
284
+ if (f * d.z >= 0) return null
285
+ val inPlane = sqrt(d.x * d.x + d.y * d.y) / nb
286
+ val w = min(1.0, max(0.0, (0.3 - inPlane) / 0.15))
287
+ if (w <= 0) return null
288
+ val r = ((cfg.magnetStrength ?: 30000.0) / nb).pow(1.0 / 3.0)
289
+ var tx = f * d.x; var ty = f * d.y
290
+ val tl = sqrt(tx * tx + ty * ty)
291
+ if (tl <= 1e-6) return null
292
+ tx /= tl; ty /= tl
293
+ val ppcm = cfg.pointsPerCm
294
+ val left = -sensor.x / ppcm; val right = (screen.x - sensor.x) / ppcm
295
+ val top = sensor.y / ppcm
296
+ val gap = 1.5
297
+ var x: Double; var y: Double
298
+ if (ty > 0.85) {
299
+ y = top + gap
300
+ if (r <= y) { y = r; x = 0.0 } else x = (if (tx >= 0) 1.0 else -1.0) * sqrt(r * r - y * y)
301
+ } else if (ty < -0.85) {
302
+ x = r * tx; y = r * ty
303
+ } else {
304
+ val side = if (tx >= 0) 1.0 else -1.0
305
+ x = if (side > 0) right + gap else left - gap
306
+ if (r <= abs(x)) { x = side * r; y = 0.0 } else y = -sqrt(r * r - x * x)
307
+ }
308
+ return Triple(Vec3(x, y, 0.5), 0.3 + 0.15 * r, w)
309
+ }
310
+
311
+ private fun engineLocate(d: Vec3, cfg: MagnetConfig): Pair<Vec3, Double>? {
312
+ val face = cfg.magnetFace
313
+ if (cfg.locatorMode == MagnetLocatorMode.DIPOLE && face != null) {
314
+ MagnetDipole.locate(d, face, cfg.magnetStrength ?: 30000.0, cfg.dipoleMaxHeight)?.let { (p, inPlane) ->
315
+ val lateral = sqrt(p.x * p.x + p.y * p.y)
316
+ val bearingSD = min(1.2, 0.12 / max(0.05, inPlane))
317
+ return Pair(p, 0.3 + 0.12 * p.length + lateral * bearingSD)
318
+ }
319
+ }
320
+ return model?.locator?.locate(d, cfg.magnetStrength, cfg.magnetFace)
321
+ }
322
+
323
+ // MARK: Reading
324
+
325
+ /** The current reading. Evaluated once per frame (`now` in seconds); every view shares it. */
326
+ fun reading(now: Double): MagnetReading {
327
+ if (now == cachedNow) return cached
328
+ cachedNow = now
329
+ cached = compute(now)
330
+ return cached
331
+ }
332
+
333
+ private fun resample(now: Double, n: Int, hz: Double, zero: Vec3): List<Vec3> {
334
+ val h = ArrayList<Vec3>(n)
335
+ var j = 0
336
+ for (k in 0 until n) {
337
+ val tk = now - (n - 1 - k) / hz
338
+ while (j + 1 < rawHistory.size && rawHistory[j + 1].first <= tk) j++
339
+ h.add(rawHistory[j].second - zero)
340
+ }
341
+ return h
342
+ }
343
+
344
+ private fun compute(now: Double): MagnetReading {
345
+ val out = MagnetReading()
346
+ val raw = simulatedField ?: latestRaw ?: return out
347
+ out.available = true
348
+ out.raw = raw
349
+ val cfg = config
350
+ val dt = if (lastT > 0) min(0.1, max(0.001, now - lastT)) else 1.0 / 60
351
+ lastT = now
352
+
353
+ var s = smoothed ?: raw
354
+ s += (raw - s) * (1 - exp(-dt / max(0.005, cfg.smoothing)))
355
+ smoothed = s
356
+
357
+ // Calibration: let the reading settle, then average.
358
+ if (calStart >= 0) {
359
+ if (calStart == 0.0) calStart = now
360
+ out.calibrating = true
361
+ out.calibrated = baseline != null
362
+ val age = now - calStart
363
+ if (age >= SETTLE_DELAY) {
364
+ calSum += if (simulatedField != null) (simulatedAmbient ?: raw) else raw
365
+ calCount += 1
366
+ }
367
+ if (age >= SETTLE_DELAY + CAL_DURATION && calCount > 0) {
368
+ val b = calSum / calCount.toDouble()
369
+ baseline = b
370
+ overloadedAt = null
371
+ if (simulatedField == null) rememberZero(b)
372
+ smoothed = b
373
+ calPose = latestPose?.copyOf()
374
+ calStart = -1.0
375
+ stillSince = -1.0
376
+ }
377
+ return out
378
+ }
379
+ val base = baseline ?: run { calibrate(); return out }
380
+ out.calibrated = true
381
+
382
+ // New pose (picked up, turned): re-zero once the phone settles.
383
+ val ref = calPose; val pose = latestPose
384
+ if (simulatedField == null && cfg.autoRecalibrate && ref != null && pose != null) {
385
+ val dot = abs(ref[0] * pose[0] + ref[1] * pose[1] + ref[2] * pose[2] + ref[3] * pose[3]).toDouble()
386
+ val angle = 2 * acos(min(1.0, dot)) * 180 / Math.PI
387
+ if (angle > cfg.poseTolerance) {
388
+ out.moving = true
389
+ if (gyroRate < 0.12) {
390
+ if (stillSince < 0) stillSince = now
391
+ if (now - stillSince > cfg.settleSeconds) calibrate()
392
+ } else stillSince = -1.0
393
+ } else stillSince = -1.0
394
+ }
395
+
396
+ // Drift and leftovers.
397
+ recent.addLast(Pair(now, s))
398
+ while (recent.isNotEmpty() && now - recent.first().first > 0.8) recent.removeFirst()
399
+ var zero = base
400
+ zeroBeforePhantom?.let { prev ->
401
+ if (now - zeroBeforePhantomAt > 120) zeroBeforePhantom = null
402
+ else if ((s - prev).length < max(2.0, 0.05 * (prev - zero).length)) { zero = prev; baseline = prev; zeroBeforePhantom = null }
403
+ }
404
+ val away = (s - zero).length
405
+ val weak = away < (cfg.driftThreshold ?: max(5.0, 0.08 * cfg.fullScale))
406
+ if (away > cfg.tooCloseStrength) {
407
+ overloadPeak = max(overloadPeak, away); overloadAt = now
408
+ overloadedAt = now
409
+ } else if (overloadPeak > 0) {
410
+ if (away < 0.15 * overloadPeak && now - overloadAt < 20) { leftoverWatchUntil = now + 10; overloadPeak = 0.0 }
411
+ else if (now - overloadAt > 20) overloadPeak = 0.0
412
+ }
413
+ var devMax = Double.POSITIVE_INFINITY
414
+ if (recent.size >= 4) {
415
+ var mean = Vec3.ZERO
416
+ recent.forEach { mean += it.second }
417
+ mean /= recent.size.toDouble()
418
+ devMax = recent.maxOf { (it.second - mean).length }
419
+ }
420
+
421
+ // The model: presence (~30 Hz) and taps (~50 Hz) from the recent raw field.
422
+ val net = if (cfg.presenceModel) model?.presence else null
423
+ val tapNet = if (cfg.tapModel) model?.tap else null
424
+ if (net != null || tapNet != null) {
425
+ rawHistory.add(Pair(now, raw))
426
+ val keep = max(net?.let { it.window + 1 } ?: 0.0, tapNet?.let { it.window / it.hz + 0.5 } ?: 0.0)
427
+ var cut = 0
428
+ while (cut < rawHistory.size && now - rawHistory[cut].first > keep) cut++
429
+ if (cut > 0) rawHistory.subList(0, cut).clear()
430
+ }
431
+ if (tapNet != null && now - tapAt >= 1.0 / 50) {
432
+ tapAt = now
433
+ tapP = tapNet.evaluate(resample(now, tapNet.window, tapNet.hz, zero))
434
+ } else if (tapNet == null) tapP = 0.0
435
+ if (net != null) {
436
+ if (now - presenceAt >= 1.0 / 30) {
437
+ presenceAt = now
438
+ val h = resample(now, (net.window * net.hz).toInt(), net.hz, zero)
439
+ // Told about an unzeroed overload only while the field has just dropped.
440
+ val size = (raw - zero).length
441
+ recentSize.addLast(Pair(now, size))
442
+ while (recentSize.isNotEmpty() && now - recentSize.first().first > 10) recentSize.removeFirst()
443
+ val peak = recentSize.maxOf { it.second }
444
+ val dropped = peak - size > max(15.0, 0.12 * peak)
445
+ val r = net.evaluate(h, if (dropped) overloadedAt?.let { now - it } else null)
446
+ presence += (r.first - presence) * (1 - exp(-(1.0 / 30) / 0.1))
447
+ offsetEstimate = r.second
448
+ }
449
+ } else presence = 1.0
450
+
451
+ // Settled back onto a remembered no-magnet reading: that is the zero.
452
+ var snapped = false
453
+ if (!holdBaseline && !weak && devMax < 1.5) {
454
+ val k = knownZeros.minByOrNull { (it.first - s).length }
455
+ if (k != null && (k.first - s).length < 2.5) {
456
+ zero = k.first; baseline = k.first
457
+ zeroBeforePhantom = null
458
+ overloadedAt = null
459
+ snapped = true
460
+ }
461
+ }
462
+ val phantom = if (net != null) presence < 0.25 else (cfg.steadyRezero && now < leftoverWatchUntil)
463
+ if (snapped) {
464
+ quietSince = -1.0; phantomSince = -1.0
465
+ } else if (!holdBaseline && weak && devMax < 0.8) {
466
+ phantomSince = -1.0
467
+ if (quietSince < 0) quietSince = now
468
+ if (now - quietSince > 0.6) {
469
+ zero += (s - zero) * (1 - exp(-dt / max(0.2, cfg.driftSeconds)))
470
+ baseline = zero
471
+ }
472
+ } else if (!holdBaseline && phantom && !weak && devMax < max(3.5, 0.03 * away)) {
473
+ // Not a magnet and holding still: the sensor's own offset.
474
+ quietSince = -1.0
475
+ if (phantomSince < 0) phantomSince = now
476
+ if (now - phantomSince > 0.6) {
477
+ if (zeroBeforePhantom == null || now - zeroBeforePhantomAt > 2) { zeroBeforePhantom = zero; zeroBeforePhantomAt = now }
478
+ zero += (s - zero) * (1 - exp(-dt / 0.25))
479
+ baseline = zero
480
+ if ((s - zero).length < 2) overloadedAt = null
481
+ }
482
+ } else {
483
+ quietSince = -1.0; phantomSince = -1.0
484
+ }
485
+
486
+ val d = s - zero
487
+ out.delta = d
488
+ val strength = d.length
489
+ out.strength = strength
490
+ out.tooClose = strength > cfg.tooCloseStrength
491
+ val b = max(0.0, strength - cfg.deadzone)
492
+ val kk = max(0.5, cfg.scale)
493
+ out.pull = min(1.0, log10(1 + b / kk) / max(0.01, log10(1 + max(1.0, cfg.fullScale - cfg.deadzone) / kk)))
494
+ out.presence = presence
495
+ out.offsetEstimate = offsetEstimate
496
+ if (net != null) out.pull *= min(1.0, max(0.0, (presence - 0.15) / 0.35))
497
+ if (strength > 1) out.distance = cfg.distanceK * strength.pow(-1.0 / 3.0)
498
+
499
+ // Locator: where the magnet is, smoothed more when unsure.
500
+ val sensor = cfg.sensorPosition
501
+ val loc = if (cfg.locatorEnabled && strength > 2.5 && sensor != null) locate(d, cfg) else null
502
+ if (loc != null && sensor != null) {
503
+ val (pos, spreadCm) = loc
504
+ val v = screenVector(pos.x, pos.y)
505
+ var target = Pt(sensor.x + v.x * cfg.pointsPerCm, sensor.y + v.y * cfg.pointsPerCm)
506
+ val window = if (strength < 15) 0.2 else if (strength < 40) 0.12 else 0.04
507
+ targets.addLast(Pair(now, target))
508
+ while (targets.isNotEmpty() && now - targets.first().first > window) targets.removeFirst()
509
+ if (targets.size >= 3) {
510
+ val xs = targets.map { it.second.x }.sorted(); val ys = targets.map { it.second.y }.sorted()
511
+ target = Pt(xs[xs.size / 2], ys[ys.size / 2])
512
+ }
513
+ val tau = 0.04 + 0.22 * min(1.0, spreadCm / 1.5)
514
+ val cur = located
515
+ located = if (cur != null) cur + (target - cur) * (1 - exp(-dt / tau)) else target
516
+ out.screenPosition = located
517
+ out.screenSpread = spreadCm * cfg.pointsPerCm
518
+ out.height = pos.z - 0.4
519
+ locatedDistance = pos.length
520
+ } else if (strength <= 2.5) {
521
+ located = null; targets.clear(); locatedDistance = null
522
+ }
523
+
524
+ // Motion: the slope of ln|B| over the last ~0.12 s.
525
+ val rEst = locatedDistance ?: out.distance
526
+ if (strength > max(3.0, cfg.deadzone * 1.5)) lnHistory.addLast(Triple(now, ln(strength), rEst))
527
+ while (lnHistory.isNotEmpty() && now - lnHistory.first().first > 0.12) lnHistory.removeFirst()
528
+ var rate = 0.0
529
+ if (lnHistory.size >= 3) {
530
+ val n = lnHistory.size.toDouble()
531
+ val mt = lnHistory.sumOf { it.first } / n; val ml = lnHistory.sumOf { it.second } / n
532
+ var num = 0.0; var den = 0.0
533
+ lnHistory.forEach { num += (it.first - mt) * (it.second - ml); den += (it.first - mt) * (it.first - mt) }
534
+ if (den > 1e-9) rate = num / den
535
+ }
536
+ motionRate += (rate - motionRate) * (1 - exp(-dt / 0.03))
537
+ out.approachRate = motionRate
538
+ val rNow = lnHistory.lastOrNull()?.third ?: rEst
539
+ out.approachSpeed = if (rNow.isFinite()) motionRate * rNow / 3 else 0.0
540
+
541
+ // Lateral motion of the located point.
542
+ val sp = out.screenPosition
543
+ if (sp != null) {
544
+ posHistory.addLast(Pair(now, sp))
545
+ while (posHistory.isNotEmpty() && now - posHistory.first().first > 0.1) posHistory.removeFirst()
546
+ val first = posHistory.first()
547
+ if (now - first.first > 0.03) {
548
+ val span = now - first.first
549
+ out.lateralVelocity = Pt((sp.x - first.second.x) / span, (sp.y - first.second.y) / span)
550
+ }
551
+ } else posHistory.clear()
552
+
553
+ // Gestures.
554
+ pullHistory.addLast(Pair(now, out.pull))
555
+ while (pullHistory.isNotEmpty() && now - pullHistory.first().first > 0.5) pullHistory.removeFirst()
556
+ val recentPull = pullHistory.maxOfOrNull { it.second } ?: 0.0
557
+ if (motionRate > cfg.gesturePushRate && out.pull >= cfg.gestureMinPull && !pushed && now - lastPushT > 0.3) {
558
+ pushed = true; lastPushT = now
559
+ fire(MagnetGesture.Push(max(0.0, out.approachSpeed)))
560
+ } else if (motionRate < cfg.gesturePushRate * 0.3) pushed = false
561
+ if (motionRate < -cfg.gesturePullRate && recentPull >= cfg.gestureMinPull && now - lastPullT > 0.3) {
562
+ lastPullT = now
563
+ fire(MagnetGesture.PullAway(max(0.0, -out.approachSpeed)))
564
+ if (tapNet == null && now - lastPushT < cfg.gestureTapWindow) fire(MagnetGesture.Tap(out.screenPosition))
565
+ }
566
+ if (tapNet != null) {
567
+ out.screenPosition?.let { tapPoints.addLast(Triple(now, it, strength)) }
568
+ while (tapPoints.isNotEmpty() && now - tapPoints.first().first > 1.0) tapPoints.removeFirst()
569
+ // No presence gate: a tap is a quick rise and fall, which a leftover never makes.
570
+ if (tapP > 0.5 && !tapHigh && now - lastTapT > 0.35 && strength > cfg.deadzone) {
571
+ tapHigh = true; lastTapT = now
572
+ fire(MagnetGesture.Tap(tapPoints.maxByOrNull { it.third }?.second))
573
+ } else if (tapP < 0.3) tapHigh = false
574
+ }
575
+ val lv = out.lateralVelocity
576
+ val lspeed = hypot(lv.x, lv.y)
577
+ if (lspeed > cfg.gestureSwipeSpeed && now - lastSwipeT > 0.4) {
578
+ lastSwipeT = now
579
+ fire(MagnetGesture.Swipe(Pt(lv.x / lspeed, lv.y / lspeed), lspeed))
580
+ }
581
+
582
+ // Rough side and polarity without the locator.
583
+ if (abs(d.z) > 0.15 * strength + 0.5) lastSign = if (d.z >= 0) 1.0 else -1.0
584
+ val inPlane = hypot(d.x, d.y)
585
+ if (out.pull > 0 && inPlane > 0.06 * strength + 1.5) {
586
+ val flat = abs(d.z) > 0.6 * strength
587
+ val side = if (flat) lastSign else {
588
+ val rv = screenVector(d.x / inPlane, d.y / inPlane)
589
+ if (lastToward == Pt.ZERO) -1.0 else if (rv.x * lastToward.x + rv.y * lastToward.y >= 0) 1.0 else -1.0
590
+ }
591
+ lastToward = screenVector(side * d.x / inPlane, side * d.y / inPlane)
592
+ if (flat) lastPolarity = -lastSign
593
+ } else if (out.pull == 0.0) lastToward = Pt.ZERO
594
+ out.toward = lastToward
595
+ out.polarity = lastPolarity
596
+ return out
597
+ }
598
+ }
@@ -0,0 +1,92 @@
1
+ package dev.libraries.magnetkit
2
+
3
+ import org.json.JSONObject
4
+ import kotlin.math.exp
5
+
6
+ /**
7
+ * A small fully connected network stored as MFXL: "MFXL", a little-endian
8
+ * UInt32 header length, a JSON header (`kind`, `layers`: [{in, out, act}],
9
+ * plus whatever the network needs), then each layer's weights (out × in,
10
+ * row-major) and biases as float16. The same files as MagnetKit on iOS.
11
+ */
12
+ internal class MfxlNet private constructor(
13
+ private val layers: List<Layer>,
14
+ val header: JSONObject,
15
+ ) {
16
+ private class Layer(val inN: Int, val outN: Int, val w: FloatArray, val b: FloatArray, val silu: Boolean)
17
+
18
+ val inputs: Int get() = layers.first().inN
19
+ val outputs: Int get() = layers.last().outN
20
+
21
+ fun forward(input: FloatArray): FloatArray {
22
+ var cur = input
23
+ for (l in layers) {
24
+ val dst = FloatArray(l.outN)
25
+ var row = 0
26
+ for (o in 0 until l.outN) {
27
+ var acc = l.b[o]
28
+ for (i in 0 until l.inN) acc += l.w[row + i] * cur[i]
29
+ row += l.inN
30
+ dst[o] = if (l.silu) acc / (1f + exp(-acc)) else acc
31
+ }
32
+ cur = dst
33
+ }
34
+ return cur
35
+ }
36
+
37
+ companion object {
38
+ /** Parses one MFXL network; nil if it is not one of `kind`. */
39
+ fun parse(data: ByteArray, kind: String? = null): MfxlNet? {
40
+ val header = header(data) ?: return null
41
+ if (kind != null && header.optString("kind") != kind && !(kind == "locator" && !header.has("kind"))) return null
42
+ val specs = header.optJSONArray("layers") ?: return null
43
+ var offset = 8 + headerLength(data)
44
+ fun halves(count: Int): FloatArray? {
45
+ if (offset + count * 2 > data.size) return null
46
+ val out = FloatArray(count)
47
+ for (i in 0 until count) {
48
+ val lo = data[offset + i * 2].toInt() and 0xff
49
+ val hi = data[offset + i * 2 + 1].toInt() and 0xff
50
+ out[i] = halfToFloat(lo or (hi shl 8))
51
+ }
52
+ offset += count * 2
53
+ return out
54
+ }
55
+ val layers = ArrayList<Layer>()
56
+ for (k in 0 until specs.length()) {
57
+ val s = specs.getJSONObject(k)
58
+ val inN = s.getInt("in"); val outN = s.getInt("out")
59
+ val w = halves(inN * outN) ?: return null
60
+ val b = halves(outN) ?: return null
61
+ layers.add(Layer(inN, outN, w, b, s.optString("act") == "silu"))
62
+ }
63
+ if (layers.isEmpty()) return null
64
+ return MfxlNet(layers, header)
65
+ }
66
+
67
+ fun headerLength(data: ByteArray): Int =
68
+ (data[4].toInt() and 0xff) or ((data[5].toInt() and 0xff) shl 8) or
69
+ ((data[6].toInt() and 0xff) shl 16) or ((data[7].toInt() and 0xff) shl 24)
70
+
71
+ fun header(data: ByteArray): JSONObject? {
72
+ if (data.size <= 8 || data[0] != 'M'.code.toByte() || data[1] != 'F'.code.toByte() ||
73
+ data[2] != 'X'.code.toByte() || data[3] != 'L'.code.toByte()) return null
74
+ val hlen = headerLength(data)
75
+ if (data.size < 8 + hlen) return null
76
+ return runCatching { JSONObject(String(data, 8, hlen, Charsets.UTF_8)) }.getOrNull()
77
+ }
78
+
79
+ private fun halfToFloat(h: Int): Float {
80
+ val sign = (h shr 15) and 1
81
+ val e = (h shr 10) and 0x1f
82
+ val mant = h and 0x3ff
83
+ val bits = when {
84
+ e == 0 && mant == 0 -> sign shl 31
85
+ e == 0 -> return (if (sign == 1) -1f else 1f) * mant * 5.9604645e-8f // 2^-24
86
+ e == 31 -> (sign shl 31) or 0x7f800000 or (mant shl 13)
87
+ else -> (sign shl 31) or ((e - 15 + 127) shl 23) or (mant shl 13)
88
+ }
89
+ return Float.fromBits(bits)
90
+ }
91
+ }
92
+ }