Created
April 16, 2025 23:56
-
-
Save agrancini-sc/5096c51c658f0b342922faf1ce2b42df to your computer and use it in GitHub Desktop.
QuestMarkControllerPlaceObjects.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Imports required dependencies for map functionality, quest markers, and camera handling | |
*/ | |
import { MapComponent } from "../MapComponent/Scripts/MapComponent"; | |
import { MapPin } from "../MapComponent/Scripts/MapPin"; | |
import { | |
calculateBearing, | |
customGetEuler, | |
getPhysicalDistanceBetweenLocations, | |
map, | |
normalizeAngle, | |
quaternionToPitch, | |
} from "../MapComponent/Scripts/MapUtils"; | |
import { QuestMarker } from "../MapComponent/Scripts/QuestMarker"; | |
import WorldCameraFinderProvider from "../SpectaclesInteractionKit/Providers/CameraProvider/WorldCameraFinderProvider"; | |
import { LensConfig } from "../SpectaclesInteractionKit/Utils/LensConfig"; | |
import { UpdateDispatcher } from "../SpectaclesInteractionKit/Utils/UpdateDispatcher"; | |
import { UICollisionSolver } from "./UICollisionDetector"; | |
/** | |
* Constants defining the boundaries and angles for marker positioning | |
*/ | |
const BOUNDARY_HALF_WIDTH_PROJECTION = 35; // Maximum width for projecting markers | |
const BOUNDARY_HALF_WIDTH = 26; // Actual width boundary for markers | |
const BOUNDARY_HALF_HEIGHT = 35; // Height boundary for markers | |
const Y_POSITION_LERP_BUFFER = 10 * MathUtils.DegToRad; // Buffer for smooth y-position transitions | |
const VIEW_DETECT_ANGLE_BUFFER = 3 * MathUtils.DegToRad; // Buffer angle for detecting if marker is in view | |
/** | |
* Enum defining possible positions for markers on the screen | |
*/ | |
enum MarkerPosition { | |
TOP = 0, | |
RIGHT = 1, | |
BOTTOM = 2, | |
LEFT = 3, | |
CORNER = 4, | |
INVIEW = 5, | |
} | |
/** | |
* Interface for tracking marker position and index in arrays | |
*/ | |
interface MarkerPositionIndex { | |
position: MarkerPosition; | |
index: number; | |
} | |
/** | |
* Main controller class for handling quest markers and destination objects in AR space | |
* This component manages both UI markers for navigation and 3D objects at destination points | |
*/ | |
@component | |
export class QuestMarkControllerPlaceObjects extends BaseScriptComponent { | |
@input | |
private mapComponent: MapComponent; | |
@input | |
private questMarkerPrefab: ObjectPrefab; | |
// new prefab for destination objects | |
@input | |
private destinationPrefab: ObjectPrefab; | |
@input | |
private inViewMaterial: Material; | |
@input | |
private outOfViewMaterial: Material; | |
@input | |
private scale: number = 1; | |
@input | |
private markerImageOffsetInDegree: number = 0; | |
@input | |
private markerHalfWidth = 5; | |
@input | |
private markerHalfHeight = 5; | |
@input | |
private labelHalfHeight = 0.7; | |
private questMarkers: Map<string, QuestMarker> = new Map(); | |
private camera: Camera; | |
private cameraTransform: Transform; | |
private halfFOV: number; | |
private uiCollisionSolver: UICollisionSolver = new UICollisionSolver(); | |
private leftElements: vec2[]; | |
private rightElements: vec2[]; | |
private topElements: vec2[]; | |
private bottomElements: vec2[]; | |
private inViewElements: vec4[]; | |
private markerPositions: MarkerPositionIndex[]; | |
private defaultLabelY: number; | |
private destinationObjects: Map<string, SceneObject> = new Map(); | |
/** | |
* Dispatcher for handling update events | |
*/ | |
private updateDispatcher: UpdateDispatcher = | |
LensConfig.getInstance().updateDispatcher; | |
/** | |
* Initializes component and binds event handlers | |
*/ | |
onAwake() { | |
this.createEvent("OnStartEvent").bind(this.onStart.bind(this)); | |
this.updateDispatcher | |
.createLateUpdateEvent("LateUpdateEvent") | |
.bind(this.onLateUpdate.bind(this)); | |
} | |
/** | |
* Sets up map pin handlers and camera references on start | |
*/ | |
onStart() { | |
this.mapComponent.subscribeOnMapAddPin(this.handleMapAddPin.bind(this)); | |
this.mapComponent.subscribeOnAllMapPinsRemoved( | |
this.handleAllMapPinsRemoved.bind(this) | |
); | |
this.camera = WorldCameraFinderProvider.getInstance().getComponent(); | |
this.cameraTransform = | |
WorldCameraFinderProvider.getInstance().getTransform(); | |
} | |
/** | |
* Main update loop that handles: | |
* 1. Updating marker positions based on camera view | |
* 2. Resolving UI collisions between markers | |
* 3. Updating 3D destination objects | |
*/ | |
onLateUpdate() { | |
this.halfFOV = this.camera.fov / 2 - VIEW_DETECT_ANGLE_BUFFER; | |
// Calculate the position of the quest mark on the screen by comparing the longlat of the map pin and the user | |
const userLocation = this.mapComponent.getUserLocation(); | |
const markerPlaneDistanceFromCamera = this.sceneObject | |
.getTransform() | |
.getWorldPosition() | |
.distance(this.cameraTransform.getWorldPosition()); | |
const yOrientationOffset: number = | |
-Math.abs(markerPlaneDistanceFromCamera) * | |
Math.tan(quaternionToPitch(this.cameraTransform.getLocalRotation())); | |
this.leftElements = []; | |
this.rightElements = []; | |
this.topElements = []; | |
this.bottomElements = []; | |
this.inViewElements = []; | |
this.markerPositions = new Array(this.questMarkers.size); | |
let markerIndex = 0; | |
this.questMarkers.forEach((marker) => { | |
const distance = getPhysicalDistanceBetweenLocations( | |
userLocation, | |
marker.mapPin.location | |
); | |
const { orientation, xPosition, yPosition } = | |
this.resolveMarkerPositionAndRotation( | |
distance, | |
marker, | |
userLocation, | |
yOrientationOffset | |
); | |
marker.setOrientation(orientation); | |
marker.setDistance(distance); | |
const localPosition = new vec3( | |
xPosition, | |
MathUtils.clamp(yPosition, -BOUNDARY_HALF_HEIGHT, BOUNDARY_HALF_HEIGHT), | |
0 | |
); | |
marker.transform.setLocalPosition(localPosition); | |
this.registerMarkerPositions(localPosition, markerIndex); | |
markerIndex++; | |
}); | |
this.resolveMarkerPositions(); | |
this.updateDestinationObjects(); | |
} | |
/** | |
* Resolves collisions between markers and updates their positions | |
* Handles different marker positions (left, right, top, bottom, in-view) | |
*/ | |
private resolveMarkerPositions() { | |
const resolvedLeftElements = this.uiCollisionSolver.resolve1DCollisions( | |
this.leftElements | |
); | |
const resolvedRightElements = this.uiCollisionSolver.resolve1DCollisions( | |
this.rightElements | |
); | |
const resolvedBottomElements = this.uiCollisionSolver.resolve1DCollisions( | |
this.bottomElements | |
); | |
const resolvedTopElements = this.uiCollisionSolver.resolve1DCollisions( | |
this.topElements | |
); | |
const resolvedInViewElements = this.uiCollisionSolver.resolve2DCollisions( | |
this.inViewElements | |
); | |
let markerIndex = 0; | |
this.questMarkers.forEach((marker) => { | |
const localPosition = marker.transform.getLocalPosition(); | |
const labelLocalPosition = marker.markerLabel | |
.getTransform() | |
.getLocalPosition(); | |
const distanceTextLocalPosition = marker.distanceText | |
.getTransform() | |
.getLocalPosition(); | |
let x = localPosition.x; | |
let y = localPosition.y; | |
let labelLocalY = this.defaultLabelY; | |
if (this.markerPositions[markerIndex].position === MarkerPosition.LEFT) { | |
y = | |
resolvedLeftElements[this.markerPositions[markerIndex].index].y - | |
this.markerHalfHeight; | |
} else if ( | |
this.markerPositions[markerIndex].position === MarkerPosition.RIGHT | |
) { | |
y = | |
resolvedRightElements[this.markerPositions[markerIndex].index].y - | |
this.markerHalfHeight; | |
} else if ( | |
this.markerPositions[markerIndex].position === MarkerPosition.BOTTOM | |
) { | |
x = | |
resolvedBottomElements[this.markerPositions[markerIndex].index].y - | |
this.markerHalfWidth; | |
} else if ( | |
this.markerPositions[markerIndex].position === MarkerPosition.TOP | |
) { | |
x = | |
resolvedTopElements[this.markerPositions[markerIndex].index].y - | |
this.markerHalfWidth; | |
} | |
marker.transform.setLocalPosition(new vec3(x, y, localPosition.z)); | |
if ( | |
this.markerPositions[markerIndex].position === MarkerPosition.INVIEW | |
) { | |
labelLocalY = | |
resolvedInViewElements[this.markerPositions[markerIndex].index].w - | |
this.labelHalfHeight + | |
this.defaultLabelY - | |
y; | |
} | |
marker.markerLabel | |
.getTransform() | |
.setLocalPosition( | |
new vec3(labelLocalPosition.x, labelLocalY, labelLocalPosition.z) | |
); | |
marker.distanceText | |
.getTransform() | |
.setLocalPosition( | |
new vec3( | |
distanceTextLocalPosition.x, | |
-labelLocalY, | |
distanceTextLocalPosition.z | |
) | |
); | |
markerIndex++; | |
}); | |
return markerIndex; | |
} | |
/** | |
* Updates the position and rotation of all destination 3D objects | |
* Keeps them aligned with their corresponding quest markers | |
*/ | |
private updateDestinationObjects() { | |
const userLocation = this.mapComponent.getUserLocation(); | |
this.destinationObjects.forEach((destinationObject, pinId) => { | |
const marker = this.questMarkers.get(pinId); | |
if (!marker) { | |
destinationObject.destroy(); | |
this.destinationObjects.delete(pinId); | |
return; | |
} | |
const distance = getPhysicalDistanceBetweenLocations( | |
userLocation, | |
marker.mapPin.location | |
); | |
const bearing = normalizeAngle( | |
calculateBearing(userLocation, marker.mapPin.location) - | |
this.mapComponent.getUserHeading() | |
); | |
const cameraPosition = this.cameraTransform.getWorldPosition(); | |
const userForward = this.cameraTransform.back | |
.projectOnPlane(vec3.up()) | |
.normalize(); | |
// Update position | |
const markerLocationWorldPos = this.cameraTransform | |
.getWorldPosition() | |
.add( | |
quat | |
.fromEulerAngles(0, -bearing, 0) | |
.multiplyVec3(userForward) | |
.uniformScale(distance * 100) | |
) | |
.add( | |
new vec3( | |
0, | |
//(marker.mapPin.location.altitude - userLocation.altitude) * 100, | |
1, | |
0 | |
) | |
); | |
destinationObject.getTransform().setWorldPosition(markerLocationWorldPos); | |
// Update rotation | |
const directionToUser = cameraPosition | |
.sub(markerLocationWorldPos) | |
.normalize(); | |
const rotationToUser = quat.lookAt(directionToUser, vec3.up()); | |
destinationObject.getTransform().setWorldRotation(rotationToUser); | |
// Update text | |
const distanceInMeters = Math.round(distance); | |
const locationText = `${marker.markerLabel.text}\n${distanceInMeters}m`; | |
destinationObject.getChild(1).getComponent("Component.Text").text = | |
locationText; | |
}); | |
} | |
/** | |
* Registers marker positions in appropriate arrays based on their screen location | |
* Used for collision detection and position resolution | |
*/ | |
private registerMarkerPositions(localPosition: vec3, markerIndex: number) { | |
const isCorner = | |
Math.abs(localPosition.y) === Math.abs(BOUNDARY_HALF_HEIGHT) && | |
Math.abs(localPosition.x) === Math.abs(BOUNDARY_HALF_WIDTH); | |
if (isCorner) { | |
this.markerPositions[markerIndex] = { | |
position: MarkerPosition.CORNER, | |
index: 0, | |
}; | |
} else { | |
if (localPosition.x == -BOUNDARY_HALF_WIDTH) { | |
this.markerPositions[markerIndex] = { | |
position: MarkerPosition.LEFT, | |
index: this.leftElements.length, | |
}; | |
this.leftElements.push( | |
new vec2( | |
localPosition.y - this.markerHalfHeight, | |
localPosition.y + this.markerHalfHeight | |
) | |
); | |
} else if (localPosition.x == BOUNDARY_HALF_WIDTH) { | |
this.markerPositions[markerIndex] = { | |
position: MarkerPosition.RIGHT, | |
index: this.rightElements.length, | |
}; | |
this.rightElements.push( | |
new vec2( | |
localPosition.y - this.markerHalfHeight, | |
localPosition.y + this.markerHalfHeight | |
) | |
); | |
} else if (localPosition.y == -BOUNDARY_HALF_HEIGHT) { | |
this.markerPositions[markerIndex] = { | |
position: MarkerPosition.BOTTOM, | |
index: this.bottomElements.length, | |
}; | |
this.bottomElements.push( | |
new vec2( | |
localPosition.x - this.markerHalfWidth, | |
localPosition.x + this.markerHalfWidth | |
) | |
); | |
} else if (localPosition.y == BOUNDARY_HALF_HEIGHT) { | |
this.markerPositions[markerIndex] = { | |
position: MarkerPosition.TOP, | |
index: this.topElements.length, | |
}; | |
this.topElements.push( | |
new vec2( | |
localPosition.x - this.markerHalfWidth, | |
localPosition.x + this.markerHalfWidth | |
) | |
); | |
} else { | |
this.markerPositions[markerIndex] = { | |
position: MarkerPosition.INVIEW, | |
index: this.inViewElements.length, | |
}; | |
// Assume the in-view markers are all at the same height | |
this.inViewElements.push( | |
new vec4( | |
localPosition.x - this.markerHalfWidth, | |
localPosition.x + this.markerHalfWidth, | |
localPosition.y - this.labelHalfHeight, | |
localPosition.y + this.labelHalfHeight | |
) | |
); | |
} | |
} | |
} | |
/** | |
* Calculates the position and rotation for markers based on: | |
* - Distance from user | |
* - Bearing to destination | |
* - Camera orientation | |
* - View boundaries | |
*/ | |
private resolveMarkerPositionAndRotation( | |
distance: number, | |
marker: QuestMarker, | |
userLocation: GeoPosition, | |
yOrientationOffset: number | |
): { orientation: number; xPosition: number; yPosition: number } { | |
const bearing = normalizeAngle( | |
calculateBearing(userLocation, marker.mapPin.location) - | |
this.mapComponent.getUserHeading() | |
); | |
const inView = bearing < this.halfFOV && bearing > -this.halfFOV; | |
const backStartAngle = Math.PI - this.halfFOV; | |
const isOnTheBack = bearing > backStartAngle || bearing < -backStartAngle; | |
let screenPosition: vec2; | |
if (inView || isOnTheBack) { | |
const cameraForward = this.cameraTransform.back; | |
const userForward = cameraForward.projectOnPlane(vec3.up()).normalize(); | |
const markerLocationWorldPos: vec3 = this.cameraTransform | |
.getWorldPosition() | |
.add( | |
quat | |
.fromEulerAngles(0, -bearing, 0) | |
.multiplyVec3(userForward) | |
.uniformScale(distance * 100) | |
) | |
.add( | |
new vec3( | |
0, | |
(marker.mapPin.location.altitude - userLocation.altitude) * 100, | |
0 | |
) | |
); | |
const cameraRoll = normalizeAngle( | |
customGetEuler(this.cameraTransform.getLocalRotation()).z | |
); | |
const unrolledWorldPos = quat | |
.fromEulerAngles(0, 0, cameraRoll) | |
.multiplyVec3(markerLocationWorldPos); | |
screenPosition = this.camera.worldSpaceToScreenSpace(unrolledWorldPos); | |
screenPosition = new vec2( | |
MathUtils.clamp( | |
(screenPosition.x - 0.5) * | |
BOUNDARY_HALF_WIDTH_PROJECTION * | |
2 * | |
(isOnTheBack ? -1 : 1), | |
-BOUNDARY_HALF_WIDTH, | |
BOUNDARY_HALF_WIDTH | |
), | |
MathUtils.clamp( | |
(0.5 - screenPosition.y) * BOUNDARY_HALF_HEIGHT * 2, | |
-BOUNDARY_HALF_HEIGHT, | |
BOUNDARY_HALF_HEIGHT | |
) | |
); | |
} else { | |
screenPosition = this.mapAngleToScreenPoint(bearing); | |
} | |
let yPosition: number; | |
let orientation = -( | |
bearing + | |
this.markerImageOffsetInDegree * MathUtils.DegToRad | |
); | |
marker.setIsInView(inView, this.inViewMaterial, this.outOfViewMaterial); | |
if (inView) { | |
if ( | |
yOrientationOffset > -BOUNDARY_HALF_HEIGHT && | |
yOrientationOffset < BOUNDARY_HALF_HEIGHT | |
) { | |
marker.setIsInView(true, this.inViewMaterial, this.outOfViewMaterial); | |
orientation = 0; | |
} else { | |
// Outside of vertical view | |
marker.setIsInView(false, this.inViewMaterial, this.outOfViewMaterial); | |
if (yOrientationOffset < -BOUNDARY_HALF_HEIGHT) { | |
orientation = Math.PI * 2 - orientation; | |
} | |
} | |
yPosition = MathUtils.clamp( | |
yOrientationOffset, | |
-BOUNDARY_HALF_HEIGHT, | |
BOUNDARY_HALF_HEIGHT | |
); | |
} else { | |
marker.setIsInView(false, this.inViewMaterial, this.outOfViewMaterial); | |
const unrestrainedYPosition = screenPosition.y + yOrientationOffset; | |
const yPositionUnderTopBoundary = Math.min( | |
unrestrainedYPosition, | |
BOUNDARY_HALF_HEIGHT | |
); | |
const min = | |
yPositionUnderTopBoundary < -BOUNDARY_HALF_HEIGHT | |
? -BOUNDARY_HALF_HEIGHT | |
: yPositionUnderTopBoundary; | |
yPosition = MathUtils.clamp( | |
unrestrainedYPosition, | |
min, | |
yOrientationOffset | |
); | |
// Smooth transition the y-position to the bottom when the marker is on the back | |
const absBearing = Math.abs(bearing); | |
if (absBearing > backStartAngle - Y_POSITION_LERP_BUFFER) { | |
const t = MathUtils.clamp( | |
(absBearing - backStartAngle + Y_POSITION_LERP_BUFFER) / | |
Y_POSITION_LERP_BUFFER, | |
0, | |
1 | |
); | |
yPosition = MathUtils.lerp(yPosition, -BOUNDARY_HALF_HEIGHT, t); | |
} | |
} | |
return { orientation, xPosition: screenPosition.x, yPosition }; | |
} | |
/** | |
* Handles creation of new map pins: | |
* 1. Creates UI quest markers for navigation | |
* 2. Instantiates 3D destination objects at target locations | |
* 3. Sets up position, rotation and text display | |
*/ | |
private handleMapAddPin(pin: MapPin): void { | |
const userLocation = this.mapComponent.getUserLocation(); | |
print("User Location: " + userLocation); | |
if ( | |
pin.location.longitude === userLocation.longitude && | |
pin.location.latitude === userLocation.latitude | |
) { | |
return; | |
} | |
const distance = getPhysicalDistanceBetweenLocations( | |
userLocation, | |
pin.location | |
); | |
const bearing = normalizeAngle( | |
calculateBearing(userLocation, pin.location) - | |
this.mapComponent.getUserHeading() | |
); | |
// Check if destination is in view | |
const inView = bearing < this.halfFOV && bearing > -this.halfFOV; | |
// Get camera position and forward direction | |
const cameraPosition = this.cameraTransform.getWorldPosition(); | |
const cameraForward = this.cameraTransform.back; | |
const userForward = cameraForward.projectOnPlane(vec3.up()).normalize(); | |
// Calculate world position using the same logic as quest markers | |
const markerLocationWorldPos = this.cameraTransform | |
.getWorldPosition() | |
.add( | |
quat | |
.fromEulerAngles(0, -bearing, 0) | |
.multiplyVec3(userForward) | |
.uniformScale(distance * 100) | |
) | |
.add( | |
new vec3(0, (pin.location.altitude - userLocation.altitude) * 100, 0) | |
); | |
// Instantiate destination prefab at world position | |
if (this.destinationPrefab) { | |
const destinationObject = this.destinationPrefab.instantiate(null); | |
destinationObject.getTransform().setWorldPosition(markerLocationWorldPos); | |
destinationObject.getTransform().setLocalScale(new vec3(0.5, 0.5, 0.5)); | |
// Make the destination object face the user | |
const directionToUser = cameraPosition | |
.sub(markerLocationWorldPos) | |
.normalize(); | |
const rotationToUser = quat.lookAt(directionToUser, vec3.up()); | |
destinationObject.getTransform().setWorldRotation(rotationToUser); | |
// Set name and text | |
destinationObject.name = | |
"Destination_" + pin.sceneObject.uniqueIdentifier; | |
const locationName = | |
pin.placeInfo?.name || pin.sceneObject.name || "Unknown Location"; | |
const distanceInMeters = Math.round(distance); | |
const locationText = `${locationName}\n${distanceInMeters}m`; | |
// Update text component | |
destinationObject.getChild(1).getComponent("Component.Text").text = | |
locationText; | |
this.destinationObjects.set( | |
pin.sceneObject.uniqueIdentifier, | |
destinationObject | |
); | |
} | |
// | |
if (!this.questMarkers.has(pin.sceneObject.uniqueIdentifier)) { | |
const questmarkObject = this.questMarkerPrefab.instantiate( | |
this.sceneObject | |
); | |
questmarkObject.name = "QuestMark " + this.questMarkers.size; | |
const questMark = new QuestMarker( | |
pin, | |
questmarkObject.getTransform(), | |
this.scale | |
); | |
this.defaultLabelY = questMark.markerLabel | |
.getTransform() | |
.getLocalPosition().y; | |
this.questMarkers.set(pin.sceneObject.uniqueIdentifier, questMark); | |
} | |
} | |
/** | |
* Cleanup handler that removes all markers and destination objects | |
*/ | |
private handleAllMapPinsRemoved(): void { | |
this.questMarkers.forEach((questMark) => { | |
questMark.transform.getSceneObject().destroy(); | |
}); | |
this.questMarkers.clear(); | |
this.destinationObjects.forEach((obj) => obj.destroy()); | |
this.destinationObjects.clear(); | |
} | |
/** | |
* Utility function that converts an angle to screen coordinates | |
* Used for positioning markers at screen edges when destination is out of view | |
*/ | |
private mapAngleToScreenPoint(radians: number): vec2 { | |
let x, y: number; | |
const degree = radians * MathUtils.RadToDeg; | |
var top = BOUNDARY_HALF_HEIGHT; | |
var left = -BOUNDARY_HALF_WIDTH; | |
var right = BOUNDARY_HALF_WIDTH; | |
var bottom = -BOUNDARY_HALF_HEIGHT; | |
const halfFOVInDegree = this.halfFOV * MathUtils.RadToDeg; | |
if (degree >= -halfFOVInDegree && degree <= halfFOVInDegree) { | |
// top | |
y = top; | |
x = map(degree, -halfFOVInDegree, halfFOVInDegree, left, right); | |
} else if (degree > halfFOVInDegree && degree <= 180 - halfFOVInDegree) { | |
// right | |
y = map(degree, halfFOVInDegree, 180 - halfFOVInDegree, top, bottom); | |
x = right; | |
} else if (degree < -halfFOVInDegree && degree >= -180 + halfFOVInDegree) { | |
// left | |
y = map(degree, -halfFOVInDegree, -180 + halfFOVInDegree, top, bottom); | |
x = left; | |
} else if (degree < -180 + halfFOVInDegree) { | |
// bottom | |
y = bottom; | |
x = map(degree, -180 + halfFOVInDegree, -180, left, 0); | |
} else { | |
// bottom | |
y = bottom; | |
x = map(degree, 180 - halfFOVInDegree, 180, right, 0); | |
} | |
return new vec2(x, y); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment