PuppetBrowserInteraction.kt
src/jvmMain/kotlin/afield/media/browser/PuppetBrowserInteraction.kt · 471 lines
package afield.media.browser
import afield.graphics.vulkan.util.Window
import afield.graphics.vulkan.targets.OpenXRHmdTarget.ControllerState
import afield.math.linalg.Matrix4f
import afield.math.linalg.Vector3f
import org.lwjgl.glfw.GLFW
import kotlin.math.abs
Input adapters that route mouse, keyboard and controller rays into a PuppetBrowser.
PuppetBrowserUvInput turns panel UVs into CSS-pixel pointer events, with click counting;
PuppetBrowserWindowInput forwards one Field window's pointer and key stream; PuppetBrowserPanel
places the browser as a world-space rectangle and hit-tests rays against it (orientation is
covered by PuppetBrowserPanelOrientationTest).
class PuppetBrowserUvInput(val browser: PuppetBrowserControl, val viewport: ExternalBrowserSurfaceSpec)
Maps normalized panel UVs into the browser's CSS-pixel input coordinate system.
constructor(browser: PuppetBrowser) : this(browser, browser.spec)
val cssWidth: Double get() = viewport.width / viewport.deviceScaleFactor
val cssHeight: Double get() = viewport.height / viewport.deviceScaleFactor
val position: Pair<Double, Double> get() = lastU to lastV
var modifiers: Set<BrowserModifier> = emptySet()
var buttons: Int
var buttons: Int = 0
private set
private var lastU = 0.5
private var lastV = 0.5
private var lastClickNanos = 0L
private var lastClickU = Double.NaN
private var lastClickV = Double.NaN
private var clickCount = 0
fun move(u: Double, v: Double, pointerType: BrowserPointerType = BrowserPointerType.MOUSE)
fun move(u: Double, v: Double, pointerType: BrowserPointerType = BrowserPointerType.MOUSE) {
lastU = u
lastV = v
browser.pointer(pointerEvent(BrowserPointerEventType.MOVE, u, v, pointerType = pointerType))
}
fun down
fun down(
button: BrowserMouseButton = BrowserMouseButton.LEFT,
u: Double = lastU,
v: Double = lastV,
pointerType: BrowserPointerType = BrowserPointerType.MOUSE,
pressure: Double = if (pointerType == BrowserPointerType.PEN) 1.0 else 0.0,
) {
lastU = u
lastV = v
buttons = buttons or button.cdpBit
browser.focus()
browser.pointer(
pointerEvent(
BrowserPointerEventType.DOWN,
u,
v,
button,
currentClickCount(u, v),
pointerType,
pressure,
)
)
}
fun up
fun up(
button: BrowserMouseButton = BrowserMouseButton.LEFT,
u: Double = lastU,
v: Double = lastV,
pointerType: BrowserPointerType = BrowserPointerType.MOUSE,
) {
lastU = u
lastV = v
buttons = buttons and button.cdpBit.inv()
browser.pointer(
pointerEvent(
BrowserPointerEventType.UP,
u,
v,
button,
clickCount.coerceAtLeast(1),
pointerType,
)
)
}
fun wheel
fun wheel(
deltaX: Double,
deltaY: Double,
u: Double = lastU,
v: Double = lastV,
) {
lastU = u
lastV = v
browser.pointer(
pointerEvent(BrowserPointerEventType.WHEEL, u, v).copy(deltaX = deltaX, deltaY = deltaY)
)
}
fun releaseAll()
fun releaseAll() {
BrowserMouseButton.entries
.filter { it != BrowserMouseButton.NONE && buttons and it.cdpBit != 0 }
.forEach { up(it) }
}
private fun pointerEvent(
type: BrowserPointerEventType,
u: Double,
v: Double,
button: BrowserMouseButton = BrowserMouseButton.NONE,
clickCount: Int = 0,
pointerType: BrowserPointerType = BrowserPointerType.MOUSE,
pressure: Double = 0.0,
) = BrowserPointerEvent(
type = type,
x = u * cssWidth,
y = v * cssHeight,
button = button,
buttons = buttons,
modifiers = modifiers,
clickCount = clickCount,
pointerType = pointerType,
pressure = pressure,
)
private fun currentClickCount(u: Double, v: Double): Int {
val now = System.nanoTime()
val near = abs(u - lastClickU) * cssWidth <= 5 && abs(v - lastClickV) * cssHeight <= 5
clickCount = if (near && now - lastClickNanos <= 500_000_000L) clickCount + 1 else 1
lastClickNanos = now
lastClickU = u
lastClickV = v
return clickCount
}
}
class PuppetBrowserWindowInput
Sends one Field Window's pointer and keyboard stream to a PuppetBrowser. hitTest converts
window-local logical coordinates to browser UVs; return null when the browser was not hit.
class PuppetBrowserWindowInput(
val browser: PuppetBrowser,
val window: Window,
private val hitTest: (x: Double, y: Double) -> Pair<Double, Double>? = { x, y ->
val size = window.size()
if (x < 0 || y < 0 || x >= size.w || y >= size.h) null else x / size.w to y / size.h
},
) : AutoCloseable {
val uv = PuppetBrowserUvInput(browser)
var active: Boolean
var active: Boolean = false
private set
var focusFollowsWindow = false
A companion window can regain keyboard input through normal desktop focus.
var onPointerActivity: (() -> Unit)? = null
val lastPointerUv: Pair<Double, Double>? get() = lastHit
private var lastHit: Pair<Double, Double>? = null
private var captured = false
private val removers = mutableListOf<() -> Unit>()
private val heldKeys = mutableMapOf<Int, BrowserKeyEvent>()
init
body — 59 lines, from line 164
init {
removers += window.addCursorListener { x, y ->
onPointerActivity?.invoke()
val hit = hitTest(x, y)
if (hit != null) lastHit = hit
val at = hit ?: if (captured) lastHit else null
at?.let { uv.move(it.first, it.second) }
}
removers += window.addMouseButtonListener { button, action, mods ->
val browserButton = browserMouseButton(button) ?: return@addMouseButtonListener
onPointerActivity?.invoke()
uv.modifiers = browserModifiers(mods)
val at = hitTest(window.cursor.x.toDouble(), window.cursor.y.toDouble())
if (at != null) lastHit = at
when (action) {
GLFW.GLFW_PRESS -> if (at != null) {
active = true
captured = true
uv.down(browserButton, at.first, at.second)
}
GLFW.GLFW_RELEASE -> if (captured) {
val releaseAt = at ?: lastHit ?: (0.5 to 0.5)
uv.up(browserButton, releaseAt.first, releaseAt.second)
if (uv.buttons == 0) captured = false
}
}
}
removers += window.addScrollListener { x, y ->
onPointerActivity?.invoke()
val at = hitTest(window.cursor.x.toDouble(), window.cursor.y.toDouble())
if (active && at != null) uv.wheel(-x * 40.0, -y * 40.0, at.first, at.second)
}
removers += window.addKeyListener { key, scancode, action, mods ->
if (!active) return@addKeyListener
uv.modifiers = browserModifiers(mods)
browserKey(key, mods)?.let { descriptor ->
val event = BrowserKeyEvent(
type = if (action == GLFW.GLFW_RELEASE) BrowserKeyEventType.KEY_UP
else BrowserKeyEventType.RAW_KEY_DOWN,
key = descriptor.key,
code = descriptor.code,
modifiers = uv.modifiers,
windowsVirtualKeyCode = descriptor.virtualKey,
nativeVirtualKeyCode = scancode,
autoRepeat = action == GLFW.GLFW_REPEAT,
keypad = descriptor.keypad,
)
if (action == GLFW.GLFW_RELEASE) heldKeys.remove(key) else heldKeys[key] = event
browser.key(event)
}
}
removers += window.addCharListener { codepoint ->
if (active) browser.insertText(String(Character.toChars(codepoint)))
}
removers += window.addFocusListener { focused ->
if (!focused) releaseFocus()
else if (focusFollowsWindow) takeFocus()
}
}
fun takeFocus()
fun takeFocus() {
active = true
browser.focus()
}
fun releaseFocus()
fun releaseFocus() {
if (!active) return
releasePointer()
heldKeys.values.forEach { browser.key(it.copy(type = BrowserKeyEventType.KEY_UP, modifiers = emptySet())) }
heldKeys.clear()
uv.modifiers = emptySet()
active = false
browser.blur()
}
fun releasePointer()
fun releasePointer() {
uv.releaseAll()
captured = false
}
override fun close()
override fun close() {
releaseFocus()
removers.toList().forEach { it() }
removers.clear()
}
}
class PuppetBrowserPanel
A world-space rectangular browser panel with pointer capture for controller or mouse rays.
class PuppetBrowserPanel(
val browser: PuppetBrowser,
var halfWidth: Float,
var halfHeight: Float,
) {
val model = Matrix4f()
val uv by lazy { PuppetBrowserUvInput(browser) }
Lazy: placing and hit-testing the panel must not ask the browser for its viewport.
data class Hit
data class Hit(
val worldPoint: Vector3f,
val u: Double,
val v: Double,
val onPanel: Boolean,
)
private var pressed = false
private var captured = false
private var lastHit: Hit? = null
fun releasePointer()
Cancel capture when a device disappears or another input source takes ownership.
fun releasePointer() {
uv.releaseAll()
pressed = false
captured = false
}
fun panelMvp
Compose this panel's world transform with one eye's view/projection matrices.
fun panelMvp(
view: Matrix4f,
projection: Matrix4f,
destination: Matrix4f = Matrix4f(),
): Matrix4f = destination.set(projection).mul(view).mul(model)
fun place(position: Vector3f, right: Vector3f, up: Vector3f): Matrix4f
fun place(position: Vector3f, right: Vector3f, up: Vector3f): Matrix4f {
val r = Vector3f(right).normalize()
val n = Vector3f(r).cross(up).normalize()
val u = Vector3f(n).cross(r).normalize()
return model.set(
r.x, r.y, r.z, 0f,
u.x, u.y, u.z, 0f,
n.x, n.y, n.z, 0f,
position.x, position.y, position.z, 1f,
)
}
fun faceToward
fun faceToward(
position: Vector3f,
eye: Vector3f,
worldUp: Vector3f = Vector3f(0f, 1f, 0f),
): Matrix4f {
val n = Vector3f(eye).sub(position).normalize()
val r = Vector3f(worldUp).cross(n).normalize()
return place(position, r, Vector3f(n).cross(r).normalize())
}
fun raycast(origin: Vector3f, direction: Vector3f): Hit?
fun raycast(origin: Vector3f, direction: Vector3f): Hit? {
val inverse = Matrix4f(model).invert()
val localOrigin = inverse.transformPosition(Vector3f(origin))
val localDirection = inverse.transformDirection(Vector3f(direction))
if (abs(localDirection.z) <= 1e-6f) return null
val distance = -localOrigin.z / localDirection.z
if (distance <= 0f) return null
val localHit = Vector3f(localOrigin).fma(distance, localDirection)
val worldHit = model.transformPosition(localHit, Vector3f())
val u = localHit.x / (halfWidth * 2.0) + 0.5
val v = 0.5 - localHit.y / (halfHeight * 2.0)
return Hit(worldHit, u, v, abs(localHit.x) <= halfWidth && abs(localHit.y) <= halfHeight)
}
fun interact
fun interact(
origin: Vector3f,
direction: Vector3f,
triggerPressed: Boolean,
scrollY: Double = 0.0,
pointerType: BrowserPointerType = BrowserPointerType.MOUSE,
scrollX: Double = 0.0,
): Hit? {
val hit = raycast(origin, direction)
if (hit != null) lastHit = hit
val at = when {
hit?.onPanel == true -> hit
captured -> hit ?: lastHit
else -> null
}
at?.let { uv.move(it.u, it.v, pointerType) }
if (triggerPressed && !pressed && hit?.onPanel == true) {
captured = true
uv.down(BrowserMouseButton.LEFT, hit.u, hit.v, pointerType)
} else if (!triggerPressed && pressed && captured) {
val releaseAt = at ?: lastHit
if (releaseAt != null) uv.up(BrowserMouseButton.LEFT, releaseAt.u, releaseAt.v, pointerType)
captured = false
}
if ((scrollX != 0.0 || scrollY != 0.0) && at != null) uv.wheel(scrollX, scrollY, at.u, at.v)
pressed = triggerPressed
return hit
}
fun interact
Feed an OpenXR controller directly into this panel.
fun interact(
state: ControllerState,
scrollY: Double = 0.0,
localForward: Vector3f = Vector3f(0f, 0f, 1f),
): Hit? {
if (!state.active || !state.poseValid) {
releasePointer()
return null
}
val (origin, direction) = controllerRay(state, localForward)
return interact(origin, direction, state.selectPressed || state.triggerPressed, scrollY)
}
companion object
companion object {
fun controllerRay
Build a world-space ray from Field's standard OpenXR controller pose.
body — 104 lines, from line 367
fun controllerRay(
state: ControllerState,
localForward: Vector3f = Vector3f(0f, 0f, 1f),
): Pair<Vector3f, Vector3f> {
val direction = Vector3f(localForward)
state.rotation.transform(direction)
return Vector3f(state.position) to direction
}
}
}
private data class BrowserKeyDescriptor(
val key: String,
val code: String,
val virtualKey: Int,
val keypad: Boolean = false,
)
private fun browserModifiers(mods: Int): Set<BrowserModifier> = buildSet {
if (mods and GLFW.GLFW_MOD_ALT != 0) add(BrowserModifier.ALT)
if (mods and GLFW.GLFW_MOD_CONTROL != 0) add(BrowserModifier.CONTROL)
if (mods and GLFW.GLFW_MOD_SUPER != 0) add(BrowserModifier.META)
if (mods and GLFW.GLFW_MOD_SHIFT != 0) add(BrowserModifier.SHIFT)
}
private fun browserMouseButton(button: Int): BrowserMouseButton? = when (button) {
GLFW.GLFW_MOUSE_BUTTON_LEFT -> BrowserMouseButton.LEFT
GLFW.GLFW_MOUSE_BUTTON_RIGHT -> BrowserMouseButton.RIGHT
GLFW.GLFW_MOUSE_BUTTON_MIDDLE -> BrowserMouseButton.MIDDLE
GLFW.GLFW_MOUSE_BUTTON_4 -> BrowserMouseButton.BACK
GLFW.GLFW_MOUSE_BUTTON_5 -> BrowserMouseButton.FORWARD
else -> null
}
private fun browserKey(key: Int, mods: Int): BrowserKeyDescriptor? {
val shift = mods and GLFW.GLFW_MOD_SHIFT != 0
if (key in GLFW.GLFW_KEY_A..GLFW.GLFW_KEY_Z) {
val letter = ('A'.code + key - GLFW.GLFW_KEY_A).toChar()
return BrowserKeyDescriptor(if (shift) "$letter" else "$letter".lowercase(), "Key$letter", letter.code)
}
if (key in GLFW.GLFW_KEY_0..GLFW.GLFW_KEY_9) {
val digit = ('0'.code + key - GLFW.GLFW_KEY_0).toChar()
return BrowserKeyDescriptor("$digit", "Digit$digit", digit.code)
}
if (key in GLFW.GLFW_KEY_F1..GLFW.GLFW_KEY_F25) {
val number = key - GLFW.GLFW_KEY_F1 + 1
return BrowserKeyDescriptor("F$number", "F$number", 111 + number)
}
if (key in GLFW.GLFW_KEY_KP_0..GLFW.GLFW_KEY_KP_9) {
val digit = key - GLFW.GLFW_KEY_KP_0
return BrowserKeyDescriptor("$digit", "Numpad$digit", 96 + digit, keypad = true)
}
return specialBrowserKeys[key]
}
private val specialBrowserKeys = mapOf(
GLFW.GLFW_KEY_SPACE to BrowserKeyDescriptor(" ", "Space", 32),
GLFW.GLFW_KEY_APOSTROPHE to BrowserKeyDescriptor("'", "Quote", 222),
GLFW.GLFW_KEY_COMMA to BrowserKeyDescriptor(",", "Comma", 188),
GLFW.GLFW_KEY_MINUS to BrowserKeyDescriptor("-", "Minus", 189),
GLFW.GLFW_KEY_PERIOD to BrowserKeyDescriptor(".", "Period", 190),
GLFW.GLFW_KEY_SLASH to BrowserKeyDescriptor("/", "Slash", 191),
GLFW.GLFW_KEY_SEMICOLON to BrowserKeyDescriptor(";", "Semicolon", 186),
GLFW.GLFW_KEY_EQUAL to BrowserKeyDescriptor("=", "Equal", 187),
GLFW.GLFW_KEY_LEFT_BRACKET to BrowserKeyDescriptor("[", "BracketLeft", 219),
GLFW.GLFW_KEY_BACKSLASH to BrowserKeyDescriptor("\\", "Backslash", 220),
GLFW.GLFW_KEY_RIGHT_BRACKET to BrowserKeyDescriptor("]", "BracketRight", 221),
GLFW.GLFW_KEY_GRAVE_ACCENT to BrowserKeyDescriptor("`", "Backquote", 192),
GLFW.GLFW_KEY_ESCAPE to BrowserKeyDescriptor("Escape", "Escape", 27),
GLFW.GLFW_KEY_ENTER to BrowserKeyDescriptor("Enter", "Enter", 13),
GLFW.GLFW_KEY_TAB to BrowserKeyDescriptor("Tab", "Tab", 9),
GLFW.GLFW_KEY_BACKSPACE to BrowserKeyDescriptor("Backspace", "Backspace", 8),
GLFW.GLFW_KEY_INSERT to BrowserKeyDescriptor("Insert", "Insert", 45),
GLFW.GLFW_KEY_DELETE to BrowserKeyDescriptor("Delete", "Delete", 46),
GLFW.GLFW_KEY_RIGHT to BrowserKeyDescriptor("ArrowRight", "ArrowRight", 39),
GLFW.GLFW_KEY_LEFT to BrowserKeyDescriptor("ArrowLeft", "ArrowLeft", 37),
GLFW.GLFW_KEY_DOWN to BrowserKeyDescriptor("ArrowDown", "ArrowDown", 40),
GLFW.GLFW_KEY_UP to BrowserKeyDescriptor("ArrowUp", "ArrowUp", 38),
GLFW.GLFW_KEY_PAGE_UP to BrowserKeyDescriptor("PageUp", "PageUp", 33),
GLFW.GLFW_KEY_PAGE_DOWN to BrowserKeyDescriptor("PageDown", "PageDown", 34),
GLFW.GLFW_KEY_HOME to BrowserKeyDescriptor("Home", "Home", 36),
GLFW.GLFW_KEY_END to BrowserKeyDescriptor("End", "End", 35),
GLFW.GLFW_KEY_CAPS_LOCK to BrowserKeyDescriptor("CapsLock", "CapsLock", 20),
GLFW.GLFW_KEY_SCROLL_LOCK to BrowserKeyDescriptor("ScrollLock", "ScrollLock", 145),
GLFW.GLFW_KEY_NUM_LOCK to BrowserKeyDescriptor("NumLock", "NumLock", 144),
GLFW.GLFW_KEY_PRINT_SCREEN to BrowserKeyDescriptor("PrintScreen", "PrintScreen", 44),
GLFW.GLFW_KEY_PAUSE to BrowserKeyDescriptor("Pause", "Pause", 19),
GLFW.GLFW_KEY_LEFT_SHIFT to BrowserKeyDescriptor("Shift", "ShiftLeft", 16),
GLFW.GLFW_KEY_RIGHT_SHIFT to BrowserKeyDescriptor("Shift", "ShiftRight", 16),
GLFW.GLFW_KEY_LEFT_CONTROL to BrowserKeyDescriptor("Control", "ControlLeft", 17),
GLFW.GLFW_KEY_RIGHT_CONTROL to BrowserKeyDescriptor("Control", "ControlRight", 17),
GLFW.GLFW_KEY_LEFT_ALT to BrowserKeyDescriptor("Alt", "AltLeft", 18),
GLFW.GLFW_KEY_RIGHT_ALT to BrowserKeyDescriptor("Alt", "AltRight", 18),
GLFW.GLFW_KEY_LEFT_SUPER to BrowserKeyDescriptor("Meta", "MetaLeft", 91),
GLFW.GLFW_KEY_RIGHT_SUPER to BrowserKeyDescriptor("Meta", "MetaRight", 92),
GLFW.GLFW_KEY_MENU to BrowserKeyDescriptor("ContextMenu", "ContextMenu", 93),
GLFW.GLFW_KEY_KP_DECIMAL to BrowserKeyDescriptor(".", "NumpadDecimal", 110, keypad = true),
GLFW.GLFW_KEY_KP_DIVIDE to BrowserKeyDescriptor("/", "NumpadDivide", 111, keypad = true),
GLFW.GLFW_KEY_KP_MULTIPLY to BrowserKeyDescriptor("*", "NumpadMultiply", 106, keypad = true),
GLFW.GLFW_KEY_KP_SUBTRACT to BrowserKeyDescriptor("-", "NumpadSubtract", 109, keypad = true),
GLFW.GLFW_KEY_KP_ADD to BrowserKeyDescriptor("+", "NumpadAdd", 107, keypad = true),
GLFW.GLFW_KEY_KP_ENTER to BrowserKeyDescriptor("Enter", "NumpadEnter", 13, keypad = true),
GLFW.GLFW_KEY_KP_EQUAL to BrowserKeyDescriptor("=", "NumpadEqual", 187, keypad = true),
)