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.
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/replatecamera/ReplateCameraModule.kt +60 -0
- package/android/src/main/java/com/replatecamera/ReplateCameraPackage.kt +1 -1
- package/android/src/main/java/com/replatecamera/ReplateCameraView.kt +367 -113
- package/android/src/main/java/com/replatecamera/ReplateCameraViewManager.kt +3 -2
- package/ios/ReplateCameraViewManager.swift +20 -19
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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,
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
fragmentManager.beginTransaction().replace(this.id, arFragment).commit()
|
|
158
|
+
val fm = (context as FragmentActivity).supportFragmentManager
|
|
53
159
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
160
|
+
fm.beginTransaction().replace(this.id, arFragment).commitAllowingStateLoss()
|
|
161
|
+
// Add an update listener
|
|
162
|
+
arFragment.arSceneView.scene.addOnUpdateListener(this)
|
|
58
163
|
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
76
|
-
for (i in 0 until
|
|
77
|
-
val
|
|
78
|
-
val x =
|
|
79
|
-
val z =
|
|
80
|
-
createSphere(Vector3(x,
|
|
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
|
|
89
|
-
val
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
258
|
+
scaleDetector.onTouchEvent(event)
|
|
259
|
+
gestureDetector.onTouchEvent(event)
|
|
260
|
+
return true
|
|
102
261
|
}
|
|
103
262
|
|
|
104
263
|
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
|
|
105
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
166
|
-
val length = sqrt(
|
|
167
|
-
return
|
|
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
|
-
//
|
|
174
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
368
|
+
} else {
|
|
369
|
+
Log.e(TAG, "PixelCopy failed with code $copyResult")
|
|
370
|
+
onPhotoSaved(null)
|
|
371
|
+
}
|
|
372
|
+
}, Handler(Looper.getMainLooper()))
|
|
373
|
+
}
|
|
185
374
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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<
|
|
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):
|
|
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.
|
|
708
|
-
private static let MAX_DISTANCE: Float = 0.
|
|
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
|
|