Commit 07a8b1d8 authored by Kang's avatar Kang
Browse files

Add instructions and label to the app

parent de77f40d
...@@ -20,6 +20,52 @@ function createARButton(renderer: WebGLRenderer, overlayRef: React.RefObject<HTM ...@@ -20,6 +20,52 @@ function createARButton(renderer: WebGLRenderer, overlayRef: React.RefObject<HTM
return button; return button;
} }
// Add helpers & config
// ---- Types ----
type LLA = { lat: number; lon: number };
type POI = {
id: string;
name: string;
lat: number;
lon: number;
icon?: string;
category?: string[];
entrance?: { lat: number; lon: number } | null;
waypoints?: { lat: number; lon: number }[];
};
type POIRuntime = {
poi: POI;
mesh: THREE.Object3D; // the label sprite
localXZ: { x: number; z: number }; // meters in local frame
};
// ---- Campus origin + simple constants ----
const ORIGIN_LLA: LLA = { lat: 48.78020, lon: 9.17230 }; // rough campus center
const LABEL_HEIGHT_Y = 2.4;
// ---- Geo helpers ----
const toRad = (d: number) => (d * Math.PI) / 180;
function metersPerDeg(lat0: number) {
const kLat = 110_540; // m/deg
const kLon = 111_320 * Math.cos(toRad(lat0)); // m/deg
return { kLat, kLon };
}
function toLocalMeters(ll: LLA, origin: LLA) {
const { kLat, kLon } = metersPerDeg(origin.lat);
const dLat = ll.lat - origin.lat;
const dLon = ll.lon - origin.lon;
return { x: dLon * kLon, z: dLat * kLat }; // x=east, z=north
}
// Objects for Navigation to the building once user tap the labels
const arrowsRef = useRef<THREE.Object3D[]>([]);
const navigatingRef = useRef<{ active: boolean; pathLLA: LLA[]; targetIndex: number } | null>(null);
const raycaster = useRef(new THREE.Raycaster());
const tapNDC = useRef(new THREE.Vector2());
export const XRRenderer: React.FC = () => { export const XRRenderer: React.FC = () => {
// References to DOM elements (we connect three.js canvas + overlay here) // References to DOM elements (we connect three.js canvas + overlay here)
...@@ -27,6 +73,9 @@ export const XRRenderer: React.FC = () => { ...@@ -27,6 +73,9 @@ export const XRRenderer: React.FC = () => {
const overlayRef = useRef<HTMLDivElement>(null); // the overlay UI inside AR (buttons etc.) const overlayRef = useRef<HTMLDivElement>(null); // the overlay UI inside AR (buttons etc.)
const reticleRef = useRef<THREE.Mesh | null>(null);// reference to the green reticle ring const reticleRef = useRef<THREE.Mesh | null>(null);// reference to the green reticle ring
// 🟡 ADD holds loaded POIs and their label meshes
const poiRuntimeRef = useRef<POIRuntime[]>([]);
// State variables // State variables
const [isSessionActive, setIsSessionActive] = useState(false); // true when AR is running const [isSessionActive, setIsSessionActive] = useState(false); // true when AR is running
const [selectedObject, setSelectedObject] = useState<string>("Cube"); // default object type const [selectedObject, setSelectedObject] = useState<string>("Cube"); // default object type
...@@ -43,52 +92,135 @@ export const XRRenderer: React.FC = () => { ...@@ -43,52 +92,135 @@ export const XRRenderer: React.FC = () => {
const renderer = createRenderer(); const renderer = createRenderer();
addLightToScene(scene); addLightToScene(scene);
mountRef.current.appendChild(renderer.domElement);
mountRef.current.appendChild(renderer.domElement); // attach to our div // env map from RoomEnvironment (reuse same renderer)
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const pmremGenerator = new THREE.PMREMGenerator( new THREE.WebGLRenderer() );
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; scene.environment = pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture;
const button = createARButton(renderer, overlayRef); const button = createARButton(renderer, overlayRef);
mountRef.current.appendChild(button); mountRef.current.appendChild(button);
// --- 4️⃣ Block manager (handles placed 3D objects) --- // --- 4️⃣ Block manager + reticle ---
const blockManager = new BlockManager(scene); const blockManager = new BlockManager(scene);
const reticle = createPlacementRing(scene, reticleRef); const reticle = createPlacementRing(scene, reticleRef);
// --- 6️⃣ WebXR hit-test setup (detect real-world planes) --- // --- Load POIs and create label sprites ---
async function loadPOIsAndCreateLabels() {
const res = await fetch("/coordinate/pois.json");
const pois: POI[] = await res.json();
const makeLabel = (text: string, emoji?: string) => {
const canvas = document.createElement("canvas");
canvas.width = 512; canvas.height = 128;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgba(0,0,0,0.65)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#fff";
ctx.font = "bold 44px system-ui, sans-serif";
ctx.textBaseline = "middle";
ctx.fillText((emoji ? `${emoji} ` : "") + text, 24, canvas.height / 2);
const tex = new THREE.CanvasTexture(canvas);
tex.anisotropy = 4;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true });
const sprite = new THREE.Sprite(mat);
const metersWide = 2.0;
const aspect = canvas.width / canvas.height;
sprite.scale.set(metersWide, metersWide / aspect, 1);
return sprite;
};
const list: POIRuntime[] = [];
for (const p of pois) {
const sprite = makeLabel(p.name, p.icon);
const localXZ = toLocalMeters({ lat: p.lat, lon: p.lon }, ORIGIN_LLA);
sprite.position.set(localXZ.x, LABEL_HEIGHT_Y, localXZ.z);
sprite.visible = false; // visibility controlled per-frame
scene.add(sprite);
list.push({ poi: p, mesh: sprite, localXZ });
}
poiRuntimeRef.current = list;
}
loadPOIsAndCreateLabels().catch(console.error);
// --- Tap → start navigation (attach to overlay so it isn't blocked) ---
function ensureArrows() {
if (arrowsRef.current.length) return;
for (let i = 0; i < 20; i++) {
const g = new THREE.ConeGeometry(0.25, 0.6, 16);
const m = new THREE.MeshStandardMaterial({ color: 0x00b4ff });
const mesh = new THREE.Mesh(g, m);
mesh.visible = false;
scene.add(mesh);
arrowsRef.current.push(mesh);
}
}
function onTap(ev: MouseEvent | TouchEvent) {
// ignore UI controls inside overlay
const t = ev.target as HTMLElement | null;
if (t && (t.closest("button") || t.closest("[data-ui='true']"))) return;
let x = 0, y = 0;
if ("touches" in ev && ev.touches.length) { x = ev.touches[0].clientX; y = ev.touches[0].clientY; }
else if ("clientX" in ev) { x = (ev as MouseEvent).clientX; y = (ev as MouseEvent).clientY; }
const rect = renderer.domElement.getBoundingClientRect();
tapNDC.current.set(
((x - rect.left) / rect.width) * 2 - 1,
-(((y - rect.top) / rect.height) * 2 - 1)
);
raycaster.current.setFromCamera(tapNDC.current, camera);
const meshes = poiRuntimeRef.current.map(p => p.mesh);
const hits = raycaster.current.intersectObjects(meshes, true);
if (!hits.length) return;
const hit = hits[0].object;
const rt = poiRuntimeRef.current.find(p => p.mesh === hit || p.mesh === (hit as any).parent);
if (!rt) return;
const path: LLA[] = [];
if (rt.poi.waypoints?.length) path.push(...rt.poi.waypoints);
if (rt.poi.entrance) path.push(rt.poi.entrance);
path.push({ lat: rt.poi.lat, lon: rt.poi.lon });
navigatingRef.current = { active: true, pathLLA: path, targetIndex: 0 };
ensureArrows();
}
// attach to overlay (covers screen)
overlayRef.current.addEventListener("click", onTap as any);
overlayRef.current.addEventListener("touchstart", onTap as any, { passive: true });
// --- 6️⃣ WebXR hit-test setup ---
let hitTestSource: XRHitTestSource | null = null; let hitTestSource: XRHitTestSource | null = null;
let localRefSpace: XRReferenceSpace | null = null; let localRefSpace: XRReferenceSpace | null = null;
// Called when AR session starts (when user clicks "Enter AR")
const sessionStartHandler = async () => { const sessionStartHandler = async () => {
setIsSessionActive(true); // show bottom UI setIsSessionActive(true);
const session = renderer.xr.getSession(); const session = renderer.xr.getSession();
// Reference space: maps AR coordinates to real world
localRefSpace = await session.requestReferenceSpace("local-floor"); localRefSpace = await session.requestReferenceSpace("local-floor");
// Viewer space: based on device’s camera position
const viewerSpace = await session.requestReferenceSpace("viewer"); const viewerSpace = await session.requestReferenceSpace("viewer");
// Hit-test source: sends out rays from device to find planes
hitTestSource = await session.requestHitTestSource({ space: viewerSpace }); hitTestSource = await session.requestHitTestSource({ space: viewerSpace });
}; };
// Called when AR session ends
const sessionEndHandler = () => { const sessionEndHandler = () => {
setIsSessionActive(false); setIsSessionActive(false);
hitTestSource = null; hitTestSource = null;
localRefSpace = null; localRefSpace = null;
reticle.visible = false; // hide reticle when AR stops reticle.visible = false;
}; };
// Listen to AR session start/end
renderer.xr.addEventListener("sessionstart", sessionStartHandler); renderer.xr.addEventListener("sessionstart", sessionStartHandler);
renderer.xr.addEventListener("sessionend", sessionEndHandler); renderer.xr.addEventListener("sessionend", sessionEndHandler);
// --- 7️⃣ Adjust to window size changes --- // --- 7️⃣ Resize ---
const handleResize = () => { const handleResize = () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
...@@ -96,18 +228,15 @@ export const XRRenderer: React.FC = () => { ...@@ -96,18 +228,15 @@ export const XRRenderer: React.FC = () => {
}; };
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
// --- 8️⃣ Animation loop (runs every frame) --- // --- 8️⃣ Animation loop ---
renderer.setAnimationLoop((timestamp, frame) => { renderer.setAnimationLoop((timestamp, frame) => {
// Only run hit-test logic if AR session and frame exist // Reticle (hit-test)
if (frame && hitTestSource && localRefSpace) { if (frame && hitTestSource && localRefSpace) {
// Check if there’s a detected surface (like a floor or table)
const hitTestResults = frame.getHitTestResults(hitTestSource); const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length > 0) { if (hitTestResults.length > 0) {
// Take the first hit and get its position in space
const hit = hitTestResults[0]; const hit = hitTestResults[0];
const pose = hit.getPose(localRefSpace); const pose = hit.getPose(localRefSpace);
if (pose) { if (pose) {
// Move the reticle to that position
reticle.visible = true; reticle.visible = true;
reticle.position.set( reticle.position.set(
pose.transform.position.x, pose.transform.position.x,
...@@ -116,51 +245,117 @@ export const XRRenderer: React.FC = () => { ...@@ -116,51 +245,117 @@ export const XRRenderer: React.FC = () => {
); );
} }
} else { } else {
// No surface found → hide the reticle
reticle.visible = false; reticle.visible = false;
} }
} }
// Render 3D scene on each frame // Common: camera position (reuse below)
renderer.render(scene, camera); const camPos = new THREE.Vector3();
}); camera.getWorldPosition(camPos);
// --- 9️⃣ Add object to the scene when user selects one --- // Labels within 500 m (always-on visibility rule)
const addSelectedObject = (name: string) => { const MAX_LABEL_DIST_M = 500;
if (!reticle.visible) return; // only place when reticle is visible const camPos = new THREE.Vector3();
camera.getWorldPosition(camPos);
const position = reticle.position.clone(); // get current reticle position for (const item of poiRuntimeRef.current) {
let objectToAdd; const { mesh, localXZ } = item;
mesh.position.set(localXZ.x, LABEL_HEIGHT_Y, localXZ.z);
const d = Math.hypot(localXZ.x - camPos.x, localXZ.z - camPos.z);
mesh.visible = d < MAX_LABEL_DIST_M;
// Choose what object to add based on selection if (mesh.visible) {
if (name === "Cube") { mesh.quaternion.copy(camera.quaternion);
objectToAdd = new Cube(position); const e = new THREE.Euler().setFromQuaternion(mesh.quaternion, "YXZ");
} else if (name === "Watermill") { e.z = 0;
objectToAdd = new GLTFModel("/watermill.glb", position, "/Textures/colormap.png"); mesh.quaternion.setFromEuler(e);
} else if (name === "Tree") { }
objectToAdd = new GLTFModel("/tree.glb", position, "/Textures/colormap.png"); }
} else {
objectToAdd = new Cube(position); // fallback // Guidance arrows (optional next step: add per-frame layout here)
const nav = navigatingRef.current;
if (nav?.active) {
const ARROW_SPACING_M = 3.5;
const ARRIVAL_RADIUS_M = 10;
const targetLLA = nav.pathLLA[nav.targetIndex];
if (targetLLA) {
const targetXZ = toLocalMeters(targetLLA, ORIGIN_LLA);
// Arrived at this waypoint?
const distToTarget = Math.hypot(targetXZ.x - camPos.x, targetXZ.z - camPos.z);
if (distToTarget < ARRIVAL_RADIUS_M) {
nav.targetIndex++;
if (nav.targetIndex >= nav.pathLLA.length) {
// finished: hide all arrows and stop
for (const a of arrowsRef.current) a.visible = false;
navigatingRef.current = null;
}
} else {
// Place arrows from camera toward target, spaced every ARROW_SPACING_M
const dx = targetXZ.x - camPos.x;
const dz = targetXZ.z - camPos.z;
const len = Math.hypot(dx, dz);
const ux = dx / len;
const uz = dz / len;
let used = 0;
for (let i = 1; i <= arrowsRef.current.length; i++) {
const dist = i * ARROW_SPACING_M;
if (dist >= len) break;
const ax = camPos.x + ux * dist;
const az = camPos.z + uz * dist;
const arrow = arrowsRef.current[used++];
if (!arrow) break;
arrow.position.set(ax, 1.6, az); // ~eye height
const yaw = Math.atan2(ux, uz); // face along path
arrow.rotation.set(0, yaw, 0);
arrow.visible = true;
}
// Hide any unused arrows
for (let j = used; j < arrowsRef.current.length; j++) {
arrowsRef.current[j].visible = false;
}
}
}
} }
// Add the object to the scene (via manager) // Render
renderer.render(scene, camera);
});
// --- 9️⃣ Add object (unchanged) ---
const addSelectedObject = (name: string) => {
if (!reticle.visible) return;
const position = reticle.position.clone();
let objectToAdd;
if (name === "Cube") objectToAdd = new Cube(position);
else if (name === "Watermill") objectToAdd = new GLTFModel("/watermill.glb", position, "/Textures/colormap.png");
else if (name === "Tree") objectToAdd = new GLTFModel("/tree.glb", position, "/Textures/colormap.png");
else objectToAdd = new Cube(position);
blockManager.addObject(objectToAdd); blockManager.addObject(objectToAdd);
}; };
// Expose function globally for debugging in browser console
// You can type `window.addSelectedObject("Cube")` to spawn manually
(window as any).addSelectedObject = addSelectedObject; (window as any).addSelectedObject = addSelectedObject;
// --- 🔚 Cleanup when component unmounts --- // --- 🔚 Cleanup ---
return () => { return () => {
renderer.xr.removeEventListener("sessionstart", sessionStartHandler); renderer.xr.removeEventListener("sessionstart", sessionStartHandler);
renderer.xr.removeEventListener("sessionend", sessionEndHandler); renderer.xr.removeEventListener("sessionend", sessionEndHandler);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
renderer.dispose(); // free GPU memory
if (overlayRef.current) {
overlayRef.current.removeEventListener("click", onTap as any);
overlayRef.current.removeEventListener("touchstart", onTap as any);
}
pmremGenerator.dispose();
renderer.dispose();
mountRef.current?.removeChild(renderer.domElement); mountRef.current?.removeChild(renderer.domElement);
if (reticleRef.current === reticle) reticleRef.current = null; if (reticleRef.current === reticle) reticleRef.current = null;
}; };
}, []); // empty dependency array → runs only once }, []); // runs once
// --- 🔸 Screenshot function --- // --- 🔸 Screenshot function ---
const captureAndDownload = () => { const captureAndDownload = () => {
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment