react-native-cesium 1.2.3 → 1.2.5

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 (44) hide show
  1. package/README.md +100 -27
  2. package/android/CMakeLists.txt +1 -2
  3. package/android/src/main/cpp/CesiumBridgeAndroid.cpp +264 -130
  4. package/android/src/main/cpp/CesiumBridgeAndroid.h +70 -27
  5. package/android/src/main/java/com/margelo/nitro/reactnativecesium/CesiumBridgeJNI.kt +68 -44
  6. package/android/src/main/java/com/margelo/nitro/reactnativecesium/HybridCesiumView.kt +199 -187
  7. package/cpp/engine/CameraIntegrator.cpp +374 -0
  8. package/cpp/engine/CameraIntegrator.hpp +146 -0
  9. package/cpp/engine/EngineTunables.hpp +90 -31
  10. package/cpp/engine/GlobeCamera.cpp +17 -6
  11. package/cpp/engine/GlobeCamera.hpp +11 -8
  12. package/cpp/engine/test/CameraIntegratorTest.cpp +205 -0
  13. package/cpp/engine/test/build_and_run.sh +27 -0
  14. package/example/README.md +1 -1
  15. package/ios/CesiumBridge.h +53 -28
  16. package/ios/CesiumBridge.mm +172 -86
  17. package/ios/HybridCesiumView.swift +256 -145
  18. package/lib/typescript/src/specs/cesium-view.nitro.d.ts +85 -13
  19. package/lib/typescript/src/specs/cesium-view.nitro.d.ts.map +1 -1
  20. package/nitrogen/generated/android/ReactNativeCesiumOnLoad.cpp +2 -0
  21. package/nitrogen/generated/android/c++/JFunc_void_CameraState.hpp +77 -0
  22. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.cpp +108 -9
  23. package/nitrogen/generated/android/c++/JHybridCesiumViewSpec.hpp +21 -3
  24. package/nitrogen/generated/android/c++/views/JHybridCesiumViewStateUpdater.cpp +24 -0
  25. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/Func_void_CameraState.kt +80 -0
  26. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativecesium/HybridCesiumViewSpec.kt +71 -3
  27. package/nitrogen/generated/ios/ReactNativeCesium-Swift-Cxx-Bridge.cpp +3 -3
  28. package/nitrogen/generated/ios/ReactNativeCesium-Swift-Cxx-Bridge.hpp +43 -28
  29. package/nitrogen/generated/ios/c++/HybridCesiumViewSpecSwift.hpp +89 -9
  30. package/nitrogen/generated/ios/c++/views/HybridCesiumViewComponent.mm +30 -0
  31. package/nitrogen/generated/ios/swift/Func_void_CameraState.swift +5 -5
  32. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec.swift +15 -3
  33. package/nitrogen/generated/ios/swift/HybridCesiumViewSpec_cxx.swift +242 -16
  34. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.cpp +21 -3
  35. package/nitrogen/generated/shared/c++/HybridCesiumViewSpec.hpp +22 -4
  36. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.cpp +66 -0
  37. package/nitrogen/generated/shared/c++/views/HybridCesiumViewComponent.hpp +6 -0
  38. package/nitrogen/generated/shared/json/CesiumViewConfig.json +6 -0
  39. package/package.json +1 -1
  40. package/src/specs/cesium-view.nitro.ts +102 -14
  41. package/cpp/engine/CameraSmoother.cpp +0 -68
  42. package/cpp/engine/CameraSmoother.hpp +0 -28
  43. package/cpp/engine/CameraTargetState.cpp +0 -205
  44. package/cpp/engine/CameraTargetState.hpp +0 -126
@@ -4,6 +4,7 @@ import android.content.Context
4
4
  import android.os.Handler
5
5
  import android.os.HandlerThread
6
6
  import android.os.Process
7
+ import android.os.SystemClock
7
8
  import android.util.Log
8
9
  import android.view.Choreographer
9
10
  import android.view.SurfaceHolder
@@ -16,34 +17,39 @@ import java.util.concurrent.atomic.AtomicBoolean
16
17
 
17
18
  class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec() {
18
19
  private val surfaceView = SurfaceView(appContext)
20
+
19
21
  /// Bridge is only ever read on the render thread. The UI thread posts
20
- /// requests via `renderHandler`. Marked volatile so init/shutdown observers
21
- /// (`bridge != null`) on the UI thread see the latest reference, but real
22
- /// work is always serialized through the render thread.
22
+ /// requests via `renderHandler`. Marked volatile so observers on the UI
23
+ /// thread (`bridge != null`) see the latest reference; real work is always
24
+ /// serialized through the render thread for config-style writes, and
25
+ /// goes directly into CameraIntegrator (with its own mutex) for camera
26
+ /// demand writes.
23
27
  @Volatile private var bridge: CesiumBridgeJNI? = null
24
28
  private var metricsFrameCounter = 0
25
29
  private var idleProbeAccumulator = 0.0
26
- private var lastFrameTimeNanos = 0L
27
- /// Last-pushed camera state — read/written on the render thread (via
28
- /// `renderHandler.post`), never directly on the UI thread.
29
- private var lastPushedCamera: CameraState? = null
30
- private var lastPushedViewCorrection: Quaternion? = null
31
- /// Camera state requested by `setCamera` before the bridge was ready.
32
- /// Updated on the UI thread, drained on the render thread.
33
- @Volatile private var pendingRuntimeCamera: CameraState? = null
34
- @Volatile private var pendingRuntimeViewCorrection: Quaternion? = null
35
- /// True while a Choreographer frame callback is pending. Mutated on the UI
36
- /// thread inside the callback / scheduleNextFrame.
37
30
  private var renderLoopActive = false
38
- /// Drops Choreographer ticks while the render thread is still finishing the
39
- /// previous frame. Set on UI thread when posting; cleared on render thread
40
- /// after the bridge call completes.
41
31
  private val renderInFlight = AtomicBoolean(false)
42
32
 
43
- /// Dedicated render thread — owns all CesiumEngine + GPU work. Created lazily
44
- /// when the first SurfaceHolder.Callback fires. The Choreographer (UI thread)
45
- /// posts render requests here; bridge config setters likewise post here so
46
- /// they serialize with rendering and never race with engine state.
33
+ /// Set in `surfaceDestroyed` before any teardown work begins. Render-thread
34
+ /// callbacks and choreographer frame ticks check this flag and bail out so
35
+ /// they don't try to emit `onMetrics` into a JS dispatcher that may already
36
+ /// have been torn down by fast refresh / view unmount. Volatile so the
37
+ /// render thread sees the write made on the UI thread.
38
+ @Volatile private var isShutdown = false
39
+
40
+ // Demand values that arrived before the bridge was created. Drained once
41
+ // in surfaceCreated after the seed teleport.
42
+ @Volatile private var pendingTeleport: CameraState? = null
43
+ @Volatile private var pendingLat: Double? = null
44
+ @Volatile private var pendingLon: Double? = null
45
+ @Volatile private var pendingAltitude: Double? = null
46
+ @Volatile private var pendingHeading: Double? = null
47
+ @Volatile private var pendingPitch: Double? = null
48
+ @Volatile private var pendingRoll: Double? = null
49
+ @Volatile private var pendingVfov: Double? = null
50
+ @Volatile private var pendingViewCorrection: Quaternion? = null
51
+
52
+ /// Dedicated render thread — owns all CesiumEngine + GPU work.
47
53
  private var renderThread: HandlerThread? = null
48
54
  private var renderHandler: Handler? = null
49
55
 
@@ -123,27 +129,54 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
123
129
  override var minAltitudeAboveTerrain: Double? = null
124
130
  set(value) { field = value; if (value != null) postToRender { bridge?.setMinAltitudeAboveTerrain(value.toFloat()) } }
125
131
 
132
+ // ── Rate caps ────────────────────────────────────────────────────────────
133
+ override var maxYawRateDegSec: Double? = null
134
+ set(value) { field = value; pushRateCaps() }
135
+ override var maxPitchRateDegSec: Double? = null
136
+ set(value) { field = value; pushRateCaps() }
137
+ override var maxRollRateDegSec: Double? = null
138
+ set(value) { field = value; pushRateCaps() }
139
+ override var maxClimbRateMps: Double? = null
140
+ set(value) { field = value; pushRateCaps() }
141
+ override var maxGroundSpeedMps: Double? = null
142
+ set(value) { field = value; pushRateCaps() }
143
+
144
+ private fun pushRateCaps() {
145
+ val yaw = maxYawRateDegSec ?: 0.0
146
+ val pitch = maxPitchRateDegSec ?: 0.0
147
+ val roll = maxRollRateDegSec ?: 0.0
148
+ val climb = maxClimbRateMps ?: 0.0
149
+ val gnd = maxGroundSpeedMps ?: 0.0
150
+ postToRender { bridge?.setRateCaps(yaw, pitch, roll, climb, gnd) }
151
+ }
152
+
126
153
  override var onMetrics: ((metrics: CesiumMetrics) -> Unit)? = null
127
154
 
155
+ override var onActualCamera: ((camera: CameraState) -> Unit)? = null
156
+ // Both accessed only on the render thread; no extra locking needed.
157
+ private var actualCameraLastEmitSec: Double = 0.0
158
+ private var actualCameraLastSent: CameraState? = null
159
+
160
+ // Returns true when the two snapshots differ enough to warrant a JS dispatch.
161
+ // Thresholds mirror EngineTunables.hpp kActualCameraCallbackInterval* constants.
162
+ private fun actualCameraChanged(a: CameraState, b: CameraState): Boolean =
163
+ Math.abs(a.latitude - b.latitude) > 1e-7 ||
164
+ Math.abs(a.longitude - b.longitude) > 1e-7 ||
165
+ Math.abs(a.altitude - b.altitude) > 0.05 ||
166
+ Math.abs(a.heading - b.heading) > 0.01 ||
167
+ Math.abs(a.pitch - b.pitch) > 0.01 ||
168
+ Math.abs(a.roll - b.roll) > 0.01 ||
169
+ Math.abs(a.verticalFovDeg - b.verticalFovDeg) > 0.01
170
+
128
171
  override val view: View get() = surfaceView
129
172
 
130
- private val frameCallback = Choreographer.FrameCallback { frameTimeNanos ->
173
+ private val frameCallback = Choreographer.FrameCallback {
131
174
  renderLoopActive = false
132
- if (pauseRendering || bridge == null) {
175
+ if (isShutdown || pauseRendering || bridge == null) {
133
176
  return@FrameCallback
134
177
  }
135
178
 
136
- val dt = if (lastFrameTimeNanos > 0) {
137
- (frameTimeNanos - lastFrameTimeNanos).coerceAtLeast(1_000_000L) / 1_000_000_000.0
138
- } else {
139
- 1.0 / 60.0
140
- }
141
- lastFrameTimeNanos = frameTimeNanos
142
-
143
- // Drop the tick if the previous frame's render hasn't finished — keeps the
144
- // render thread from queuing up an unbounded backlog under stutter.
145
179
  if (!renderInFlight.compareAndSet(false, true)) {
146
- // Re-arm so we try again next vsync.
147
180
  scheduleNextFrame(immediate = true)
148
181
  return@FrameCallback
149
182
  }
@@ -153,13 +186,13 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
153
186
  renderInFlight.set(false)
154
187
  return@FrameCallback
155
188
  }
156
- rh.post { runFrameOnRenderThread(dt) }
189
+ val nowSeconds = SystemClock.elapsedRealtimeNanos() / 1_000_000_000.0
190
+ rh.post { runFrameOnRenderThread(nowSeconds) }
157
191
  }
158
192
 
159
- /// Called on the render thread. Owns ALL CesiumEngine + GPU access; the UI
160
- /// thread only signals demand here. Safe to read `onMetrics` because Nitro
161
- /// callbacks dispatch internally to the JS thread.
162
- private fun runFrameOnRenderThread(dt: Double) {
193
+ /// Run on the render thread immediately after the bridge is created, before
194
+ /// the first frame.
195
+ private fun runFrameOnRenderThread(nowSeconds: Double) {
163
196
  val b = bridge
164
197
  if (b == null) {
165
198
  renderInFlight.set(false)
@@ -170,10 +203,11 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
170
203
  val nextImmediate: Boolean
171
204
  if (shouldRender) {
172
205
  idleProbeAccumulator = 0.0
173
- b.renderFrame(dt)
206
+ b.renderFrame(nowSeconds)
174
207
  nextImmediate = true
175
208
  } else {
176
- idleProbeAccumulator += dt
209
+ // Approximate dt for the idle probe; not used for motion.
210
+ idleProbeAccumulator += 1.0 / 60.0
177
211
  if (idleProbeAccumulator >= 0.25) {
178
212
  idleProbeAccumulator = 0.0
179
213
  b.markNeedsRender()
@@ -184,7 +218,7 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
184
218
  metricsFrameCounter++
185
219
  if (metricsFrameCounter >= 20) {
186
220
  metricsFrameCounter = 0
187
- val cb = onMetrics
221
+ val cb = if (isShutdown) null else onMetrics
188
222
  if (cb != null) {
189
223
  cb(CesiumMetrics(
190
224
  fps = b.getMetricsFps(),
@@ -199,11 +233,29 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
199
233
  }
200
234
  }
201
235
 
236
+ // onActualCamera: poll at 5 Hz (kActualCameraCallbackIntervalSec = 0.2 s),
237
+ // but only dispatch when at least one field has moved beyond its epsilon.
238
+ val acCb = if (isShutdown) null else onActualCamera
239
+ if (acCb != null && (nowSeconds - actualCameraLastEmitSec) >= 0.2) {
240
+ actualCameraLastEmitSec = nowSeconds
241
+ val candidate = CameraState(
242
+ latitude = b.getActualLatitude(),
243
+ longitude = b.getActualLongitude(),
244
+ altitude = b.getActualAltitude(),
245
+ heading = b.getActualHeading(),
246
+ pitch = b.getActualPitch(),
247
+ roll = b.getActualRoll(),
248
+ verticalFovDeg = b.getActualVerticalFovDeg(),
249
+ )
250
+ val prev = actualCameraLastSent
251
+ if (prev == null || actualCameraChanged(candidate, prev)) {
252
+ actualCameraLastSent = candidate
253
+ acCb(candidate)
254
+ }
255
+ }
256
+
202
257
  renderInFlight.set(false)
203
258
 
204
- // Re-arm on the UI thread so Choreographer remains the source of truth
205
- // for vsync timing. Posting a no-op runnable to surfaceView keeps us off
206
- // the render thread for that small piece of bookkeeping.
207
259
  surfaceView.post { scheduleNextFrame(immediate = nextImmediate) }
208
260
  }
209
261
 
@@ -214,47 +266,38 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
214
266
  val h = surfaceView.height
215
267
  if (w <= 0 || h <= 0) return
216
268
 
217
- // Spin up the render thread on first surfaceCreated. If we previously
218
- // tore it down (e.g. on backgrounding), re-spawn cleanly.
219
269
  ensureRenderThread()
220
270
  val rh = renderHandler ?: return
221
271
 
222
272
  val cacheDir = appContext.cacheDir.absolutePath
223
273
  val cacertPath = extractCacert()
224
- // Init runs on the render thread because Vulkan instance/device creation
225
- // and Cesium Native init both touch state we'd rather only ever poke
226
- // from one thread.
227
274
  rh.post {
228
275
  val b = CesiumBridgeJNI()
229
276
  b.init(surface, w, h, cacheDir, cacertPath)
230
277
  bridge = b
231
278
  syncBridgePropsOnRenderThread(b)
232
279
 
233
- b.updateCamera(
234
- initialCamera.latitude, initialCamera.longitude, initialCamera.altitude,
235
- initialCamera.heading, initialCamera.pitch, initialCamera.roll,
280
+ val seed = pendingTeleport ?: initialCamera
281
+ b.teleport(
282
+ seed.latitude, seed.longitude, seed.altitude,
283
+ seed.heading, seed.pitch, seed.roll, seed.verticalFovDeg,
236
284
  )
237
- b.setVerticalFovDeg(initialCamera.verticalFovDeg)
238
- lastPushedCamera = initialCamera
239
-
240
- val pending = pendingRuntimeCamera
241
- if (pending != null) {
242
- val q = pendingRuntimeViewCorrection
243
- if (q != null) {
244
- b.updateCameraQuaternion(
245
- pending.latitude, pending.longitude, pending.altitude,
246
- pending.heading, pending.pitch, pending.roll,
247
- q.w, q.x, q.y, q.z,
248
- )
249
- lastPushedViewCorrection = q
250
- } else {
251
- b.updateCamera(
252
- pending.latitude, pending.longitude, pending.altitude,
253
- pending.heading, pending.pitch, pending.roll,
254
- )
255
- }
256
- b.setVerticalFovDeg(pending.verticalFovDeg)
257
- lastPushedCamera = pending
285
+ pendingTeleport = null
286
+
287
+ val la = pendingLat
288
+ val lo = pendingLon
289
+ if (la != null && lo != null) {
290
+ b.setPosition(la, lo); pendingLat = null; pendingLon = null
291
+ }
292
+ pendingAltitude?.let { b.setAltitude(it); pendingAltitude = null }
293
+ pendingHeading?.let { b.setHeading(it); pendingHeading = null }
294
+ val p = pendingPitch; val r = pendingRoll
295
+ if (p != null && r != null) {
296
+ b.setAttitude(p, r); pendingPitch = null; pendingRoll = null
297
+ }
298
+ pendingVfov?.let { b.setVerticalFovDeg(it); pendingVfov = null }
299
+ pendingViewCorrection?.let { q ->
300
+ b.setViewCorrection(q.w, q.x, q.y, q.z); pendingViewCorrection = null
258
301
  }
259
302
 
260
303
  if (ionAccessToken.isNotEmpty()) {
@@ -265,9 +308,8 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
265
308
  }
266
309
  b.setMsaa(msaaSampleCount.toInt())
267
310
 
268
- // Start the rendering loop here, after the bridge is fully
269
- // initialized. scheduleNextFrame must be invoked on the UI thread
270
- // because Choreographer.postFrameCallback() requires it.
311
+ pushRateCaps()
312
+
271
313
  surfaceView.post { scheduleNextFrame(immediate = true) }
272
314
  }
273
315
  }
@@ -276,30 +318,30 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
276
318
  postToRender {
277
319
  bridge?.resize(width, height)
278
320
  bridge?.markNeedsRender()
279
- // Re-arm the loop in case surfaceChanged fires before the bridge was
280
- // created (rare, but possible on cold starts). Idempotent: scheduleNextFrame
281
- // early-returns if the loop is already active.
282
321
  surfaceView.post { scheduleNextFrame(immediate = true) }
283
322
  }
284
323
  }
285
324
 
286
325
  override fun surfaceDestroyed(holder: SurfaceHolder) {
326
+ // Flip the shutdown gate and drop the JS callback before any teardown
327
+ // work — any in-flight render-thread frame or queued choreographer tick
328
+ // will then bail out without touching the bridge or emitting metrics
329
+ // into a dying JS runtime.
330
+ isShutdown = true
331
+ onMetrics = null
332
+ onActualCamera = null
333
+ actualCameraLastSent = null
334
+
287
335
  val rh = renderHandler
288
336
  val ht = renderThread
289
337
 
290
338
  if (rh == null || ht == null) {
291
- // Nothing to drain; clear bridge reference if it somehow exists.
292
339
  bridge?.shutdown()
293
340
  bridge?.destroy()
294
341
  bridge = null
295
342
  return
296
343
  }
297
344
 
298
- // Block the UI thread (briefly) until the render thread has shut the
299
- // bridge down — surfaceDestroyed contract requires the surface to be
300
- // released before this returns. We cap our wait so a stuck GPU teardown
301
- // can't hang the UI thread indefinitely (matches our 1s shutdown
302
- // semaphore policy on the Vulkan / Metal side).
303
345
  val done = java.util.concurrent.CountDownLatch(1)
304
346
  rh.post {
305
347
  try {
@@ -313,8 +355,7 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
313
355
  }
314
356
  }
315
357
  if (!done.await(1500, java.util.concurrent.TimeUnit.MILLISECONDS)) {
316
- Log.w("CesiumView", "Render-thread shutdown did not complete in 1.5s; "
317
- + "tearing down handler thread anyway.")
358
+ Log.w("CesiumView", "Render-thread shutdown did not complete in 1.5s; tearing down handler thread anyway.")
318
359
  }
319
360
  stopRenderThread()
320
361
  }
@@ -341,53 +382,35 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
341
382
  ht.quitSafely()
342
383
  }
343
384
 
344
- /// Posts an action to the render thread. When the render thread isn't
345
- /// running yet (early prop assignment, or after teardown), we silently drop
346
- /// the action — bridges always take their initial config from the
347
- /// `syncBridgePropsOnRenderThread` step inside surfaceCreated.
348
385
  private fun postToRender(action: () -> Unit) {
349
386
  renderHandler?.post(action)
350
387
  }
351
388
 
352
- /// Camera reads cross the JNI from the JS thread (Nitro promise resolution).
353
- /// They read snapshot scalar values through CesiumBridgeAndroid → engine →
354
- /// GlobeCamera, none of which is mutated outside the render thread, so the
355
- /// worst that can happen is observing a value from a frame slightly behind
356
- /// what the render thread is encoding right now — acceptable for a UI-driven
357
- /// camera read.
358
- override fun getCameraState(): Promise<CameraState> {
389
+ // ── Camera readback ─────────────────────────────────────────────────────
390
+ override fun getActualCamera(): Promise<CameraState> {
359
391
  val b = bridge ?: return Promise.resolved(initialCamera)
360
392
  return Promise.resolved(CameraState(
361
- latitude = b.getCameraLat(),
362
- longitude = b.getCameraLon(),
363
- altitude = b.getCameraAlt(),
364
- heading = b.getCameraHeading(),
365
- pitch = b.getCameraPitch(),
366
- roll = b.getCameraRoll(),
367
- verticalFovDeg = b.getVerticalFovDeg(),
393
+ latitude = b.getActualLatitude(),
394
+ longitude = b.getActualLongitude(),
395
+ altitude = b.getActualAltitude(),
396
+ heading = b.getActualHeading(),
397
+ pitch = b.getActualPitch(),
398
+ roll = b.getActualRoll(),
399
+ verticalFovDeg = b.getActualVerticalFovDeg(),
368
400
  ))
369
401
  }
370
402
 
371
- override fun setCamera(camera: CameraState) {
372
- // Runtime camera control. Does NOT mutate `initialCamera` (which is
373
- // construction-time only).
374
- pendingRuntimeCamera = camera
375
- pendingRuntimeViewCorrection = null
376
- postToRender {
377
- pushCameraIfChanged(camera)
378
- bridge?.markNeedsRender()
379
- }
380
- scheduleNextFrame(immediate = true)
381
- }
382
-
383
- override fun setCameraQuaternion(camera: CameraState, viewCorrection: Quaternion) {
384
- pendingRuntimeCamera = camera
385
- pendingRuntimeViewCorrection = viewCorrection
386
- postToRender {
387
- pushCameraQuaternionIfChanged(camera, viewCorrection)
388
- bridge?.markNeedsRender()
389
- }
390
- scheduleNextFrame(immediate = true)
403
+ override fun getDemandCamera(): Promise<CameraState> {
404
+ val b = bridge ?: return Promise.resolved(initialCamera)
405
+ return Promise.resolved(CameraState(
406
+ latitude = b.getDemandLatitude(),
407
+ longitude = b.getDemandLongitude(),
408
+ altitude = b.getDemandAltitude(),
409
+ heading = b.getDemandHeading(),
410
+ pitch = b.getDemandPitch(),
411
+ roll = b.getDemandRoll(),
412
+ verticalFovDeg = b.getDemandVerticalFovDeg(),
413
+ ))
391
414
  }
392
415
 
393
416
  override fun getViewCorrection(): Promise<Quaternion> {
@@ -403,65 +426,63 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
403
426
  )
404
427
  }
405
428
 
406
- private fun pushCameraIfChanged(camera: CameraState) {
407
- val b = bridge ?: return
408
- val last = lastPushedCamera
409
- if (last != null &&
410
- last.latitude == camera.latitude &&
411
- last.longitude == camera.longitude &&
412
- last.altitude == camera.altitude &&
413
- last.heading == camera.heading &&
414
- last.pitch == camera.pitch &&
415
- last.roll == camera.roll &&
416
- last.verticalFovDeg == camera.verticalFovDeg) {
417
- return
429
+ // ── Per-DoF camera setters ──────────────────────────────────────────────
430
+ // These bypass the renderHandler hop: the underlying CameraIntegrator owns
431
+ // its own mutex and handles concurrent writers safely. Keeping the hot
432
+ // path off the render thread reduces latency for high-frequency
433
+ // (Reanimated worklet, 50 Hz IMU) drivers.
434
+ override fun setPosition(latitude: Double, longitude: Double) {
435
+ val b = bridge
436
+ if (b != null) {
437
+ b.setPosition(latitude, longitude)
438
+ scheduleNextFrame(immediate = true)
439
+ } else {
440
+ pendingLat = latitude; pendingLon = longitude
418
441
  }
419
- lastPushedCamera = camera
420
- b.updateCamera(camera.latitude, camera.longitude, camera.altitude,
421
- camera.heading, camera.pitch, camera.roll)
422
- b.setVerticalFovDeg(camera.verticalFovDeg)
423
442
  }
424
443
 
425
- private fun pushCameraQuaternionIfChanged(camera: CameraState, viewCorrection: Quaternion) {
426
- val b = bridge ?: return
427
- val last = lastPushedCamera
428
- val lastQ = lastPushedViewCorrection
429
- if (last != null && lastQ != null &&
430
- last.latitude == camera.latitude &&
431
- last.longitude == camera.longitude &&
432
- last.altitude == camera.altitude &&
433
- last.heading == camera.heading &&
434
- last.pitch == camera.pitch &&
435
- last.roll == camera.roll &&
436
- last.verticalFovDeg == camera.verticalFovDeg &&
437
- lastQ.w == viewCorrection.w &&
438
- lastQ.x == viewCorrection.x &&
439
- lastQ.y == viewCorrection.y &&
440
- lastQ.z == viewCorrection.z
441
- ) {
442
- return
444
+ override fun setAltitude(altitudeMeters: Double) {
445
+ val b = bridge
446
+ if (b != null) { b.setAltitude(altitudeMeters); scheduleNextFrame(immediate = true) }
447
+ else { pendingAltitude = altitudeMeters }
448
+ }
449
+
450
+ override fun setHeading(headingDeg: Double) {
451
+ val b = bridge
452
+ if (b != null) { b.setHeading(headingDeg); scheduleNextFrame(immediate = true) }
453
+ else { pendingHeading = headingDeg }
454
+ }
455
+
456
+ override fun setAttitude(pitchDeg: Double, rollDeg: Double) {
457
+ val b = bridge
458
+ if (b != null) { b.setAttitude(pitchDeg, rollDeg); scheduleNextFrame(immediate = true) }
459
+ else { pendingPitch = pitchDeg; pendingRoll = rollDeg }
460
+ }
461
+
462
+ override fun setViewCorrection(q: Quaternion) {
463
+ val b = bridge
464
+ if (b != null) { b.setViewCorrection(q.w, q.x, q.y, q.z); scheduleNextFrame(immediate = true) }
465
+ else { pendingViewCorrection = q }
466
+ }
467
+
468
+ override fun setVerticalFov(deg: Double) {
469
+ val b = bridge
470
+ if (b != null) { b.setVerticalFovDeg(deg); scheduleNextFrame(immediate = true) }
471
+ else { pendingVfov = deg }
472
+ }
473
+
474
+ override fun teleport(camera: CameraState) {
475
+ val b = bridge
476
+ if (b != null) {
477
+ b.teleport(camera.latitude, camera.longitude, camera.altitude,
478
+ camera.heading, camera.pitch, camera.roll, camera.verticalFovDeg)
479
+ scheduleNextFrame(immediate = true)
480
+ } else {
481
+ pendingTeleport = camera
443
482
  }
444
- lastPushedCamera = camera
445
- lastPushedViewCorrection = viewCorrection
446
- b.updateCameraQuaternion(
447
- camera.latitude,
448
- camera.longitude,
449
- camera.altitude,
450
- camera.heading,
451
- camera.pitch,
452
- camera.roll,
453
- viewCorrection.w,
454
- viewCorrection.x,
455
- viewCorrection.y,
456
- viewCorrection.z,
457
- )
458
- b.setVerticalFovDeg(camera.verticalFovDeg)
459
483
  }
460
484
 
461
- /// Run on the render thread immediately after the bridge is created, before
462
- /// the first frame. Matches Swift's syncBridgeOptionsFromProps and pushes
463
- /// every prop into the freshly-built CesiumEngine so the consumer doesn't
464
- /// have to re-set them after the surface comes up.
485
+ /// Render-thread setup, called once after the bridge is built.
465
486
  private fun syncBridgePropsOnRenderThread(b: CesiumBridgeJNI) {
466
487
  b.setMaxSSE(maximumScreenSpaceError)
467
488
  b.setMaxSimLoads(maximumSimultaneousTileLoads.toInt())
@@ -483,12 +504,6 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
483
504
 
484
505
  /**
485
506
  * Schedule the next frame callback.
486
- *
487
- * - `immediate = true`: the engine wants to render this vsync (active pan,
488
- * tile loading, prop change). Re-post on the next available vsync.
489
- * - `immediate = false`: the engine is idle. Re-post in 250ms — matches the
490
- * iOS low-refresh range and keeps the main thread mostly asleep while
491
- * still picking up late tile completions / network responses.
492
507
  */
493
508
  private fun scheduleNextFrame(immediate: Boolean) {
494
509
  if (renderLoopActive || pauseRendering || bridge == null) return
@@ -504,15 +519,12 @@ class HybridCesiumView(private val appContext: Context) : HybridCesiumViewSpec()
504
519
  val outFile = File(appContext.cacheDir, "cacert.pem")
505
520
  try {
506
521
  appContext.assets.open("cacert.pem").use { input ->
507
- // Re-extract if the cached copy is missing or a different size to the
508
- // bundled asset (handles the file being updated across app versions).
509
522
  val assetSize = input.available().toLong()
510
523
  if (!outFile.exists() || outFile.length() != assetSize) {
511
524
  FileOutputStream(outFile).use { output -> input.copyTo(output) }
512
525
  }
513
526
  }
514
527
  } catch (_: Exception) {
515
- // cacert.pem not bundled as asset — libcurl will use system certs
516
528
  return if (outFile.exists()) outFile.absolutePath else ""
517
529
  }
518
530
  return outFile.absolutePath