· 7 months ago · Mar 12, 2025, 05:25 AM
1package io.github.chayanforyou.arfurniture.ui.screens
2
3import androidx.compose.foundation.layout.Box
4import androidx.compose.foundation.layout.fillMaxSize
5import androidx.compose.runtime.Composable
6import androidx.compose.runtime.LaunchedEffect
7import androidx.compose.runtime.getValue
8import androidx.compose.runtime.mutableStateOf
9import androidx.compose.runtime.remember
10import androidx.compose.runtime.setValue
11import androidx.compose.ui.Modifier
12import com.google.android.filament.Engine
13import com.google.ar.core.CameraConfig
14import com.google.ar.core.CameraConfigFilter
15import com.google.ar.core.Config
16import com.google.ar.core.Config.PlaneFindingMode
17import com.google.ar.core.Frame
18import com.google.ar.core.Pose
19import com.google.ar.core.Session
20import com.google.ar.core.TrackingFailureReason
21import io.github.sceneview.ar.ARScene
22import io.github.sceneview.ar.ARSceneView
23import io.github.sceneview.ar.arcore.createAnchorOrNull
24import io.github.sceneview.ar.arcore.getUpdatedPlanes
25import io.github.sceneview.ar.arcore.position
26import io.github.sceneview.ar.node.AnchorNode
27import io.github.sceneview.math.Position
28import io.github.sceneview.math.Rotation
29import io.github.sceneview.math.Scale
30import io.github.sceneview.node.ModelNode
31import io.github.sceneview.rememberEngine
32import io.github.sceneview.rememberModelLoader
33import io.github.sceneview.rememberNodes
34import io.github.sceneview.rememberOnGestureListener
35import io.github.sceneview.rememberView
36import java.util.EnumSet
37
38@Composable
39internal fun ARView(modelName: String) {
40 // The destroy calls are automatically made when their disposable effect leaves
41 // the composition or its key changes.
42 val engine = rememberEngine()
43 val modelLoader = rememberModelLoader(engine)
44 val childNodes = rememberNodes()
45 val view = rememberView(engine)
46 // Temporary workaround to prevent crashes upon destruction of ARSceneView.
47 // Check https://github.com/SceneView/sceneview-android/issues/450 for more details.
48 val cameraNode = remember { ARSceneView.createARCameraNode(engine) }
49
50 // Currently loaded model
51 var modelNode by remember { mutableStateOf<ModelNode?>(null) }
52
53 // Whether to show detected plane or not
54 var planeRenderer by remember { mutableStateOf(true) }
55
56 // Reason for plane detection failure
57 var trackingFailureReason by remember {
58 mutableStateOf<TrackingFailureReason?>(null)
59 }
60 var frame by remember { mutableStateOf<Frame?>(null) }
61
62 var animateModel by remember { mutableStateOf(false) }
63 var showCoachingOverlay by remember { mutableStateOf(true) }
64 var showGestureOverlay by remember { mutableStateOf(false) }
65
66 LaunchedEffect(key1 = modelName) {
67 val path = "models/sofa.glb"
68
69 modelNode = modelLoader.createModelInstance(path).let { instance ->
70 ModelNode(
71 modelInstance = instance,
72 ).apply {
73 // Change models initial scale. Currently using width
74 scale = Scale(0.3F / size.x)
75 // Reset model's initial rotation
76 rotation = Rotation()
77 // Enable custom gestures on model
78 enableGestures()
79 }
80 }
81 }
82
83 Box(modifier = Modifier.fillMaxSize()) {
84 ARScene(
85 modifier = Modifier.fillMaxSize(),
86 cameraNode = cameraNode,
87 childNodes = childNodes,
88 engine = engine,
89 view = view,
90 modelLoader = modelLoader,
91 onGestureListener = rememberOnGestureListener(
92 onSingleTapConfirmed = { _, _ ->
93 if (animateModel) {
94 animateModel = false
95 showGestureOverlay = true
96 }
97 },
98 onMove = { _, event, node ->
99 if (node == null) return@rememberOnGestureListener
100 // Move gesture to move model node. Instead of changing model position
101 // change anchor node position (parent of model node)
102 frame?.hitTest(event)?.firstOrNull()?.hitPose?.position?.let { position ->
103 val anchor = (node.parent as? AnchorNode) ?: node
104 anchor.worldPosition = position
105 }
106 }
107 ),
108 sessionCameraConfig = { session -> session.disableDepthConfig() },
109 sessionConfiguration = { _, config -> config.configure() },
110 planeRenderer = planeRenderer,
111 onTrackingFailureChanged = {
112 showCoachingOverlay = it != null
113 trackingFailureReason = it
114 },
115 onSessionUpdated = { session, updatedFrame ->
116 frame = updatedFrame
117
118 // If no model is placed then create anchor node and add
119 // model node to that anchor
120 if (childNodes.isEmpty()) {
121 updatedFrame.createCenterAnchorNode(engine)?.let { anchorNode ->
122 modelNode?.let {
123 animateModel = true
124 anchorNode.addChildNode(it)
125 childNodes.add(anchorNode)
126 planeRenderer = false
127 showCoachingOverlay = false
128 }
129 }
130 } else if (animateModel) {
131 (childNodes.firstOrNull() as? AnchorNode)?.apply {
132 val (x, y) = view.viewport.run {
133 width / 2F to height / 2F
134 }
135
136 val newPosition = updatedFrame.getPose(x, y)?.position ?: return@ARScene
137 worldPosition = Position(newPosition.x, newPosition.y, newPosition.z)
138 }
139 }
140 },
141 )
142 }
143}
144
145/**
146 * Disable Depth API
147 * Depth API may give better results. But, some devices (tested on Samsung S20+)
148 * creates too much lag. Reason is unknown. Looks like the image acquired
149 * with `frame.acquireCameraImage()` is not realised. so we won't be able to acquire
150 * new frames when the rate limit is exceeded.
151 */
152private fun Session.disableDepthConfig(): CameraConfig {
153 val filter = CameraConfigFilter(this)
154 filter.setDepthSensorUsage(EnumSet.of(CameraConfig.DepthSensorUsage.DO_NOT_USE))
155 val cameraConfigList = getSupportedCameraConfigs(filter)
156 return cameraConfigList.first()
157}
158
159/**
160 * Configurations for ARSession
161 */
162private fun Config.configure() {
163 depthMode = Config.DepthMode.DISABLED
164 updateMode = Config.UpdateMode.LATEST_CAMERA_IMAGE
165 instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP
166 planeFindingMode = PlaneFindingMode.HORIZONTAL
167 lightEstimationMode = Config.LightEstimationMode.ENVIRONMENTAL_HDR
168}
169
170/**
171 * Create anchor node at the center of the screen
172 */
173private fun Frame.createCenterAnchorNode(engine: Engine): AnchorNode? =
174 getUpdatedPlanes().firstOrNull()
175 ?.let { it.createAnchorOrNull(it.centerPose) }
176 ?.let { anchor ->
177 AnchorNode(engine, anchor).apply {
178 isEditable = false
179 isPositionEditable = false
180 updateAnchorPose = false
181 }
182 }
183
184/**
185 * Get real world position from the given [x] & [y] coordinates.
186 */
187private fun Frame.getPose(x: Float, y: Float): Pose? =
188 hitTest(x, y).firstOrNull()?.hitPose
189
190/**
191 * Enable gestures for receiver [ModelNode].
192 *
193 * The default gestures for scale has exponential effect.
194 * So using custom gesture for scaling is better.
195 */
196private fun ModelNode.enableGestures() {
197 var previousScale = 0F
198 onScale = { _, _, value ->
199 scale = Scale(previousScale * value)
200 false
201 }
202 onScaleBegin = { _, _ ->
203 previousScale = scale.x
204 false
205 }
206 onScaleEnd = { _, _ ->
207 previousScale = 0F
208 }
209
210 // Set rendering priority higher to properly load occlusion.
211 setPriority(7)
212 // Model Node needs to be editable for independent rotation from the anchor rotation
213 isEditable = true
214 isScaleEditable = true
215 isRotationEditable = true
216 editableScaleRange = Float.MIN_VALUE..Float.MAX_VALUE
217}