Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
HFT-informatics-weekend
AR - Urban Planner
Commits
07a8b1d8
Commit
07a8b1d8
authored
Oct 12, 2025
by
Kang
Browse files
Add instructions and label to the app
parent
de77f40d
Changes
1
Hide whitespace changes
Inline
Side-by-side
webxr-cube/src/components/XRRenderer.tsx
View file @
07a8b1d8
...
@@ -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
=
()
=>
{
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment