PuppetBrowserVrInput.kt
src/jvmMain/kotlin/afield/media/browser/PuppetBrowserVrInput.kt · 197 lines
package afield.media.browser
import afield.graphics.vulkan.targets.OpenXRHmdTarget.ControllerState
import afield.graphics.vulkan.targets.OpenXRHmdTarget.HandJoint
import afield.graphics.vulkan.targets.OpenXRHmdTarget.HandTrackingState
import afield.math.linalg.Vector3f
import afield.math.linalg.Quaternionf
import afield.math.linalg.Matrix4f
data class BrowserVrPointer
One already tracked ray. OpenXR aim poses point along local -Z.
data class BrowserVrPointer(
val id: String,
val origin: Vector3f,
val direction: Vector3f,
val pressed: Boolean,
val scrollY: Double = 0.0,
val gripPressed: Boolean = false,
val orientation: Quaternionf = Quaternionf(),
val scrollX: Double = 0.0,
)
class PuppetBrowserVrInput(val panel: PuppetBrowserPanel)
Arbitrates two hands against Chromium's single mouse. A press owns the pointer until release, including outside the panel; tracking loss cancels capture. Hover alone cannot steal a drag.
var activePointer: String?
var activePointer: String? = null
private set
private var capturedPointer: String? = null
private var previousPressed = emptySet<String>()
private val pinching = mutableMapOf<String, Boolean>()
fun update(pointers: List<BrowserVrPointer>, enabled: Boolean = true): PuppetBrowserPanel.Hit?
fun update(pointers: List<BrowserVrPointer>, enabled: Boolean = true): PuppetBrowserPanel.Hit? {
if (!enabled) {
panel.releasePointer()
capturedPointer = null
activePointer = null
previousPressed = pointers.filter { it.pressed }.map { it.id }.toSet()
return null
}
val captured = capturedPointer
if (captured != null && pointers.none { it.id == captured }) {
panel.releasePointer()
capturedPointer = null
activePointer = null
}
val hits = pointers.associate { it.id to panel.raycast(it.origin, it.direction) }
val freshPress = pointers.firstOrNull {
it.pressed && it.id !in previousPressed && hits[it.id]?.onPanel == true
}
val selected = pointers.firstOrNull { it.id == capturedPointer }
?: freshPress
?: pointers.firstOrNull { it.id == activePointer && hits[it.id]?.onPanel == true }
?: pointers.firstOrNull { hits[it.id]?.onPanel == true }
if (selected?.id != activePointer) panel.releasePointer()
activePointer = selected?.id
val hit = selected?.let {
// Moving onto the panel with a button already held must not synthesize a new click.
val down = it.pressed && (it.id == capturedPointer || it.id !in previousPressed)
panel.interact(it.origin, it.direction, down, it.scrollY, scrollX = it.scrollX)
}
if (selected == null) panel.releasePointer()
capturedPointer = if (panel.uv.buttons != 0) selected?.id else null
previousPressed = pointers.filter { it.pressed }.map { it.id }.toSet()
return hit
}
fun cancel()
fun cancel() {
panel.releasePointer()
capturedPointer = null
activePointer = null
previousPressed = emptySet()
pinching.clear()
}
fun controller(id: String, state: ControllerState, elapsedSeconds: Double): BrowserVrPointer?
Prefer the runtime's aim pose. Invalid poses never fall back to an untracked mouse.
fun controller(id: String, state: ControllerState, elapsedSeconds: Double): BrowserVrPointer? {
if (!state.active || !state.poseValid) return null
fun axis(stick: Float, pad: Float): Double {
val raw = if (kotlin.math.abs(stick) > 0.12f) stick else pad
val magnitude = ((kotlin.math.abs(raw) - 0.12) / 0.88).coerceIn(0.0, 1.0)
return kotlin.math.sign(raw.toDouble()) * magnitude * magnitude
}
val seconds = elapsedSeconds.coerceIn(0.0, 0.05)
val (origin, direction) = PuppetBrowserPanel.controllerRay(state, Vector3f(0f, 0f, -1f))
return BrowserVrPointer(id, origin, direction, state.selectPressed || state.triggerPressed || state.aPressed,
-axis(state.thumbstickY, state.trackpadY) * 1200.0 * seconds,
gripPressed = state.squeezePressed, orientation = Quaternionf(state.rotation),
scrollX = axis(state.thumbstickX, state.trackpadX) * 1200.0 * seconds)
}
fun hand(id: String, state: HandTrackingState?): BrowserVrPointer?
Joint-based fallback when the runtime supplies hands without a controller aim action.
fun hand(id: String, state: HandTrackingState?): BrowserVrPointer? {
val wrist = state?.get(HandJoint.WRIST)
val knuckle = state?.get(HandJoint.INDEX_PROXIMAL)
val thumb = state?.get(HandJoint.THUMB_TIP)
val index = state?.get(HandJoint.INDEX_TIP)
if (state?.isActive != true || wrist?.positionValid != true || knuckle?.positionValid != true ||
thumb?.positionValid != true || index?.positionValid != true) {
pinching.remove(id)
return null
}
val direction = Vector3f(knuckle.position).sub(wrist.position)
if (direction.lengthSquared() < 1e-8f) return null
val down = state.pinchStrength >= if (pinching[id] == true) 0.55f else 0.8f
pinching[id] = down
return BrowserVrPointer(id, Vector3f(knuckle.position), direction.normalize(), down)
}
}
class PuppetBrowserVrGrab
One grip moves the panel; two grips resize around its centre and translate with the hands.
class PuppetBrowserVrGrab(
private val panel: PuppetBrowserPanel,
private val minimumWidthMeters: Float = 0.4f,
private val maximumWidthMeters: Float = 8f,
) {
var owner: String?
var owner: String? = null
private set
private val offset = Matrix4f()
private var previousGrips = emptySet<String>()
private data class Resize(
val first: String, val second: String, val midpoint: Vector3f,
val distance: Float, val width: Float, val model: Matrix4f,
)
private var resize: Resize? = null
init
init {
require(minimumWidthMeters > 0f && maximumWidthMeters >= minimumWidthMeters)
}
fun update(pointers: List<BrowserVrPointer>, enabled: Boolean = true): Boolean
body — 56 lines, from line 131
fun update(pointers: List<BrowserVrPointer>, enabled: Boolean = true): Boolean {
if (!enabled) {
owner = null
resize = null
previousGrips = pointers.filter { it.gripPressed }.map { it.id }.toSet()
return false
}
resize?.let { gesture ->
val first = pointers.firstOrNull { it.id == gesture.first && it.gripPressed }
val second = pointers.firstOrNull { it.id == gesture.second && it.gripPressed }
if (first != null && second != null) {
val midpoint = Vector3f(first.origin).add(second.origin).mul(0.5f)
val scale = (first.origin.distance(second.origin) / gesture.distance)
.coerceIn(minimumWidthMeters / gesture.width, maximumWidthMeters / gesture.width)
val centre = gesture.model.getTranslation(Vector3f())
val translated = Vector3f(midpoint).sub(gesture.midpoint).add(centre)
panel.model.identity().translate(translated.x, translated.y, translated.z).scale(scale)
.translate(-centre.x, -centre.y, -centre.z).mul(gesture.model)
} else {
// Releasing either hand resumes a one-handed grab without jumping. Tracking
// loss follows the same rule; with neither hand tracked the panel stays put.
resize = null
val remaining = first ?: second
owner = remaining?.id
if (remaining != null) offset.set(pose(remaining).invert().mul(panel.model))
}
previousGrips = pointers.filter { it.gripPressed }.map { it.id }.toSet()
return owner != null
}
val held = pointers.firstOrNull { it.id == owner && it.gripPressed }
if (held != null) {
panel.model.set(pose(held).mul(offset))
} else {
owner = null
val start = pointers.firstOrNull {
it.gripPressed && it.id !in previousGrips && panel.raycast(it.origin, it.direction)?.onPanel == true
}
if (start != null) {
owner = start.id
offset.set(pose(start).invert().mul(panel.model))
}
}
val first = pointers.firstOrNull { it.id == owner && it.gripPressed }
val second = pointers.firstOrNull {
it.id != owner && it.gripPressed && it.id !in previousGrips &&
panel.raycast(it.origin, it.direction)?.onPanel == true
}
if (first != null && second != null && first.origin.distance(second.origin) >= 0.08f) {
resize = Resize(first.id, second.id, Vector3f(first.origin).add(second.origin).mul(0.5f),
first.origin.distance(second.origin),
panel.model.transformDirection(Vector3f(panel.halfWidth * 2, 0f, 0f)).length(),
Matrix4f(panel.model))
}
previousGrips = pointers.filter { it.gripPressed }.map { it.id }.toSet()
return owner != null
}
fun cancel()
fun cancel() {
owner = null
resize = null
previousGrips = emptySet()
}
private fun pose(pointer: BrowserVrPointer): Matrix4f =
Matrix4f().translate(pointer.origin.x, pointer.origin.y, pointer.origin.z).rotate(pointer.orientation)
}