Practice and reinforce the concepts from Lesson 7
Create an AR app that allows users to place 3D furniture models on detected surfaces, move and rotate them interactively, and visualize how products would look in their real space. This activity teaches hit testing, anchor creation, and 3D model manipulation.
An AR furniture placement app where users can tap on surfaces to place virtual furniture, drag to reposition objects, pinch to scale, and rotate with two-finger gestures. Objects stay anchored to surfaces as the camera moves around.

Create a utility to load and manage 3D models:
// utils/ModelLoader.js
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import * as THREE from 'three';
export class ModelLoader {
constructor() {
this.loader = new GLTFLoader();
this.cache = new Map();
}
async loadModel(path) {
// Check cache first
if (this.cache.has(path)) {
return this.cache.get(path).clone();
}
return new Promise((resolve, reject) => {
this.loader.load(
path,
(gltf) => {
const model = gltf.scene;
// Normalize model size
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1.0 / maxDim;
model.scale.multiplyScalar(scale);
// Center model
box.setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
// Cache original model
this.cache.set(path, model);
// Return a clone
resolve(model.clone());
},
(progress) => {
console.log(`Loading: ${(progress.loaded / progress.total * 100).toFixed(0)}%`);
},
(error) => {
console.error('Model loading error:', error);
reject(error);
}
);
});
}
createFallbackModel(type = 'chair') {
// Create simple geometric fallback if model fails to load
const group = new THREE.Group();
switch (type) {
case 'chair':
// Simple chair made of boxes
const seat = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.05, 0.4),
new THREE.MeshStandardMaterial({ color: 0x8B4513 })
);
seat.position.y = 0.4;
const back = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.5, 0.05),
new THREE.MeshStandardMaterial({ color: 0x8B4513 })
);
back.position.y = 0.65;
back.position.z = -0.175;
group.add(seat, back);
break;
case 'table':
const top = new THREE.Mesh(
new THREE.BoxGeometry(1.0, 0.05, 0.6),
new THREE.MeshStandardMaterial({ color: 0x654321 })
);
top.position.y = 0.7;
group.add(top);
break;
default:
const cube = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.3, 0.3),
new THREE.MeshStandardMaterial({ color: 0x4ECDC4 })
);
group.add(cube);
}
return group;
}
}
Create hit testing to find where user taps intersect surfaces:
// utils/HitTesting.js
import * as THREE from 'three';
export class HitTester {
constructor(camera, scene) {
this.raycaster = new THREE.Raycaster();
this.camera = camera;
this.scene = scene;
}
hitTest(screenX, screenY, gl) {
// Convert screen coordinates to normalized device coordinates
const x = (screenX / gl.drawingBufferWidth) * 2 - 1;
const y = -(screenY / gl.drawingBufferHeight) * 2 + 1;
// Update raycaster
this.raycaster.setFromCamera({ x, y }, this.camera);
// Find intersection with planes
const planes = this.scene.children.filter(
child => child.userData.isPlane
);
const intersects = this.raycaster.intersectObjects(planes, true);
if (intersects.length > 0) {
return {
hit: true,
point: intersects[0].point,
normal: intersects[0].face.normal,
object: intersects[0].object,
};
}
return { hit: false };
}
hitTestObjects(screenX, screenY, gl, objects) {
const x = (screenX / gl.drawingBufferWidth) * 2 - 1;
const y = -(screenY / gl.drawingBufferHeight) * 2 + 1;
this.raycaster.setFromCamera({ x, y }, this.camera);
const intersects = this.raycaster.intersectObjects(objects, true);
if (intersects.length > 0) {
return {
hit: true,
object: intersects[0].object,
point: intersects[0].point,
};
}
return { hit: false };
}
}
💡 Tip: Hit testing is essential for AR interactions. It determines where virtual objects should appear when users tap on real-world surfaces.
Build a system to manage placed objects:
// utils/PlacedObjectManager.js
import * as THREE from 'three';
export class PlacedObject {
constructor(model, position, rotation = 0) {
this.id = Date.now().toString() + Math.random();
this.model = model;
this.anchor = new THREE.Group();
// Add model to anchor
this.anchor.add(model);
this.anchor.position.copy(position);
this.anchor.rotation.y = rotation;
// Add selection indicator
this.createSelectionRing();
this.selected = false;
this.scale = 1.0;
}
createSelectionRing() {
const geometry = new THREE.RingGeometry(0.4, 0.45, 32);
const material = new THREE.MeshBasicMaterial({
color: 0x4ECDC4,
side: THREE.DoubleSide,
transparent: true,
opacity: 0,
});
this.selectionRing = new THREE.Mesh(geometry, material);
this.selectionRing.rotation.x = -Math.PI / 2;
this.selectionRing.position.y = 0.01; // Slightly above ground
this.anchor.add(this.selectionRing);
}
setSelected(selected) {
this.selected = selected;
this.selectionRing.material.opacity = selected ? 0.8 : 0;
}
setPosition(position) {
this.anchor.position.copy(position);
}
setRotation(angle) {
this.anchor.rotation.y = angle;
}
setScale(scale) {
this.scale = scale;
this.model.scale.setScalar(scale);
}
getAnchor() {
return this.anchor;
}
dispose() {
this.anchor.traverse((child) => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose());
} else {
child.material.dispose();
}
}
});
}
}
export class PlacedObjectManager {
constructor(scene) {
this.scene = scene;
this.objects = [];
this.selectedObject = null;
}
addObject(model, position, rotation = 0) {
const placedObj = new PlacedObject(model, position, rotation);
this.objects.push(placedObj);
this.scene.add(placedObj.getAnchor());
return placedObj;
}
removeObject(placedObj) {
const index = this.objects.indexOf(placedObj);
if (index > -1) {
this.objects.splice(index, 1);
this.scene.remove(placedObj.getAnchor());
placedObj.dispose();
}
}
selectObject(placedObj) {
// Deselect previous
if (this.selectedObject) {
this.selectedObject.setSelected(false);
}
// Select new
this.selectedObject = placedObj;
if (placedObj) {
placedObj.setSelected(true);
}
}
getSelectedObject() {
return this.selectedObject;
}
getAllObjects() {
return this.objects;
}
clear() {
this.objects.forEach(obj => {
this.scene.remove(obj.getAnchor());
obj.dispose();
});
this.objects = [];
this.selectedObject = null;
}
}
Implement touch gestures for object manipulation:
// components/GestureHandler.js
import { useEffect, useRef } from 'react';
export const useGestureHandler = (onTap, onDrag, onPinch, onRotate) => {
const touchStart = useRef(null);
const lastTouchDistance = useRef(null);
const lastTouchAngle = useRef(null);
const isDragging = useRef(false);
const getTouchDistance = (touch1, touch2) => {
const dx = touch1.pageX - touch2.pageX;
const dy = touch1.pageY - touch2.pageY;
return Math.sqrt(dx * dx + dy * dy);
};
const getTouchAngle = (touch1, touch2) => {
return Math.atan2(
touch2.pageY - touch1.pageY,
touch2.pageX - touch1.pageX
);
};
const handleTouchStart = (event) => {
const touches = event.nativeEvent.touches;
if (touches.length === 1) {
touchStart.current = {
x: touches[0].pageX,
y: touches[0].pageY,
time: Date.now(),
};
isDragging.current = false;
} else if (touches.length === 2) {
lastTouchDistance.current = getTouchDistance(touches[0], touches[1]);
lastTouchAngle.current = getTouchAngle(touches[0], touches[1]);
}
};
const handleTouchMove = (event) => {
const touches = event.nativeEvent.touches;
if (touches.length === 1 && touchStart.current) {
const deltaX = touches[0].pageX - touchStart.current.x;
const deltaY = touches[0].pageY - touchStart.current.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
isDragging.current = true;
onDrag?.(touches[0].pageX, touches[0].pageY);
}
} else if (touches.length === 2) {
// Pinch to scale
const distance = getTouchDistance(touches[0], touches[1]);
if (lastTouchDistance.current) {
const scale = distance / lastTouchDistance.current;
onPinch?.(scale);
}
lastTouchDistance.current = distance;
// Rotate with two fingers
const angle = getTouchAngle(touches[0], touches[1]);
if (lastTouchAngle.current) {
const deltaAngle = angle - lastTouchAngle.current;
onRotate?.(deltaAngle);
}
lastTouchAngle.current = angle;
}
};
const handleTouchEnd = (event) => {
const touches = event.nativeEvent.touches;
if (touches.length === 0 && touchStart.current) {
const elapsed = Date.now() - touchStart.current.time;
// Detect tap (short touch without drag)
if (elapsed < 200 && !isDragging.current) {
onTap?.(touchStart.current.x, touchStart.current.y);
}
touchStart.current = null;
isDragging.current = false;
}
if (touches.length < 2) {
lastTouchDistance.current = null;
lastTouchAngle.current = null;
}
};
return {
handleTouchStart,
handleTouchMove,
handleTouchEnd,
};
};
Build a UI for selecting furniture to place:
// components/FurnitureCatalog.js
import React from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
StyleSheet,
} from 'react-native';
const furniture = [
{ id: 'chair', name: 'Chair', icon: '🪑', model: 'chair.glb' },
{ id: 'table', name: 'Table', icon: '🪑', model: 'table.glb' },
{ id: 'lamp', name: 'Lamp', icon: '💡', model: 'lamp.glb' },
{ id: 'sofa', name: 'Sofa', icon: '🛋️', model: 'sofa.glb' },
{ id: 'plant', name: 'Plant', icon: '🪴', model: 'plant.glb' },
{ id: 'shelf', name: 'Shelf', icon: '📚', model: 'shelf.glb' },
];
const FurnitureCatalog = ({ selectedId, onSelect, visible }) => {
if (!visible) return null;
return (
<View style={styles.container}>
<Text style={styles.title}>Select Furniture</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{furniture.map((item) => (
<TouchableOpacity
key={item.id}
style={[
styles.item,
selectedId === item.id && styles.itemSelected
]}
onPress={() => onSelect(item)}
>
<Text style={styles.icon}>{item.icon}</Text>
<Text style={styles.name}>{item.name}</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 100,
left: 0,
right: 0,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingVertical: 15,
shadowColor: '#000',
shadowOffset: { width: 0, height: -3 },
shadowOpacity: 0.1,
shadowRadius: 5,
elevation: 10,
},
title: {
fontSize: 18,
fontWeight: 'bold',
marginLeft: 20,
marginBottom: 10,
},
scrollContent: {
paddingHorizontal: 15,
gap: 10,
},
item: {
backgroundColor: 'white',
borderRadius: 15,
padding: 15,
alignItems: 'center',
minWidth: 80,
borderWidth: 2,
borderColor: 'transparent',
},
itemSelected: {
borderColor: '#4ECDC4',
backgroundColor: '#E8F9F7',
},
icon: {
fontSize: 36,
marginBottom: 8,
},
name: {
fontSize: 12,
fontWeight: '600',
},
});
export default FurnitureCatalog;
Combine all components in the main app:
// App.js
import React, { useState, useRef } from 'react';
import { StyleSheet, View, TouchableOpacity, Text, Alert } from 'react-native';
import ARCamera from './components/ARCamera';
import FurnitureCatalog from './components/FurnitureCatalog';
import { useGestureHandler } from './components/GestureHandler';
import { ModelLoader } from './utils/ModelLoader';
import { HitTester } from './utils/HitTesting';
import { PlacedObjectManager } from './utils/PlacedObjectManager';
export default function App() {
const [selectedFurniture, setSelectedFurniture] = useState(null);
const [showCatalog, setShowCatalog] = useState(true);
const [placedCount, setPlacedCount] = useState(0);
const modelLoader = useRef(new ModelLoader());
const hitTester = useRef(null);
const objectManager = useRef(null);
const glRef = useRef(null);
const cameraRef = useRef(null);
const handleTap = async (x, y) => {
if (!hitTester.current || !selectedFurniture) return;
// Hit test against planes
const result = hitTester.current.hitTest(x, y, glRef.current);
if (result.hit) {
try {
// Load model (use fallback if loading fails)
let model;
try {
model = await modelLoader.current.loadModel(
`./assets/models/${selectedFurniture.model}`
);
} catch (error) {
console.log('Using fallback model');
model = modelLoader.current.createFallbackModel(selectedFurniture.id);
}
// Place object
objectManager.current.addObject(model, result.point);
setPlacedCount(prev => prev + 1);
} catch (error) {
Alert.alert('Error', 'Could not place object');
}
}
};
const handleDrag = (x, y) => {
const selected = objectManager.current?.getSelectedObject();
if (!selected || !hitTester.current) return;
const result = hitTester.current.hitTest(x, y, glRef.current);
if (result.hit) {
selected.setPosition(result.point);
}
};
const handlePinch = (scale) => {
const selected = objectManager.current?.getSelectedObject();
if (!selected) return;
const newScale = selected.scale * scale;
if (newScale > 0.5 && newScale < 3.0) {
selected.setScale(newScale);
}
};
const handleRotate = (deltaAngle) => {
const selected = objectManager.current?.getSelectedObject();
if (!selected) return;
const currentRotation = selected.anchor.rotation.y;
selected.setRotation(currentRotation + deltaAngle);
};
const gestures = useGestureHandler(
handleTap,
handleDrag,
handlePinch,
handleRotate
);
const handleClearAll = () => {
Alert.alert(
'Clear All Objects',
'Remove all placed furniture?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: () => {
objectManager.current?.clear();
setPlacedCount(0);
},
},
]
);
};
return (
<View style={styles.container}>
<View
style={styles.arView}
onTouchStart={gestures.handleTouchStart}
onTouchMove={gestures.handleTouchMove}
onTouchEnd={gestures.handleTouchEnd}
>
<ARCamera
onInit={(gl, camera, scene, manager) => {
glRef.current = gl;
cameraRef.current = camera;
hitTester.current = new HitTester(camera, scene);
objectManager.current = manager;
}}
/>
</View>
<View style={styles.topBar}>
<Text style={styles.counter}>
Objects: {placedCount}
</Text>
<TouchableOpacity
style={styles.clearButton}
onPress={handleClearAll}
>
<Text style={styles.clearText}>Clear All</Text>
</TouchableOpacity>
</View>
<FurnitureCatalog
selectedId={selectedFurniture?.id}
onSelect={(item) => {
setSelectedFurniture(item);
setShowCatalog(false);
}}
visible={showCatalog}
/>
{!showCatalog && (
<TouchableOpacity
style={styles.catalogToggle}
onPress={() => setShowCatalog(true)}
>
<Text style={styles.catalogToggleText}>🪑</Text>
</TouchableOpacity>
)}
{selectedFurniture && !showCatalog && (
<View style={styles.instruction}>
<Text style={styles.instructionText}>
Tap on a surface to place {selectedFurniture.name}
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
arView: {
flex: 1,
},
topBar: {
position: 'absolute',
top: 50,
left: 20,
right: 20,
flexDirection: 'row',
justifyContent: 'space-between',
},
counter: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: 'white',
paddingHorizontal: 15,
paddingVertical: 10,
borderRadius: 20,
fontSize: 16,
fontWeight: 'bold',
},
clearButton: {
backgroundColor: '#FF6B6B',
paddingHorizontal: 15,
paddingVertical: 10,
borderRadius: 20,
},
clearText: {
color: 'white',
fontWeight: 'bold',
},
catalogToggle: {
position: 'absolute',
bottom: 30,
right: 20,
backgroundColor: 'white',
width: 60,
height: 60,
borderRadius: 30,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 5,
},
catalogToggleText: {
fontSize: 28,
},
instruction: {
position: 'absolute',
bottom: 120,
left: 20,
right: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: 15,
borderRadius: 10,
},
instructionText: {
color: 'white',
textAlign: 'center',
fontSize: 14,
},
});
Problem: Objects appear at wrong position or floating Solution: Ensure hit testing returns valid surface points. Check that objects are positioned at the hit point, not camera position. Verify plane detection is working.
Problem: Models don't load Solution: Check that model files are in correct path. Verify GLB/GLTF format is correct. Use fallback geometric models during development.
Problem: Gestures don't work smoothly Solution: Reduce touch event processing frequency. Check that gesture detection thresholds are appropriate. Ensure selected object reference is maintained.
Problem: Objects disappear when camera moves Solution: Verify objects are added to scene, not camera. Check that AR tracking is maintaining coordinate system. Ensure anchors are properly created.
Problem: Memory issues with multiple objects Solution: Dispose of geometries and materials when removing objects. Limit total number of objects. Use instancing for repeated models.
For advanced students:
Physics Simulation: Add simple physics so objects fall onto surfaces and can collide with each other
Measurements Tool: Show real-world dimensions of placed objects in meters/feet
Save/Load Layouts: Persist object placements and restore them when app reopens
Shadows: Add realistic shadows cast by virtual objects onto real surfaces
Object Snapping: Snap objects to grid or align edges when placing near other objects
In this activity, you:
In the next lesson, you'll explore AR interaction patterns including multiuser AR, occlusion handling, and advanced gesture recognition for more natural AR experiences.