replate-camera 0.8.0 → 0.9.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.
@@ -93,5 +93,6 @@ dependencies {
93
93
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
94
94
  implementation 'com.google.ar:core:1.45.0'
95
95
  implementation 'com.google.ar.sceneform.ux:sceneform-ux:1.17.1'
96
+ implementation("androidx.exifinterface:exifinterface:1.3.7")
96
97
  }
97
98
 
@@ -0,0 +1,60 @@
1
+ package com.replatecamera
2
+
3
+ import com.facebook.react.bridge.*
4
+ import com.facebook.react.module.annotations.ReactModule
5
+ import com.facebook.react.uimanager.UIManagerHelper
6
+ import com.facebook.react.uimanager.common.UIManagerType
7
+
8
+ @ReactModule(name = ReplateCameraModule.NAME)
9
+ class ReplateCameraModule(private val reactContext: ReactApplicationContext) :
10
+ ReactContextBaseJavaModule(reactContext) {
11
+
12
+ companion object {
13
+ const val NAME = "ReplateCameraModule"
14
+ }
15
+
16
+ override fun getName(): String = NAME
17
+
18
+ /**
19
+ * Example: takePhoto(...) from JS, returning a promise that resolves with the file URI or rejects on error.
20
+ *
21
+ * @param viewTag The React tag (integer) of the ReplateCameraView. From JS, you can get this via findNodeHandle(ref).
22
+ */
23
+ @ReactMethod
24
+ fun takePhoto(viewTag: Int, promise: Promise) {
25
+ val cameraView = findCameraViewByTag(viewTag)
26
+ if (cameraView == null) {
27
+ promise.reject("VIEW_NOT_FOUND", "No ReplateCameraView found with tag $viewTag")
28
+ return
29
+ }
30
+
31
+ // Call the Kotlin AR method:
32
+ cameraView.takePhoto { uri ->
33
+ if (uri != null) {
34
+ // resolve the promise with the URI string
35
+ promise.resolve(uri.toString())
36
+ } else {
37
+ promise.reject("PHOTO_ERROR", "Failed to capture photo.")
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Example: resetSession(...) from JS, no return value needed.
44
+ */
45
+ @ReactMethod
46
+ fun resetSession(viewTag: Int) {
47
+ val cameraView = findCameraViewByTag(viewTag)
48
+ cameraView?.resetSession()
49
+ }
50
+
51
+ /**
52
+ * Helper function that looks up the ReplateCameraView instance by its React tag.
53
+ */
54
+ private fun findCameraViewByTag(viewTag: Int): ReplateCameraView? {
55
+ // Use UIManagerHelper to look up the native view associated with 'viewTag'.
56
+ val uiManager = UIManagerHelper.getUIManager(reactContext, UIManagerType.DEFAULT /* 1 => default type for Fabric or not */)
57
+ val nativeView = uiManager?.resolveView(viewTag)
58
+ return nativeView as? ReplateCameraView
59
+ }
60
+ }
@@ -8,7 +8,7 @@ import com.facebook.react.uimanager.ViewManager
8
8
 
9
9
  class ReplateCameraPackage : ReactPackage {
10
10
  override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
11
- return emptyList()
11
+ return listOf(ReplateCameraModule(reactContext))
12
12
  }
13
13
 
14
14
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
@@ -4,105 +4,267 @@ import android.Manifest
4
4
  import android.app.Activity
5
5
  import android.content.Context
6
6
  import android.content.pm.PackageManager
7
+ import android.graphics.*
8
+ import android.net.Uri
7
9
  import android.os.Build
10
+ import android.os.Environment
11
+ import android.os.Handler
12
+ import android.os.Looper
8
13
  import android.util.AttributeSet
14
+ import android.util.Log
9
15
  import android.view.GestureDetector
10
16
  import android.view.MotionEvent
17
+ import android.view.PixelCopy
11
18
  import android.view.ScaleGestureDetector
12
19
  import android.widget.FrameLayout
13
20
  import androidx.annotation.RequiresApi
14
21
  import androidx.core.app.ActivityCompat
15
22
  import androidx.core.content.ContextCompat
23
+ import androidx.exifinterface.media.ExifInterface
16
24
  import androidx.fragment.app.FragmentActivity
25
+ import com.google.ar.core.Anchor
17
26
  import com.google.ar.core.HitResult
18
- import com.google.ar.sceneform.AnchorNode
27
+ import com.google.ar.core.Plane
28
+ import com.google.ar.core.TrackingState
29
+ import com.google.ar.sceneform.*
19
30
  import com.google.ar.sceneform.math.Vector3
31
+ import com.google.ar.sceneform.rendering.Color
32
+ import com.google.ar.sceneform.rendering.Material
20
33
  import com.google.ar.sceneform.rendering.MaterialFactory
21
34
  import com.google.ar.sceneform.rendering.ShapeFactory
22
35
  import com.google.ar.sceneform.ux.ArFragment
23
36
  import com.google.ar.sceneform.ux.TransformableNode
24
- import com.google.ar.sceneform.rendering.Color
37
+ import java.io.File
38
+ import java.io.FileOutputStream
39
+ import java.text.SimpleDateFormat
40
+ import java.util.Date
41
+ import kotlin.math.acos
42
+ import kotlin.math.atan2
43
+ import kotlin.math.cos
44
+ import kotlin.math.round
45
+ import kotlin.math.sin
25
46
  import kotlin.math.sqrt
26
47
 
48
+ /**
49
+ * Kotlin version of an ARCore/Sceneform view, mimicking much of the Swift+RealityKit approach.
50
+ * - Creates spheres in two circles around an anchor.
51
+ * - Allows pan/pinch to move/scale.
52
+ * - Captures screenshots with metadata placeholders.
53
+ * - Tracks gravity vector via SensorManager (TYPE_GRAVITY).
54
+ *
55
+ * Usage:
56
+ * 1) In your Activity/Fragment layout, add a <FrameLayout> that hosts this view.
57
+ * 2) Instantiate ReplateCameraView in code or via XML. The ARFragment is attached dynamically.
58
+ * 3) Call takePhoto() to save a screenshot to external files.
59
+ */
27
60
  @RequiresApi(Build.VERSION_CODES.N)
28
61
  class ReplateCameraView @JvmOverloads constructor(
29
- context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
30
- ) : FrameLayout(context, attrs, defStyleAttr) {
62
+ context: Context,
63
+ attrs: AttributeSet? = null,
64
+ defStyleAttr: Int = 0
65
+ ) : FrameLayout(context, attrs, defStyleAttr), Scene.OnUpdateListener,
66
+ android.hardware.SensorEventListener {
67
+
68
+ companion object {
69
+ private const val TAG = "ReplateCameraView"
70
+
71
+ // Number of spheres per circle (mirrors Swift code's 72)
72
+ private const val TOTAL_SPHERES_PER_CIRCLE = 72
73
+ // Each sphere is placed 5 degrees apart => 360/5 = 72
74
+ private const val ANGLE_INCREMENT_DEGREES = 5f
75
+
76
+ // Initial defaults that match the Swift code
77
+ private const val DEFAULT_SPHERE_RADIUS = 0.004f
78
+ private const val DEFAULT_SPHERES_RADIUS = 0.13f
79
+ private const val DEFAULT_SPHERES_HEIGHT = 0.10f
80
+ private const val DEFAULT_DISTANCE_BETWEEN_CIRCLES = 0.10f
81
+ private const val DEFAULT_DRAG_SPEED = 7000f
82
+ private const val ANGLE_THRESHOLD = 0.6f // Radians
83
+ private const val MIN_DISTANCE = 0.15f
84
+ private const val MAX_DISTANCE = 0.65f
31
85
 
86
+ // Track whether each sphere index is "set" (mirroring Swift arrays)
87
+ private val upperSpheresSet = BooleanArray(TOTAL_SPHERES_PER_CIRCLE) { false }
88
+ private val lowerSpheresSet = BooleanArray(TOTAL_SPHERES_PER_CIRCLE) { false }
89
+
90
+ // Stats
91
+ var totalPhotosTaken = 0
92
+ var photosFromDifferentAnglesTaken = 0
93
+ }
94
+
95
+ // AR Sceneform stuff
32
96
  private lateinit var arFragment: ArFragment
33
97
  private var anchorNode: AnchorNode? = null
34
- private var spheres: MutableList<TransformableNode> = mutableListOf()
98
+
99
+ // Spheres and a "focus node"
100
+ private val spheresModels = mutableListOf<TransformableNode>()
101
+ private var focusNode: Node? = null
102
+
103
+ // Scene geometry
104
+ private var sphereRadius = DEFAULT_SPHERE_RADIUS
105
+ private var spheresRadius = DEFAULT_SPHERES_RADIUS
106
+ private var sphereAngle = ANGLE_INCREMENT_DEGREES // If you want to “scale angle”
107
+ private var spheresHeight = DEFAULT_SPHERES_HEIGHT
108
+ private var distanceBetweenCircles = DEFAULT_DISTANCE_BETWEEN_CIRCLES
109
+ private var circleInFocus = 0 // 0 => lower circle, 1 => upper circle
110
+ private var dragSpeed = DEFAULT_DRAG_SPEED
111
+
112
+ // Gravity vector
113
+ private var gravityVector = mutableMapOf<String, Double>()
114
+
115
+ // Gesture detectors for custom pan/pinch (replicating Swift approach)
35
116
  private var gestureDetector: GestureDetector
36
117
  private var scaleDetector: ScaleGestureDetector
37
- private var sphereRadius = 0.004f
38
- private var sphereCircleRadius = 0.13f
39
- private var dragSpeed = 0.005f
40
118
 
119
+ // ------------------------------------------------------------------------
120
+ // Init & Setup
121
+ // ------------------------------------------------------------------------
41
122
  init {
42
- setupArFragment()
43
123
  requestCameraPermission()
124
+ setupArFragment()
125
+
44
126
  gestureDetector = GestureDetector(context, GestureListener())
45
127
  scaleDetector = ScaleGestureDetector(context, ScaleListener())
128
+
129
+ // Register a gravity sensor to replicate iOS's CMMotionManager
130
+ val sensorManager = context.getSystemService(Context.SENSOR_SERVICE)
131
+ as? android.hardware.SensorManager
132
+ sensorManager?.getDefaultSensor(android.hardware.Sensor.TYPE_GRAVITY)?.also { gravitySensor ->
133
+ sensorManager.registerListener(this, gravitySensor, android.hardware.SensorManager.SENSOR_DELAY_GAME)
134
+ }
46
135
  }
47
136
 
137
+ /**
138
+ * If camera permission not granted, request at runtime (for AR use).
139
+ */
140
+ private fun requestCameraPermission() {
141
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
142
+ != PackageManager.PERMISSION_GRANTED) {
143
+ ActivityCompat.requestPermissions(
144
+ context as Activity,
145
+ arrayOf(Manifest.permission.CAMERA),
146
+ 0
147
+ )
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Programmatically attach an ArFragment into this FrameLayout.
153
+ * We also add ourselves as a Scene update listener.
154
+ */
48
155
  @RequiresApi(Build.VERSION_CODES.N)
49
156
  private fun setupArFragment() {
50
157
  arFragment = ArFragment()
51
- val fragmentManager = (context as FragmentActivity).supportFragmentManager
52
- fragmentManager.beginTransaction().replace(this.id, arFragment).commit()
158
+ val fm = (context as FragmentActivity).supportFragmentManager
53
159
 
54
- arFragment.arSceneView.scene.addOnUpdateListener { frameTime ->
55
- arFragment.onUpdate(frameTime)
56
- onUpdateFrame()
57
- }
160
+ fm.beginTransaction().replace(this.id, arFragment).commitAllowingStateLoss()
161
+ // Add an update listener
162
+ arFragment.arSceneView.scene.addOnUpdateListener(this)
58
163
 
59
- arFragment.setOnTapArPlaneListener { hitResult, plane, motionEvent ->
164
+ // Listen for plane taps
165
+ arFragment.setOnTapArPlaneListener { hitResult: HitResult, plane: Plane, motionEvent: MotionEvent ->
60
166
  if (anchorNode == null) {
61
167
  anchorNode = createAnchorNode(hitResult)
62
- createSpheres()
168
+ createSpheresAtY(spheresHeight) // Lower circle
169
+ createSpheresAtY(distanceBetweenCircles + spheresHeight) // Upper circle
170
+ createFocusSphere()
63
171
  }
64
172
  }
65
173
  }
66
174
 
175
+ /**
176
+ * Creates and returns an AnchorNode from a tap HitResult.
177
+ */
67
178
  private fun createAnchorNode(hitResult: HitResult): AnchorNode {
68
- val anchor = hitResult.createAnchor()
69
- val anchorNode = AnchorNode(anchor)
70
- anchorNode.setParent(arFragment.arSceneView.scene)
71
- return anchorNode
179
+ val anchor: Anchor = hitResult.createAnchor()
180
+ return AnchorNode(anchor).also { node ->
181
+ node.setParent(arFragment.arSceneView.scene)
182
+ }
183
+ }
184
+
185
+ // ------------------------------------------------------------------------
186
+ // Scene OnUpdateListener => replicates Swift ARSessionDelegate
187
+ // ------------------------------------------------------------------------
188
+ override fun onUpdate(frameTime: FrameTime?) {
189
+ val frame = arFragment.arSceneView.arFrame ?: return
190
+ if (frame.camera.trackingState != TrackingState.TRACKING) return
191
+
192
+ // If you need per-frame logic, do it here.
193
+ // Example: check lighting, or do real-time distance checks, etc.
72
194
  }
73
195
 
196
+ // ------------------------------------------------------------------------
197
+ // Spheres Logic
198
+ // ------------------------------------------------------------------------
74
199
  @RequiresApi(Build.VERSION_CODES.N)
75
- private fun createSpheres() {
76
- for (i in 0 until 72) {
77
- val angle = Math.toRadians((i * 5).toDouble()).toFloat()
78
- val x = sphereCircleRadius * Math.cos(angle.toDouble()).toFloat()
79
- val z = sphereCircleRadius * Math.sin(angle.toDouble()).toFloat()
80
- createSphere(Vector3(x, 0.10f, z))
200
+ private fun createSpheresAtY(y: Float) {
201
+ for (i in 0 until TOTAL_SPHERES_PER_CIRCLE) {
202
+ val angleRad = Math.toRadians((i * ANGLE_INCREMENT_DEGREES).toDouble()).toFloat()
203
+ val x = spheresRadius * cos(angleRad.toDouble()).toFloat()
204
+ val z = spheresRadius * sin(angleRad.toDouble()).toFloat()
205
+ createSphere(Vector3(x, y, z))
81
206
  }
82
207
  }
83
208
 
84
209
  @RequiresApi(Build.VERSION_CODES.N)
85
210
  private fun createSphere(position: Vector3) {
86
211
  MaterialFactory.makeOpaqueWithColor(context, Color(android.graphics.Color.WHITE))
87
- .thenAccept { material ->
88
- val sphere = ShapeFactory.makeSphere(sphereRadius, position, material)
89
- val node = TransformableNode(arFragment.transformationSystem)
90
- node.renderable = sphere
91
- node.setParent(anchorNode)
92
- spheres.add(node)
212
+ .thenAccept { material: Material ->
213
+ val sphereRenderable = ShapeFactory.makeSphere(sphereRadius, Vector3.zero(), material)
214
+ val sphereNode = TransformableNode(arFragment.transformationSystem)
215
+ sphereNode.renderable = sphereRenderable
216
+ sphereNode.worldPosition = position
217
+ sphereNode.setParent(anchorNode)
218
+ spheresModels.add(sphereNode)
93
219
  }
94
220
  }
95
221
 
96
- private fun onUpdateFrame() {
97
- // Handle frame updates
222
+ @RequiresApi(Build.VERSION_CODES.N)
223
+ private fun createFocusSphere() {
224
+ // Parent node for the two green spheres + optional overlay
225
+ val parentNode = Node().apply { setParent(anchorNode) }
226
+ focusNode = parentNode
227
+
228
+ // Lower focus sphere
229
+ MaterialFactory.makeOpaqueWithColor(context, Color(0f, 1f, 0f))
230
+ .thenAccept { mat ->
231
+ val r = sphereRadius * 1.5f
232
+ val renderable = ShapeFactory.makeSphere(r, Vector3.zero(), mat)
233
+ val sphereN = Node()
234
+ sphereN.worldPosition = Vector3(0f, spheresHeight, 0f)
235
+ sphereN.renderable = renderable
236
+ parentNode.addChild(sphereN)
237
+ }
238
+
239
+ // Upper focus sphere
240
+ MaterialFactory.makeOpaqueWithColor(context, Color(0f, 1f, 0f))
241
+ .thenAccept { mat ->
242
+ val r = sphereRadius * 1.5f
243
+ val renderable = ShapeFactory.makeSphere(r, Vector3.zero(), mat)
244
+ val sphereN = Node()
245
+ sphereN.worldPosition = Vector3(0f, spheresHeight + distanceBetweenCircles, 0f)
246
+ sphereN.renderable = renderable
247
+ parentNode.addChild(sphereN)
248
+ }
249
+
250
+ // If you have a custom "center.obj", load via ModelRenderable builder or Filament loader
251
+ // parentNode.addChild(loadedCustomOverlayNode)
98
252
  }
99
253
 
254
+ // ------------------------------------------------------------------------
255
+ // Gestures (Mirror Swift's tap, pan, pinch)
256
+ // ------------------------------------------------------------------------
100
257
  override fun onTouchEvent(event: MotionEvent): Boolean {
101
- return scaleDetector.onTouchEvent(event) || gestureDetector.onTouchEvent(event)
258
+ scaleDetector.onTouchEvent(event)
259
+ gestureDetector.onTouchEvent(event)
260
+ return true
102
261
  }
103
262
 
104
263
  private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
105
- override fun onSingleTapUp(event: MotionEvent): Boolean {
264
+
265
+ override fun onSingleTapUp(e: MotionEvent): Boolean {
266
+ Log.d(TAG, "Single tap up at x=${e.x}, y=${e.y}")
267
+ // If you want custom logic (like Swift's "viewTapped"), put it here
106
268
  return true
107
269
  }
108
270
 
@@ -112,105 +274,197 @@ class ReplateCameraView @JvmOverloads constructor(
112
274
  distanceX: Float,
113
275
  distanceY: Float
114
276
  ): Boolean {
115
- // Assuming you have a reference to the AR scene view and the anchor entity
116
277
  val sceneView = arFragment.arSceneView
117
- val anchorEntity = anchorNode
118
- anchorEntity?.let {
119
- // Get the camera's transformation matrix
120
- val cameraTransform = sceneView.scene.camera.worldPosition
121
-
122
- if (e2.action == MotionEvent.ACTION_MOVE) {
123
- // Calculate the translation
124
- val translationX = -distanceX
125
- val translationY = -distanceY
126
-
127
- // Extract forward and right vectors from the camera transform matrix
128
- val forward = Vector3(
129
- -cameraTransform.x,
130
- 0f,
131
- -cameraTransform.z
132
- ) // Assuming Y is up
133
- val right = Vector3(
134
- cameraTransform.x,
135
- 0f,
136
- cameraTransform.z
137
- ) // Assuming Y is up
138
-
139
- // Normalize the vectors
140
- val forwardNormalized = normalize(forward)
141
- val rightNormalized = normalize(right)
142
-
143
- // Calculate the adjusted movement based on user input and camera orientation
144
- val adjustedMovement = Vector3(
145
- translationX * rightNormalized.x + translationY * forwardNormalized.x,
146
- 0f, // Assuming you want to keep the movement in the horizontal plane
147
- -translationX * rightNormalized.z - translationY * forwardNormalized.z // Invert the z movement
148
- )
149
-
150
- // Update the position of the anchor entity
151
- val initialPosition = anchorEntity.worldPosition
152
- anchorEntity.worldPosition = Vector3.add(
153
- initialPosition,
154
- adjustedMovement.scaled(1f / dragSpeed)
155
- )
156
-
157
- // Reset translation
158
- e2.setLocation(0f, 0f)
159
- }
160
- }
278
+ val currentAnchor = anchorNode ?: return true
279
+
280
+ // Replicate Swift handlePan => anchorNode position changes based on camera orientation
281
+ val camera = sceneView.scene.camera
282
+ if (e2.action == MotionEvent.ACTION_MOVE && camera != null) {
283
+ val translationX = -distanceX
284
+ val translationY = -distanceY
285
+
286
+ // Sceneform camera: forward is -Z, right is +X
287
+ val cameraForward = Vector3(camera.forward.x, 0f, camera.forward.z)
288
+ val cameraRight = Vector3(camera.right.x, 0f, camera.right.z)
161
289
 
290
+ val forwardNorm = normalize(cameraForward)
291
+ val rightNorm = normalize(cameraRight)
292
+
293
+ // Similar to Swift: anchorEntity.position = initialPosition + adjustedMovement / dragSpeed
294
+ val adjustedMovement = Vector3(
295
+ translationX * rightNorm.x + translationY * forwardNorm.x,
296
+ 0f,
297
+ translationX * rightNorm.z + translationY * forwardNorm.z
298
+ ).scaled(1f / dragSpeed)
299
+
300
+ val initialPos = currentAnchor.worldPosition
301
+ currentAnchor.worldPosition = Vector3.add(initialPos, adjustedMovement)
302
+ }
162
303
  return true
163
304
  }
164
305
 
165
- private fun normalize(vector: Vector3): Vector3 {
166
- val length = sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z)
167
- return Vector3(vector.x / length, vector.y / length, vector.z / length)
306
+ private fun normalize(v: Vector3): Vector3 {
307
+ val length = sqrt(v.x * v.x + v.y * v.y + v.z * v.z)
308
+ return if (length > 0.0001f) {
309
+ Vector3(v.x / length, v.y / length, v.z / length)
310
+ } else {
311
+ Vector3.zero()
312
+ }
168
313
  }
169
314
  }
170
315
 
171
316
  private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
172
317
  override fun onScale(detector: ScaleGestureDetector): Boolean {
173
- // Ensure ARCore is set up properly
174
- if (anchorNode == null) return false
175
-
176
- // Calculate the scale factor from the detector
318
+ // Like Swift handlePinch => remove child spheres, scale, recreate
319
+ val currentAnchor = anchorNode ?: return false
177
320
  val scale = detector.scaleFactor
178
321
 
179
- // Remove all spheres from the anchor node
180
- arFragment.arSceneView.scene.addOnUpdateListener {
181
- spheres.forEach { sphere ->
182
- anchorNode?.removeChild(sphere)
322
+ // Remove all existing spheres
323
+ spheresModels.forEach { node -> currentAnchor.removeChild(node) }
324
+ spheresModels.clear()
325
+
326
+ // Remove focus
327
+ focusNode?.let { currentAnchor.removeChild(it) }
328
+ focusNode = null
329
+
330
+ // Scale the radii
331
+ sphereRadius *= scale
332
+ spheresRadius *= scale
333
+ sphereAngle *= scale // if also scaling the angle
334
+ // Rebuild
335
+ createSpheresAtY(spheresHeight)
336
+ createSpheresAtY(distanceBetweenCircles + spheresHeight)
337
+ createFocusSphere()
338
+
339
+ return true
340
+ }
341
+ }
342
+
343
+ // ------------------------------------------------------------------------
344
+ // Photo Capture Implementation (PixelCopy)
345
+ // ------------------------------------------------------------------------
346
+ /**
347
+ * Takes a screenshot of the ArSceneView and embeds the current gravityVector
348
+ * as a JSON string in the EXIF “UserComment” field.
349
+ *
350
+ * @param onPhotoSaved Callback that gives you the Uri of the saved file (or null on failure).
351
+ */
352
+ fun takePhoto(onPhotoSaved: (Uri?) -> Unit = {}) {
353
+ val sceneView: ArSceneView = arFragment.arSceneView
354
+ val bitmap = Bitmap.createBitmap(sceneView.width, sceneView.height, Bitmap.Config.ARGB_8888)
355
+
356
+ PixelCopy.request(sceneView, bitmap, { copyResult ->
357
+ if (copyResult == PixelCopy.SUCCESS) {
358
+ // Save + embed EXIF
359
+ val savedUri = saveBitmapWithExif(bitmap, gravityVector)
360
+ if (savedUri != null) {
361
+ totalPhotosTaken += 1
362
+ Log.d(TAG, "Photo saved to: $savedUri")
363
+ onPhotoSaved(savedUri)
364
+ } else {
365
+ Log.e(TAG, "Failed to save photo.")
366
+ onPhotoSaved(null)
183
367
  }
184
- spheres.clear()
368
+ } else {
369
+ Log.e(TAG, "PixelCopy failed with code $copyResult")
370
+ onPhotoSaved(null)
371
+ }
372
+ }, Handler(Looper.getMainLooper()))
373
+ }
185
374
 
186
- // Update the scales for the spheres
187
- sphereRadius *= scale
188
- sphereCircleRadius *= scale
189
- dragSpeed *= scale
375
+ /**
376
+ * Save the [bitmap] into JPEG. Then embed an EXIF “UserComment” that includes the gravity vector.
377
+ */
378
+ private fun saveBitmapWithExif(
379
+ bitmap: Bitmap,
380
+ gravity: Map<String, Double>
381
+ ): Uri? {
382
+ val picturesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: return null
383
+ val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
384
+ val file = File(picturesDir, "AR_Capture_$timeStamp.jpg")
190
385
 
191
- // Recreate spheres (similar to your Swift logic)
192
- createSpheres()
386
+ return try {
387
+ // Write JPEG
388
+ FileOutputStream(file).use { out ->
389
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
390
+ out.flush()
193
391
  }
194
392
 
195
- return true
393
+ // Now embed EXIF metadata
394
+ val exif = ExifInterface(file.absolutePath)
395
+ // Convert gravity vector to JSON-like string
396
+ val gx = gravity["x"] ?: 0.0
397
+ val gy = gravity["y"] ?: 0.0
398
+ val gz = gravity["z"] ?: 0.0
399
+
400
+ // Example JSON
401
+ // { "gravity": { "x":0.123, "y":-0.456, "z":9.81 } }
402
+ val gravityJson = """{ "gravity": { "x": $gx, "y": $gy, "z": $gz } }"""
403
+
404
+ exif.setAttribute(ExifInterface.TAG_USER_COMMENT, gravityJson)
405
+ // Save EXIF
406
+ exif.saveAttributes()
407
+
408
+ Uri.fromFile(file)
409
+ } catch (e: Exception) {
410
+ Log.e(TAG, "Error saving or updating EXIF: ${e.message}")
411
+ null
196
412
  }
197
413
  }
198
414
 
199
- private fun requestCameraPermission() {
200
- if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
201
- != PackageManager.PERMISSION_GRANTED
202
- ) {
203
- ActivityCompat.requestPermissions(context as Activity, arrayOf(Manifest.permission.CAMERA), 0)
415
+ // ------------------------------------------------------------------------
416
+ // Gravity Sensor => Replicates CMMotionManager
417
+ // ------------------------------------------------------------------------
418
+ override fun onSensorChanged(event: android.hardware.SensorEvent?) {
419
+ if (event?.sensor?.type == android.hardware.Sensor.TYPE_GRAVITY) {
420
+ gravityVector["x"] = event.values[0].toDouble()
421
+ gravityVector["y"] = event.values[1].toDouble()
422
+ gravityVector["z"] = event.values[2].toDouble()
423
+ Log.d(TAG, "Gravity vector => x=${gravityVector["x"]}, y=${gravityVector["y"]}, z=${gravityVector["z"]}")
204
424
  }
205
425
  }
206
426
 
207
- fun onRequestPermissionsResult(
208
- requestCode: Int, permissions: Array<out String>, grantResults: IntArray
209
- ) {
210
- if (requestCode == 0 && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
211
- // Permission granted
212
- } else {
213
- // Permission denied
427
+ override fun onAccuracyChanged(sensor: android.hardware.Sensor?, accuracy: Int) {
428
+ // Not used
429
+ }
430
+
431
+ // ------------------------------------------------------------------------
432
+ // Reset Logic
433
+ // ------------------------------------------------------------------------
434
+ /**
435
+ * Resets the AR session, removing anchors and restoring default geometry.
436
+ */
437
+ fun resetSession() {
438
+ anchorNode?.let { node ->
439
+ node.children.forEach { child ->
440
+ node.removeChild(child)
441
+ }
442
+ arFragment.arSceneView.scene.removeChild(node)
443
+ }
444
+ anchorNode = null
445
+ spheresModels.clear()
446
+ focusNode = null
447
+
448
+ // Reset booleans
449
+ for (i in upperSpheresSet.indices) {
450
+ upperSpheresSet[i] = false
451
+ lowerSpheresSet[i] = false
214
452
  }
453
+ totalPhotosTaken = 0
454
+ photosFromDifferentAnglesTaken = 0
455
+
456
+ // Reset geometry
457
+ sphereRadius = DEFAULT_SPHERE_RADIUS
458
+ spheresRadius = DEFAULT_SPHERES_RADIUS
459
+ sphereAngle = ANGLE_INCREMENT_DEGREES
460
+ spheresHeight = DEFAULT_SPHERES_HEIGHT
461
+ distanceBetweenCircles = DEFAULT_DISTANCE_BETWEEN_CIRCLES
462
+ circleInFocus = 0
463
+ dragSpeed = DEFAULT_DRAG_SPEED
464
+
465
+ // If you want to fully re-init the AR session, you could do:
466
+ // arFragment.arSceneView.session?.close()
467
+ // setupArFragment()
468
+ // But that may require re-attaching the fragment, so it depends on your usage.
215
469
  }
216
470
  }
@@ -8,11 +8,11 @@ import com.facebook.react.uimanager.SimpleViewManager
8
8
  import com.facebook.react.uimanager.ThemedReactContext
9
9
  import com.facebook.react.uimanager.annotations.ReactProp
10
10
 
11
- class ReplateCameraViewManager : SimpleViewManager<View>() {
11
+ class ReplateCameraViewManager : SimpleViewManager<ReplateCameraView>() {
12
12
  override fun getName() = "ReplateCameraView"
13
13
 
14
14
  @RequiresApi(Build.VERSION_CODES.N)
15
- override fun createViewInstance(reactContext: ThemedReactContext): View {
15
+ override fun createViewInstance(reactContext: ThemedReactContext): ReplateCameraView {
16
16
  return ReplateCameraView(reactContext)
17
17
  }
18
18
 
@@ -20,4 +20,5 @@ class ReplateCameraViewManager : SimpleViewManager<View>() {
20
20
  fun setColor(view: View, color: String) {
21
21
  view.setBackgroundColor(Color.parseColor(color))
22
22
  }
23
+
23
24
  }
@@ -594,7 +594,7 @@ class ReplateCameraView: UIView, ARSessionDelegate {
594
594
  print("Error when sending feedback")
595
595
  }
596
596
  }
597
-
597
+
598
598
  func startDeviceMotionUpdates() {
599
599
  ReplateCameraView.motionManager.deviceMotionUpdateInterval = 0.1 // Update interval in seconds
600
600
  ReplateCameraView.motionManager.startDeviceMotionUpdates(to: .main) { (deviceMotion, error) in
@@ -704,10 +704,11 @@ class ReplateCameraController: NSObject {
704
704
  private static let arQueue = DispatchQueue(label: "com.replate.ar.controller", qos: .userInteractive)
705
705
 
706
706
  // Configuration Constants
707
- private static let MIN_DISTANCE: Float = 0.15
708
- private static let MAX_DISTANCE: Float = 0.65
707
+ private static let MIN_DISTANCE: Float = 0.25
708
+ private static let MAX_DISTANCE: Float = 0.55
709
709
  private static let ANGLE_THRESHOLD: Float = 0.6
710
- private static let TARGET_IMAGE_SIZE = CGSize(width: 2048, height: 1556)
710
+ // private static let TARGET_IMAGE_SIZE = CGSize(width: 2048, height: 1556)
711
+ private static let TARGET_IMAGE_SIZE = CGSize(width: 3072, height: 2304)
711
712
  private static let MIN_AMBIENT_INTENSITY: CGFloat = 650
712
713
 
713
714
  // Callbacks
@@ -909,12 +910,12 @@ class ReplateCameraController: NSObject {
909
910
  callbackHandler.reject(.notInRange)
910
911
  return
911
912
  }
912
-
913
+
913
914
  guard let frame = ReplateCameraView.arView?.session.currentFrame else {
914
915
  callbackHandler.reject(.captureError)
915
916
  return
916
917
  }
917
-
918
+
918
919
  // Check lighting conditions
919
920
  if let lightEstimate = frame.lightEstimate {
920
921
  guard lightEstimate.ambientIntensity >= Self.MIN_AMBIENT_INTENSITY else {
@@ -922,7 +923,7 @@ class ReplateCameraController: NSObject {
922
923
  return
923
924
  }
924
925
  }
925
-
926
+
926
927
  self.updateSpheres(
927
928
  deviceTargetInfo: deviceTargetInfo,
928
929
  cameraTransform: deviceTargetInfo.transform
@@ -968,16 +969,16 @@ class ReplateCameraController: NSObject {
968
969
 
969
970
  private func processAndSaveImage(_ pixelBuffer: CVPixelBuffer, callbackHandler: SafeCallbackHandler) {
970
971
  let ciImage = CIImage(cvImageBuffer: pixelBuffer)
971
-
972
+
972
973
  guard let resizedImage = resizeImage(ciImage, to: Self.TARGET_IMAGE_SIZE),
973
974
  let cgImage = cgImage(from: resizedImage) else {
974
975
  callbackHandler.reject(.processingError)
975
976
  return
976
977
  }
977
-
978
+
978
979
  let uiImage = UIImage(cgImage: cgImage)
979
980
  let rotatedImage = uiImage.rotate(radians: .pi / 2)
980
-
981
+
981
982
  // Save the image in the background
982
983
  DispatchQueue.global(qos: .userInitiated).async {
983
984
  guard let savedURL = self.saveImageAsPNG(rotatedImage) else {
@@ -986,7 +987,7 @@ class ReplateCameraController: NSObject {
986
987
  }
987
988
  return
988
989
  }
989
-
990
+
990
991
  // Call the callback on the main thread
991
992
  DispatchQueue.main.async {
992
993
  callbackHandler.resolve(savedURL.absoluteString)
@@ -1337,30 +1338,30 @@ class ReplateCameraController: NSObject {
1337
1338
 
1338
1339
  return fileURL
1339
1340
  }
1340
-
1341
+
1341
1342
  func saveImageAsPNG(_ image: UIImage) -> URL? {
1342
1343
  // Convert UIImage to PNG data
1343
1344
  guard let imageData = image.pngData(),
1344
1345
  let source = CGImageSourceCreateWithData(imageData as CFData, nil) else {
1345
1346
  return nil
1346
1347
  }
1347
-
1348
+
1348
1349
  // Define temporary file URL
1349
1350
  let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
1350
1351
  let uniqueFilename = "image_\(Date().timeIntervalSince1970).png"
1351
1352
  let fileURL = temporaryDirectoryURL.appendingPathComponent(uniqueFilename)
1352
-
1353
+
1353
1354
  // Retrieve existing image properties
1354
1355
  guard let imageProperties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
1355
1356
  return nil
1356
1357
  }
1357
-
1358
+
1358
1359
  // Add metadata (including a user comment with transform JSON)
1359
1360
  var mutableMetadata = imageProperties
1360
1361
  mutableMetadata[kCGImagePropertyPNGDictionary] = [
1361
1362
  kCGImagePropertyPNGComment: getTransformJSON(session: ReplateCameraView.arView.session)
1362
1363
  ]
1363
-
1364
+
1364
1365
  // Create destination for PNG file
1365
1366
  guard let destination = CGImageDestinationCreateWithURL(
1366
1367
  fileURL as CFURL,
@@ -1370,7 +1371,7 @@ class ReplateCameraController: NSObject {
1370
1371
  ) else {
1371
1372
  return nil
1372
1373
  }
1373
-
1374
+
1374
1375
  // Add image with metadata to destination
1375
1376
  CGImageDestinationAddImageFromSource(
1376
1377
  destination,
@@ -1378,12 +1379,12 @@ class ReplateCameraController: NSObject {
1378
1379
  0,
1379
1380
  mutableMetadata as CFDictionary
1380
1381
  )
1381
-
1382
+
1382
1383
  // Finalize image creation
1383
1384
  guard CGImageDestinationFinalize(destination) else {
1384
1385
  return nil
1385
1386
  }
1386
-
1387
+
1387
1388
  return fileURL
1388
1389
  }
1389
1390
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replate-camera",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Camera component for Replate Manager",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",