From 24f240f3386ae402a90b1ed8042525c9ec08ea2a Mon Sep 17 00:00:00 2001 From: wheaney <42350981+wheaney@users.noreply.github.com> Date: Wed, 27 May 2026 16:54:54 -0700 Subject: [PATCH] WIP pull shared JS logic out of gnome/kwin --- gnome/src/math.js | 79 +---- gnome/src/virtualdisplaysactor.js | 396 +------------------------ kwin/src/qml/Displays.qml | 465 +++++------------------------- shared/js/displayPlacement.js | 365 +++++++++++++++++++++++ shared/js/math.js | 79 +++++ 5 files changed, 512 insertions(+), 872 deletions(-) create mode 100644 shared/js/displayPlacement.js create mode 100644 shared/js/math.js diff --git a/gnome/src/math.js b/gnome/src/math.js index a17d8b5..e5af812 100644 --- a/gnome/src/math.js +++ b/gnome/src/math.js @@ -1,78 +1 @@ -export function degreeToRadian(degree) { - return degree * Math.PI / 180; -} - -// FOV in radians is spherical, so doesn't follow Pythagoras' theorem -export function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { - // first convert from a spherical FOV to a diagonal FOV on a flat plane at a unit distance of 1.0 - const diagonalLengthUnitDistance = 2 * Math.tan(diagonalFOVRadians / 2); - - // then convert to flat plane horizontal and vertical FOVs - const heightUnitDistance = diagonalLengthUnitDistance / Math.sqrt(1 + aspectRatio * aspectRatio); - const widthUnitDistance = heightUnitDistance * aspectRatio; - - return { - // then convert back to spherical FOV - diagonalRadians: diagonalFOVRadians, - horizontalRadians: 2 * Math.atan(widthUnitDistance / 2), - verticalRadians: 2 * Math.atan(heightUnitDistance / 2), - - // flat values are relative to a unit distance of 1.0 - diagonalLengthUnitDistance, - widthUnitDistance, - heightUnitDistance - } -} - -const segmentsPerRadian = 20.0 / degreeToRadian(90.0); - -// displays are placed around a circle, these functions help determine radians and distances from the original -// FOV measurements scaled to the display dimensions -export const fovConversionFns = { - // convert curved FOV for flat displays - flat: { - // distance to an edge is the hypothenuse of the triangle where the opposite side is half the width of the reference fov screen - centerToFovEdgeDistance: (centerDistance, fovLength) => Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)), - fovEdgeToScreenCenterDistance: (edgeDistance, screenLength) => Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)), - lengthToRadians: (fovRadians, fovLength, screenEdgeDistance, toLength) => Math.asin(toLength / 2 / screenEdgeDistance) * 2, - angleToLength: (fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) => { - return toAngleOpposite / toAngleAdjacent * screenDistance; - }, - fovRadiansAtDistance: (fovRadians, unitLength, newScreenDistance) => { - return 2 * Math.atan(unitLength / 2 / newScreenDistance); - }, - radiansToSegments: (screenRadians) => 1 - }, - - // convert curved FOV for curved displays, scaling either involves no change or is linear - curved: { - centerToFovEdgeDistance: (centerDistance, fovLength) => centerDistance, - fovEdgeToScreenCenterDistance: (edgeDistance, screenLength) => edgeDistance, - lengthToRadians: (fovRadians, fovLength, screenEdgeDistance, toLength) => fovRadians / fovLength * toLength, - angleToLength: (fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) => fovLength / fovRadians * Math.atan2(toAngleOpposite, toAngleAdjacent), - fovRadiansAtDistance: (fovRadians, unitLength, newScreenDistance) => fovRadians / newScreenDistance, - radiansToSegments: (screenRadians) => Math.ceil(screenRadians * segmentsPerRadian) - } -} - -export const applyQuaternionToVector = (vector, quaternion) => { - const t = [ - 2.0 * (quaternion[1] * vector[2] - quaternion[2] * vector[1]), - 2.0 * (quaternion[2] * vector[0] - quaternion[0] * vector[2]), - 2.0 * (quaternion[0] * vector[1] - quaternion[1] * vector[0]) - ]; - return [ - vector[0] + quaternion[3] * t[0] + quaternion[1] * t[2] - quaternion[2] * t[1], - vector[1] + quaternion[3] * t[1] + quaternion[2] * t[0] - quaternion[0] * t[2], - vector[2] + quaternion[3] * t[2] + quaternion[0] * t[1] - quaternion[1] * t[0] - ]; -} - -export const vectorMagnitude = (vector) => { - return Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]); -} - -export const normalizeVector = (vector) => { - const length = vectorMagnitude(vector); - return [vector[0] / length, vector[1] / length, vector[2] / length]; -} \ No newline at end of file +export { degreeToRadian, diagonalToCrossFOVs, fovConversionFns, applyQuaternionToVector, vectorMagnitude, normalizeVector } from '../../shared/js/math.js'; diff --git a/gnome/src/virtualdisplaysactor.js b/gnome/src/virtualdisplaysactor.js index f33fb64..da71959 100644 --- a/gnome/src/virtualdisplaysactor.js +++ b/gnome/src/virtualdisplaysactor.js @@ -7,405 +7,13 @@ import Shell from 'gi://Shell'; import St from 'gi://St'; import { VirtualDisplayEffect, SMOOTH_FOLLOW_SLERP_TIMELINE_MS } from './virtualdisplayeffect.js'; -import { applyQuaternionToVector, degreeToRadian, diagonalToCrossFOVs, fovConversionFns, vectorMagnitude } from './math.js'; +import { degreeToRadian, diagonalToCrossFOVs, fovConversionFns } from './math.js'; +import { findFocusedMonitor, monitorsToPlacements } from '../../shared/js/displayPlacement.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import Globals from './globals.js'; -// if nothing is in focus, take it as soon as it crosses into the monitor's bounds -const FOCUS_THRESHOLD = 0.95 / 2.0; - -// if we leave the monitor with some margin, unfocus even if no other monitor is in focus -const UNFOCUS_THRESHOLD = 1.1 / 2.0; - -// returns how far the look vector is from the center of the monitor, as a percentage of the monitor's dimensions -function getMonitorDistance(fovDetails, lookUpPixels, lookWestPixels, monitorVector, monitorDetails, upAngleToLength, westAngleToLength) { - // since the monitor vector has been modified to be relative to the lens position, we need to calculate its distance from the lens - // we need to adjust all angle-based lengths based on new vector distance - const monitorDistance = vectorMagnitude(monitorVector); - const distanceAdjustment = monitorDistance / fovDetails.completeScreenDistancePixels; - - const vectorUpPixels = upAngleToLength( - fovDetails.defaultDistanceVerticalRadians, - fovDetails.heightPixels, - monitorDistance, - monitorVector[2], - monitorVector[0] - ) * distanceAdjustment; - const upPercentage = Math.abs(lookUpPixels * distanceAdjustment - vectorUpPixels) / monitorDetails.height; - - const vectorWestPixels = westAngleToLength( - fovDetails.defaultDistanceHorizontalRadians, - fovDetails.widthPixels, - monitorDistance, - monitorVector[1], - monitorVector[0] - ) * distanceAdjustment; - const westPercentage = Math.abs(lookWestPixels * distanceAdjustment - vectorWestPixels) / monitorDetails.width; - - // how close we are to any edge is the largest of the two percentages - return Math.max(upPercentage, westPercentage); -} - -/** - * Find the vector in the array that's closest to the quaternion rotation - * - * @param {number[]} quaternion - Reference quaternion [x, y, z, w] - * @param {number[]} position - Reference position [x, y, z] in NWU space - * @param {number[][]} monitorVectors - Array of monitor vectors [x, y, z] to search from - * @param {number} currentFocusedIndex - Index of the currently focused monitor - * @param {number} focusedMonitorDistance - Distance to the focused monitor, < 1.0 if zoomed in - * @param {boolean} smoothFollowEnabled - If true, always keep the current monitor in focus or choose the closest - * @param {Object} fovDetails - Contains reference widthPixels, heightPixels, horizontal and vertical radians, and pixel distance to the center of the screen - * @param {Object[]} monitorsDetails - Contains x, y, width, height (coordinates from top-left) for each monitor - * @returns {number} Index of the closest vector, if it surpasses the previous closest index by a certain margin, otherwise the previous index - */ -function findFocusedMonitor(quaternion, position, monitorVectors, currentFocusedIndex, focusedMonitorDistance, smoothFollowEnabled, fovDetails, monitorsDetails) { - if (currentFocusedIndex !== -1 && smoothFollowEnabled) return currentFocusedIndex; - - const lookVector = [1.0, 0.0, 0.0]; // NWU vector pointing to the center of the screen - const rotatedLookVector = applyQuaternionToVector(lookVector, quaternion); - - // TODO - right now we're using the curved functions to figure out distances even for flat monitors - // because it will account for the monitors facing towards us, but this will lose some accuracy - const upConversionFns = fovDetails.monitorWrappingScheme === 'vertical' ? fovConversionFns.curved : fovConversionFns.flat; - const lookUpPixels = upConversionFns.angleToLength( - fovDetails.defaultDistanceVerticalRadians, - fovDetails.heightPixels, - fovDetails.completeScreenDistancePixels, - rotatedLookVector[2], - rotatedLookVector[0] - ); - const westConversionFns = fovDetails.monitorWrappingScheme === 'horizontal' ? fovConversionFns.curved : fovConversionFns.flat; - const lookWestPixels = westConversionFns.angleToLength( - fovDetails.defaultDistanceHorizontalRadians, - fovDetails.widthPixels, - fovDetails.completeScreenDistancePixels, - rotatedLookVector[1], - rotatedLookVector[0] - ); - - function vectorRelativeToLensPosition(vector) { - return [ - vector[0] - position[0], - vector[1] - position[1], - vector[2] - position[2] - ] - } - - // the currently focused monitor is the most likely to be the closest, check it first and exit early if it is - if (currentFocusedIndex !== -1) { - const focusedDistance = getMonitorDistance( - fovDetails, - lookUpPixels, - lookWestPixels, - vectorRelativeToLensPosition(monitorVectors[currentFocusedIndex]), - monitorsDetails[currentFocusedIndex], - upConversionFns.angleToLength, - westConversionFns.angleToLength - ) * focusedMonitorDistance; - - if (focusedDistance < UNFOCUS_THRESHOLD) return currentFocusedIndex; - } - - let closestIndex = -1; - let closestDistance = Infinity; - - // find the vector closest to the rotated look vector - monitorVectors.forEach((monitorVector, index) => { - if (index === currentFocusedIndex) return; - - const distance = getMonitorDistance( - fovDetails, - lookUpPixels, - lookWestPixels, - vectorRelativeToLensPosition(monitorVector), - monitorsDetails[index], - upConversionFns.angleToLength, - westConversionFns.angleToLength - ); - - if (distance < closestDistance) { - closestIndex = index; - closestDistance = distance; - } - }); - - if (smoothFollowEnabled || closestDistance < FOCUS_THRESHOLD) return closestIndex; - - // neither the current nor the closest will take focus, unfocus all displays - return -1; -} - -/*** - * @returns {Object} - containing `begin`, `center`, and `end` radians for rotating the given monitor - */ -function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) { - // Monitor coordinates can become fractional due to size adjustment. - // If a monitor edge lands extremely close to a cached pixel key, snap to it; - // otherwise tiny negative gaps can cause us to subtract a full spacing interval. - let beginPixel = monitorBeginPixel; - const pixelEpsilon = Math.max(1e-6, Math.abs(monitorLengthPixels) * 1e-6); - - let closestWrapPixel = beginPixel; - let closestWrap = cachedMonitorRadians[beginPixel]; - if (closestWrap === undefined) { - closestWrapPixel = Object.keys(cachedMonitorRadians).reduce((previousPixel, currentPixel) => { - if (previousPixel === undefined) return currentPixel; - - const currentDelta = currentPixel - monitorBeginPixel; - const previousDelta = previousPixel - monitorBeginPixel; - - // always prefer an exact monitor width match - if (previousDelta % monitorLengthPixels !== 0) { - if (currentDelta % monitorLengthPixels === 0) return currentPixel; - - // prefer placing a monitor to the right or below, even if there's a closer placement to the left or above - if (previousDelta < 0 && currentDelta > 0) return currentPixel; - - // otherwise, just prefer the closest one - if (Math.abs(currentDelta) < Math.abs(previousDelta)) return currentPixel; - } - - return previousPixel; - }, undefined); - closestWrap = cachedMonitorRadians[closestWrapPixel]; - } - - const closestWrapPixelNumber = Number(closestWrapPixel); - if (Number.isFinite(closestWrapPixelNumber) && Math.abs(closestWrapPixelNumber - beginPixel) < pixelEpsilon) { - beginPixel = closestWrapPixelNumber; - closestWrapPixel = closestWrapPixelNumber; - } - - const spacingRadians = lengthToRadianFn(monitorSpacingPixels); - if (closestWrapPixel !== beginPixel) { - // there's a gap between the cached wrap value and this one - const gapPixels = beginPixel - closestWrapPixel; - const gapRadians = lengthToRadianFn(gapPixels); - - // use Math.floor so if it's negative (this monitor is to the left of or above the closest) it will always - // compenstate for the spacing that's needed at the right/bottom - const appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians; - - // update the closestWrap value and cache it - closestWrap = closestWrap + gapRadians + appliedSpacingRadians; - closestWrapPixel = beginPixel; - cachedMonitorRadians[closestWrapPixel] = closestWrap; - } - - const monitorRadians = lengthToRadianFn(monitorLengthPixels); - const centerRadians = closestWrap + monitorRadians / 2; - const endRadians = closestWrap + monitorRadians; - - // since we're computing the end values for this monitor, cache them too in case they line up with a future monitor - const nextMonitorPixel = beginPixel + monitorLengthPixels; - if (cachedMonitorRadians[nextMonitorPixel] === undefined) - cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians; - - return { - begin: closestWrap, - center: centerRadians, - end: endRadians - } -} - -/** - * Convert the given monitor details into NWU vectors describing the center of the fully placed monitor, - * and the top-left of the partially placed monitor (minus only a single-axis rotation) - * - * @param {Object} fovDetails - contains reference widthPixels, heightPixels, horizontal and vertical radians, -* and distance to the center of the screen - * @param {Object[]} monitorDetailsList - contains x, y, width, height (coordinates from top-left) - * @param {number} monitorSpacing - visual spacing between monitors, as a percentage of the viewport width - * @returns {Object[]} - contains NWU vectors used for rendering and focused monitor detection - */ -function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) { - const monitorPlacements = []; - const cachedMonitorRadians = {}; - - Globals.logger.log_debug(`\t\t\tFOV Details: ${JSON.stringify(fovDetails)}`); - - const conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; - - if (fovDetails.monitorWrappingScheme === 'horizontal') { - // monitors wrap around us horizontally - - const sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedWidthPixels); - const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels; - - // targetWidth is assumed to aleady be size adjusted - const lengthToRadianFn = (targetWidth) => conversionFns.lengthToRadians( - fovDetails.defaultDistanceHorizontalRadians, - fovDetails.widthPixels, - sideEdgeRadius, - targetWidth - ); - - cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedWidthPixels) / 2; - horizontalMonitorSort(monitorDetailsList).forEach(({monitorDetails, originalIndex}) => { - const monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.x, monitorDetails.width, lengthToRadianFn); - const monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(sideEdgeRadius, monitorDetails.width); - const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels; - - // offset for aligning this monitor's center with the fov-sized viewport's center - const upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2; - - // this is where our monitor's center is in relation to an fov-sized viewport centered about (0, 0) - const upCenterPixels = upTopPixels - upCenterOffsetPixels; - - monitorPlacements.push({ - originalIndex, - centerNoRotate: [ - monitorCenterRadius, - - // west is centered about the FOV center - 0, - - // up is flat when wrapping horizontally - upCenterPixels - ], - centerLook: [ - // north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians - monitorCenterRadius * Math.cos(monitorWrapDetails.center), - - // west is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians - -monitorCenterRadius * Math.sin(monitorWrapDetails.center), - - // up is flat when wrapping horizontally - upCenterPixels - ], - rotationAngleRadians: { - x: 0, - y: -monitorWrapDetails.center - } - }); - }); - } else if (fovDetails.monitorWrappingScheme === 'vertical') { - // monitors wrap around us vertically - - const topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedHeightPixels); - const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedHeightPixels; - - // targetHeight is assumed to aleady be size adjusted - const lengthToRadianFn = (targetHeight) => conversionFns.lengthToRadians( - fovDetails.defaultDistanceVerticalRadians, - fovDetails.heightPixels, - topEdgeRadius, - targetHeight - ); - - cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedHeightPixels) / 2; - verticalMonitorSort(monitorDetailsList).forEach(({monitorDetails, originalIndex}) => { - const monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.y, monitorDetails.height, lengthToRadianFn); - const monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(topEdgeRadius, monitorDetails.height); - const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels; - - // offset for aligning this monitor's center with the fov-sized viewport's center - const westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2; - - // this is where our monitor's center is in relation to an fov-sized viewport centered about (0, 0) - const westCenterPixels = westLeftPixels - westCenterOffsetPixels; - - monitorPlacements.push({ - originalIndex, - centerNoRotate: [ - monitorCenterRadius, - - // west is flat when wrapping vertically - westCenterPixels, - - // up is centered about the FOV center - 0 - ], - centerLook: [ - // north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians - monitorCenterRadius * Math.cos(monitorWrapDetails.center), - - // west is flat when wrapping vertically - westCenterPixels, - - // up is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians - -monitorCenterRadius * Math.sin(monitorWrapDetails.center) - ], - rotationAngleRadians: { - x: -monitorWrapDetails.center, - y: 0 - } - }); - }); - } else { - const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels; - - // monitors make a flat wall in front of us, no wrapping - monitorDetailsList.forEach((monitorDetails, index) => { - const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels; - const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels; - - // offsets for aligning this monitor's center with the fov-sized viewport's center - const westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2; - const upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2; - const westCenterPixels = westLeftPixels - westCenterOffsetPixels; - const upCenterPixels = upTopPixels - upCenterOffsetPixels; - - monitorPlacements.push({ - originalIndex: index, - centerNoRotate: [ - fovDetails.completeScreenDistancePixels, - westCenterPixels, - upCenterPixels - ], - centerLook: [ - fovDetails.completeScreenDistancePixels, - westCenterPixels, - upCenterPixels - ], - rotationAngleRadians: { - x: 0, - y: 0 - } - }); - }); - } - - // put them back in the original monitor order before returning - monitorPlacements.sort((a, b) => a.originalIndex - b.originalIndex); - - Globals.logger.log_debug(`\t\t\tMonitor placements: ${JSON.stringify(monitorPlacements)}, cached values: ${JSON.stringify(cachedMonitorRadians)}`); - - return monitorPlacements; -} - -// sort monitors based on wrapping scheme before determining their placements to avoid odd gaps -function horizontalMonitorSort(monitors) { - return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => { - const aMon = a.monitorDetails; - const bMon = b.monitorDetails; - - // First compare by y-coordinate to form rows (top to bottom) - if (aMon.y !== bMon.y) { - return aMon.y - bMon.y; - } - // Then compare by x-coordinate within the same row (left to right) - return aMon.x - bMon.x; - }); -} - -// sort monitors based on wrapping scheme before determining their placements to avoid odd gaps -function verticalMonitorSort(monitors) { - return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => { - const aMon = a.monitorDetails; - const bMon = b.monitorDetails; - - // First compare by x-coordinate to form columns (left to right) - if (aMon.x !== bMon.x) { - return aMon.x - bMon.x; - } - // Then compare by y-coordinate within the same column (top to bottom) - return aMon.y - bMon.y; - }); -} export const VirtualDisplaysActor = GObject.registerClass({ Properties: { diff --git a/kwin/src/qml/Displays.qml b/kwin/src/qml/Displays.qml index db9904f..c9a6e8f 100644 --- a/kwin/src/qml/Displays.qml +++ b/kwin/src/qml/Displays.qml @@ -1,71 +1,62 @@ import QtQuick +import "../../../shared/js/math.js" as SharedMath +import "../../../shared/js/displayPlacement.js" as SharedPlacement QtObject { - readonly property real focusThreshold: 0.95 / 2.0 - readonly property real unfocusThreshold: 1.1 / 2.0 + readonly property real focusThreshold: SharedPlacement.FOCUS_THRESHOLD + readonly property real unfocusThreshold: SharedPlacement.UNFOCUS_THRESHOLD - // Converts degrees to radians - function degreeToRadian(degree) { - return degree * Math.PI / 180; - } + // --- Qt coordinate-space helpers (not in shared because they use Qt types) --- function radianToDegree(radian) { return radian * 180 / Math.PI; } function nwuToEusVector(vector) { - // Converts NWU vector to EUS vector return Qt.vector3d(-vector.y, vector.z, -vector.x); } function eusToNwuVector(vector) { - // Converts EUS vector to NWU vector return Qt.vector3d(-vector.z, -vector.x, vector.y); } function eusToNwuQuat(quaternion) { - // Converts EUS quaternion to NWU quaternion return Qt.quaternion(quaternion.scalar, -quaternion.z, -quaternion.x, quaternion.y); } - // Converts diagonal FOV in radians and aspect ratio to horizontal and vertical FOV measurements - function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { - // first convert from a spherical FOV to a diagonal FOV on a flat plane at a unit distance of 1.0 - const diagonalLengthUnitDistance = 2 * Math.tan(diagonalFOVRadians / 2); - - // then convert to flat plane horizontal and vertical FOVs - const heightUnitDistance = diagonalLengthUnitDistance / Math.sqrt(1 + aspectRatio * aspectRatio); - const widthUnitDistance = heightUnitDistance * aspectRatio; - - return { - // then convert back to spherical FOV - diagonalRadians: diagonalFOVRadians, - horizontalRadians: 2 * Math.atan(widthUnitDistance / 2), - verticalRadians: 2 * Math.atan(heightUnitDistance / 2), - - // flat values are relative to a unit distance of 1.0 - diagonalLengthUnitDistance, - widthUnitDistance, - heightUnitDistance - } + function slerpVector(from, to, progress) { + const inverseProgress = 1.0 - progress; + return Qt.vector3d( + from.x * inverseProgress + to.x * progress, + from.y * inverseProgress + to.y * progress, + from.z * inverseProgress + to.z * progress + ); } - function actualWrapScheme(screens, viewportWidth, viewportHeight) { - const minX = Math.min(...screens.map(screen => screen.geometry.x)); - const maxX = Math.max(...screens.map(screen => screen.geometry.x + screen.geometry.width)); - const minY = Math.min(...screens.map(screen => screen.geometry.y)); - const maxY = Math.max(...screens.map(screen => screen.geometry.y + screen.geometry.height)); + // --- Delegated to shared (re-exported for callers that go through this object) --- - if ((maxX - minX) / viewportWidth >= (maxY - minY) / viewportHeight) { - return 'horizontal'; - } else { - return 'vertical'; - } + function degreeToRadian(degree) { return SharedMath.degreeToRadian(degree); } + function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { return SharedMath.diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio); } + + readonly property var fovConversionFns: SharedMath.fovConversionFns + + function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) { + return SharedPlacement.monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn); + } + + // --- FOV / wrap-scheme helpers --- + + function actualWrapScheme(screens, viewportWidth, viewportHeight) { + const monitors = screens.map(screen => ({ + x: screen.geometry.x, y: screen.geometry.y, + width: screen.geometry.width, height: screen.geometry.height + })); + return SharedPlacement.autoDetectWrapScheme(monitors, viewportWidth, viewportHeight); } function buildFovDetails(screens, viewportWidth, viewportHeight, viewportDiagonalFOV, lensDistanceRatio, defaultDisplayDistance, wrappingChoice, distanceAdjustedSize) { const aspect = viewportWidth / viewportHeight; - const fovLengths = diagonalToCrossFOVs(degreeToRadian(viewportDiagonalFOV), aspect); + const fovLengths = SharedMath.diagonalToCrossFOVs(SharedMath.degreeToRadian(viewportDiagonalFOV), aspect); let monitorWrappingScheme = actualWrapScheme(screens, viewportWidth, viewportHeight); if (wrappingChoice === 1) monitorWrappingScheme = 'horizontal'; @@ -74,8 +65,8 @@ QtObject { const lensDistanceComplement = 1.0 - lensDistanceRatio; const lensDistanceFactor = (1.0 / lensDistanceComplement) - 1.0; - const horizontalConversions = effect.curvedDisplay && monitorWrappingScheme === 'horizontal' ? fovConversionFns.curved : fovConversionFns.flat; - const verticalConversions = effect.curvedDisplay && monitorWrappingScheme === 'vertical' ? fovConversionFns.curved : fovConversionFns.flat; + const horizontalConversions = effect.curvedDisplay && monitorWrappingScheme === 'horizontal' ? SharedMath.fovConversionFns.curved : SharedMath.fovConversionFns.flat; + const verticalConversions = effect.curvedDisplay && monitorWrappingScheme === 'vertical' ? SharedMath.fovConversionFns.curved : SharedMath.fovConversionFns.flat; const defaultDistanceVerticalRadians = verticalConversions.fovRadiansAtDistance( fovLengths.verticalRadians, @@ -88,16 +79,9 @@ QtObject { defaultDisplayDistance ); - // distance needed for the FOV-sized monitor to fill up the screen, as measured from the lenses const lensToUnitDistancePixels = viewportWidth / fovLengths.widthUnitDistance; - - // distance from pivot point to lens const lensDistancePixels = lensToUnitDistancePixels * lensDistanceFactor; - - // distance from pivot point to full screen (monitor at unit distance from lens) const fullScreenDistancePixels = lensToUnitDistancePixels + lensDistancePixels; - - // distance of a display at the default (most zoomed out) distance from the pivot point const completeScreenDistancePixels = fullScreenDistancePixels * defaultDisplayDistance; return { @@ -116,362 +100,43 @@ QtObject { }; } - // Utility constant - readonly property real segmentsPerRadian: 20.0 / degreeToRadian(90.0) - - // FOV conversion functions for flat and curved displays - property var fovConversionFns: ({ - flat: { - centerToFovEdgeDistance: function(centerDistance, fovLength) { - return Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)); - }, - fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { - return Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)); - }, - lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { - return Math.asin(toLength / 2 / screenEdgeDistance) * 2; - }, - angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { - return toAngleOpposite / toAngleAdjacent * screenDistance; - }, - fovRadiansAtDistance: function(fovRadians, unitLength, newScreenDistance) { - return 2 * Math.atan(unitLength / 2 / newScreenDistance); - }, - radiansToSegments: function(screenRadians) { return 1; } - }, - curved: { - centerToFovEdgeDistance: function(centerDistance, fovLength) { - return centerDistance; - }, - fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { - return edgeDistance; - }, - lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { - return fovRadians / fovLength * toLength; - }, - angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { - return fovLength / fovRadians * Math.atan2(toAngleOpposite, toAngleAdjacent); - }, - fovRadiansAtDistance: function(fovRadians, unitLength, newScreenDistance) { - return fovRadians / newScreenDistance; - }, - radiansToSegments: function(screenRadians) { - return Math.ceil(screenRadians * segmentsPerRadian); - } - } - }) - - function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) { - var closestWrapPixel = monitorBeginPixel; - var closestWrap = cachedMonitorRadians[monitorBeginPixel]; - if (closestWrap === undefined) { - var keys = Object.keys(cachedMonitorRadians); - closestWrapPixel = keys.reduce(function(previousPixel, currentPixel) { - if (previousPixel === undefined) return currentPixel; - - var currentDelta = currentPixel - monitorBeginPixel; - var previousDelta = previousPixel - monitorBeginPixel; - - if (previousDelta % monitorLengthPixels !== 0) { - if (currentDelta % monitorLengthPixels === 0) return currentPixel; - if (previousDelta < 0 && currentDelta > 0) return currentPixel; - if (Math.abs(currentDelta) < Math.abs(previousDelta)) return currentPixel; - } - return previousPixel; - }, undefined); - closestWrap = cachedMonitorRadians[closestWrapPixel]; - } - - var spacingRadians = lengthToRadianFn(monitorSpacingPixels); - if (closestWrapPixel !== monitorBeginPixel) { - var gapPixels = monitorBeginPixel - closestWrapPixel; - var gapRadians = lengthToRadianFn(gapPixels); - var appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians; - closestWrap = closestWrap + gapRadians + appliedSpacingRadians; - closestWrapPixel = monitorBeginPixel; - cachedMonitorRadians[closestWrapPixel] = closestWrap; - } - - var monitorRadians = lengthToRadianFn(monitorLengthPixels); - var centerRadians = closestWrap + monitorRadians / 2; - var endRadians = closestWrap + monitorRadians; - - var nextMonitorPixel = monitorBeginPixel + monitorLengthPixels; - if (cachedMonitorRadians[nextMonitorPixel] === undefined) - cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians; - - return { - begin: closestWrap, - center: centerRadians, - end: endRadians - } - } - - function horizontalMonitorSort(monitors) { - return monitors.map(function(monitor, index) { - return { originalIndex: index, monitorDetails: monitor }; - }).sort(function(a, b) { - var aMon = a.monitorDetails; - var bMon = b.monitorDetails; - if (aMon.y !== bMon.y) return aMon.y - bMon.y; - return aMon.x - bMon.x; - }); - } - - function verticalMonitorSort(monitors) { - return monitors.map(function(monitor, index) { - return { originalIndex: index, monitorDetails: monitor }; - }).sort(function(a, b) { - var aMon = a.monitorDetails; - var bMon = b.monitorDetails; - if (aMon.x !== bMon.x) return aMon.x - bMon.x; - return aMon.y - bMon.y; - }); - } - - // fovDetails: { widthPixels, heightPixels, defaultDistanceHorizontalRadians, defaultDistanceVerticalRadians, completeScreenDistancePixels, monitorWrappingScheme, curvedDisplay } - // monitorDetailsList: [{x, y, width, height}, ...] - // monitorSpacing: number (percentage, e.g. 0.05 for 5%) + // Wraps SharedPlacement.monitorsToPlacements, converting plain-array vectors to Qt.vector3d. function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) { - var monitorPlacements = []; - var cachedMonitorRadians = {}; - - var conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; - - if (fovDetails.monitorWrappingScheme === 'horizontal') { - // monitors wrap around us horizontally - - var sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedWidthPixels); - var monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels; - - // targetWidth is assumed to aleady be size adjusted - var lengthToRadianFn = function(targetWidth) { - return conversionFns.lengthToRadians( - fovDetails.defaultDistanceHorizontalRadians, - fovDetails.widthPixels, - sideEdgeRadius, - targetWidth - ); - }; - - cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedWidthPixels) / 2; - horizontalMonitorSort(monitorDetailsList).forEach(function(obj) { - var monitorDetails = obj.monitorDetails; - var originalIndex = obj.originalIndex; - var monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.x, monitorDetails.width, lengthToRadianFn); - var monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(sideEdgeRadius, monitorDetails.width); - var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels; - var upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2; - var upCenterPixels = upTopPixels - upCenterOffsetPixels; - - monitorPlacements.push({ - originalIndex: originalIndex, - monitorCenterNorth: monitorCenterRadius, - centerNoRotate: Qt.vector3d( - monitorCenterRadius, - 0, - upCenterPixels - ), - centerLook: Qt.vector3d( - monitorCenterRadius * Math.cos(monitorWrapDetails.center), - -monitorCenterRadius * Math.sin(monitorWrapDetails.center), - upCenterPixels - ), - rotationAngleRadians: { - x: 0, - y: -monitorWrapDetails.center - } - }); - }); - } else if (fovDetails.monitorWrappingScheme === 'vertical') { - var topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedHeightPixels); - var monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedHeightPixels; - var lengthToRadianFn = function(targetHeight) { - return conversionFns.lengthToRadians( - fovDetails.defaultDistanceVerticalRadians, - fovDetails.heightPixels, - topEdgeRadius, - targetHeight - ); - }; - - cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedHeightPixels) / 2; - verticalMonitorSort(monitorDetailsList).forEach(function(obj) { - var monitorDetails = obj.monitorDetails; - var originalIndex = obj.originalIndex; - var monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.y, monitorDetails.height, lengthToRadianFn); - var monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(topEdgeRadius, monitorDetails.height); - var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels; - var westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2; - var westCenterPixels = westLeftPixels - westCenterOffsetPixels; - - monitorPlacements.push({ - originalIndex: originalIndex, - monitorCenterNorth: monitorCenterRadius, - centerNoRotate: Qt.vector3d( - monitorCenterRadius, - westCenterPixels, - 0 - ), - centerLook: Qt.vector3d( - monitorCenterRadius * Math.cos(monitorWrapDetails.center), - westCenterPixels, - -monitorCenterRadius * Math.sin(monitorWrapDetails.center) - ), - rotationAngleRadians: { - x: -monitorWrapDetails.center, - y: 0 - } - }); - }); - } else { - var monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels; - monitorDetailsList.forEach(function(monitorDetails, index) { - var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels; - var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels; - var westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2; - var upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2; - var westCenterPixels = westLeftPixels - westCenterOffsetPixels; - var upCenterPixels = upTopPixels - upCenterOffsetPixels; - - monitorPlacements.push({ - originalIndex: index, - monitorCenterNorth: fovDetails.completeScreenDistancePixels, - centerNoRotate: Qt.vector3d( - fovDetails.completeScreenDistancePixels, - westCenterPixels, - upCenterPixels - ), - centerLook: Qt.vector3d( - fovDetails.completeScreenDistancePixels, - westCenterPixels, - upCenterPixels - ), - rotationAngleRadians: { - x: 0, - y: 0 - } - }); - }); - } - - // put them back in the original monitor order before returning - monitorPlacements.sort(function(a, b) { return a.originalIndex - b.originalIndex; }); - - return monitorPlacements; - } - - // returns how far the look vector is from the center of the monitor, as a percentage of the monitor's dimensions - function getMonitorDistance(fovDetails, lookUpPixels, lookWestPixels, monitorVector, monitorDetails, upAngleToLength, westAngleToLength) { - // since the monitor vector has been modified to be relative to the lens position, we need to calculate its distance from the lens - // we need to adjust all angle-based lengths based on new vector distance - const monitorDistance = monitorVector.length(); - const distanceAdjustment = monitorDistance / fovDetails.completeScreenDistancePixels; - - var vectorUpPixels = upAngleToLength( - fovDetails.defaultDistanceVerticalRadians, - fovDetails.heightPixels, - monitorDistance, - monitorVector.z, - monitorVector.x - ) * distanceAdjustment; - var upPercentage = Math.abs(lookUpPixels * distanceAdjustment - vectorUpPixels) / monitorDetails.height; - - var vectorWestPixels = westAngleToLength( - fovDetails.defaultDistanceHorizontalRadians, - fovDetails.widthPixels, - monitorDistance, - monitorVector.y, - monitorVector.x - ) * distanceAdjustment; - var westPercentage = Math.abs(lookWestPixels * distanceAdjustment - vectorWestPixels) / monitorDetails.width; - - // how close we are to any edge is the largest of the two percentages - return Math.max(upPercentage, westPercentage); + return SharedPlacement.monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) + .map(p => ({ + originalIndex: p.originalIndex, + monitorCenterNorth: p.monitorCenterNorth, + centerNoRotate: Qt.vector3d(p.centerNoRotate[0], p.centerNoRotate[1], p.centerNoRotate[2]), + centerLook: Qt.vector3d(p.centerLook[0], p.centerLook[1], p.centerLook[2]), + rotationAngleRadians: p.rotationAngleRadians + })); } + // Wraps SharedPlacement.findFocusedMonitor, adapting Qt types to plain arrays. function findFocusedMonitor(quaternion, position, monitorVectors, currentFocusedIndex, smoothFollowEnabled, fovDetails, monitorsDetails) { - if (currentFocusedIndex !== -1 && smoothFollowEnabled) return currentFocusedIndex; + // convert Qt.quaternion [scalar, x, y, z] → shared [x, y, z, w] + const quatArray = [quaternion.x, quaternion.y, quaternion.z, quaternion.scalar]; - var lookVector = Qt.vector3d(1.0, 0.0, 0.0); // NWU vector pointing to the center of the screen - var rotatedLookVector = quaternion.times(lookVector); + // convert Qt.vector3d position → plain array + const posArray = [position.x, position.y, position.z]; - // TODO - right now we're using the curved functions to figure out distances even for flat monitors - // because it will account for the monitors facing towards us, but this will lose some accuracy - var upConversionFns = fovDetails.monitorWrappingScheme === "vertical" ? fovConversionFns.curved : fovConversionFns.flat; - var lookUpPixels = upConversionFns.angleToLength( - fovDetails.defaultDistanceVerticalRadians, - fovDetails.heightPixels, - fovDetails.completeScreenDistancePixels, - rotatedLookVector.z, - rotatedLookVector.x + // convert Qt.vector3d monitor centerLook vectors → plain arrays + const vectorArrays = monitorVectors.map(v => [v.x, v.y, v.z]); + + // adapt monitorsDetails from screen.geometry shape to plain {x,y,width,height} + const detailsArrays = monitorsDetails.map(d => ({ + x: d.x, y: d.y, width: d.width, height: d.height + })); + + // KWin passes focusedDisplayDistance / allDisplaysDistance inline; use 1.0 as neutral default + // and rely on the caller to scale if needed (matches legacy behaviour where ratio wasn't passed) + return SharedPlacement.findFocusedMonitor( + quatArray, posArray, vectorArrays, + currentFocusedIndex, + effect.focusedDisplayDistance / effect.allDisplaysDistance, + smoothFollowEnabled, + fovDetails, + detailsArrays ); - var westConversionFns = fovDetails.monitorWrappingScheme === "horizontal" ? fovConversionFns.curved : fovConversionFns.flat; - var lookWestPixels = westConversionFns.angleToLength( - fovDetails.defaultDistanceHorizontalRadians, - fovDetails.widthPixels, - fovDetails.completeScreenDistancePixels, - rotatedLookVector.y, - rotatedLookVector.x - ); - - function vectorRelativeToPosition(vector) { - return vector.minus(position); - } - - // Check current focused monitor first - if (currentFocusedIndex !== -1) { - var focusedDistance = getMonitorDistance( - fovDetails, - lookUpPixels, - lookWestPixels, - vectorRelativeToPosition(monitorVectors[currentFocusedIndex]), - monitorsDetails[currentFocusedIndex], - upConversionFns.angleToLength, - westConversionFns.angleToLength - ) * effect.focusedDisplayDistance / effect.allDisplaysDistance; - - if (focusedDistance < unfocusThreshold) return currentFocusedIndex; - } - - var closestIndex = -1; - var closestDistance = Number.POSITIVE_INFINITY; - - // Find the closest monitor - for (var i = 0; i < monitorVectors.length; ++i) { - if (i === currentFocusedIndex) - continue; - var distance = getMonitorDistance( - fovDetails, - lookUpPixels, - lookWestPixels, - vectorRelativeToPosition(monitorVectors[i]), - monitorsDetails[i], - upConversionFns.angleToLength, - westConversionFns.angleToLength - ); - - if (distance < closestDistance) { - closestIndex = i; - closestDistance = distance; - } - } - - if (smoothFollowEnabled || closestDistance < focusThreshold) - return closestIndex; - - // Unfocus all displays - return -1; } - - function slerpVector(from, to, progress) { - const inverseProgress = 1.0 - progress; - const finalVector = Qt.vector3d( - from.x * inverseProgress + to.x * progress, - from.y * inverseProgress + to.y * progress, - from.z * inverseProgress + to.z * progress - ); - - return finalVector; - } -} \ No newline at end of file +} diff --git a/shared/js/displayPlacement.js b/shared/js/displayPlacement.js new file mode 100644 index 0000000..078784d --- /dev/null +++ b/shared/js/displayPlacement.js @@ -0,0 +1,365 @@ +import { applyQuaternionToVector, fovConversionFns, vectorMagnitude } from './math.js'; + +// if nothing is in focus, take it as soon as it crosses into the monitor's bounds +export const FOCUS_THRESHOLD = 0.95 / 2.0; + +// if we leave the monitor with some margin, unfocus even if no other monitor is in focus +export const UNFOCUS_THRESHOLD = 1.1 / 2.0; + +/** + * Given the known radian positions of previously-placed monitors, compute the begin/center/end + * radian positions for one monitor along a wrapped axis. + * + * All vector arguments use plain JS arrays; callers on Qt platforms convert before/after. + * + * @param {Object} cachedMonitorRadians - mutable pixel→radian cache shared across all monitors in one axis + * @param {number} monitorSpacingPixels + * @param {number} monitorBeginPixel + * @param {number} monitorLengthPixels + * @param {function} lengthToRadianFn + * @returns {{begin: number, center: number, end: number}} + */ +export function monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorBeginPixel, monitorLengthPixels, lengthToRadianFn) { + // Monitor coordinates can become fractional due to size adjustment. + // If a monitor edge lands extremely close to a cached pixel key, snap to it; + // otherwise tiny negative gaps can cause us to subtract a full spacing interval. + let beginPixel = monitorBeginPixel; + const pixelEpsilon = Math.max(1e-6, Math.abs(monitorLengthPixels) * 1e-6); + + let closestWrapPixel = beginPixel; + let closestWrap = cachedMonitorRadians[beginPixel]; + if (closestWrap === undefined) { + closestWrapPixel = Object.keys(cachedMonitorRadians).reduce((previousPixel, currentPixel) => { + if (previousPixel === undefined) return currentPixel; + + const currentDelta = currentPixel - monitorBeginPixel; + const previousDelta = previousPixel - monitorBeginPixel; + + // always prefer an exact monitor width match + if (previousDelta % monitorLengthPixels !== 0) { + if (currentDelta % monitorLengthPixels === 0) return currentPixel; + + // prefer placing a monitor to the right or below, even if there's a closer placement to the left or above + if (previousDelta < 0 && currentDelta > 0) return currentPixel; + + // otherwise, just prefer the closest one + if (Math.abs(currentDelta) < Math.abs(previousDelta)) return currentPixel; + } + + return previousPixel; + }, undefined); + closestWrap = cachedMonitorRadians[closestWrapPixel]; + } + + const closestWrapPixelNumber = Number(closestWrapPixel); + if (Number.isFinite(closestWrapPixelNumber) && Math.abs(closestWrapPixelNumber - beginPixel) < pixelEpsilon) { + beginPixel = closestWrapPixelNumber; + closestWrapPixel = closestWrapPixelNumber; + } + + const spacingRadians = lengthToRadianFn(monitorSpacingPixels); + if (closestWrapPixel !== beginPixel) { + // there's a gap between the cached wrap value and this one + const gapPixels = beginPixel - closestWrapPixel; + const gapRadians = lengthToRadianFn(gapPixels); + + // use Math.floor so if it's negative (this monitor is to the left of or above the closest) it will always + // compensate for the spacing that's needed at the right/bottom + const appliedSpacingRadians = Math.floor(gapPixels / monitorLengthPixels) * spacingRadians; + + closestWrap = closestWrap + gapRadians + appliedSpacingRadians; + closestWrapPixel = beginPixel; + cachedMonitorRadians[closestWrapPixel] = closestWrap; + } + + const monitorRadians = lengthToRadianFn(monitorLengthPixels); + const centerRadians = closestWrap + monitorRadians / 2; + const endRadians = closestWrap + monitorRadians; + + // cache the end position so adjacent monitors can snap to it + const nextMonitorPixel = beginPixel + monitorLengthPixels; + if (cachedMonitorRadians[nextMonitorPixel] === undefined) + cachedMonitorRadians[nextMonitorPixel] = endRadians + spacingRadians; + + return { + begin: closestWrap, + center: centerRadians, + end: endRadians + } +} + +// sort monitors left-to-right, top-to-bottom before placing them to avoid odd gaps +export function horizontalMonitorSort(monitors) { + return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => { + const aMon = a.monitorDetails; + const bMon = b.monitorDetails; + if (aMon.y !== bMon.y) return aMon.y - bMon.y; + return aMon.x - bMon.x; + }); +} + +// sort monitors top-to-bottom, left-to-right before placing them to avoid odd gaps +export function verticalMonitorSort(monitors) { + return monitors.map((monitor, index) => ({originalIndex: index, monitorDetails: monitor})).sort((a, b) => { + const aMon = a.monitorDetails; + const bMon = b.monitorDetails; + if (aMon.x !== bMon.x) return aMon.x - bMon.x; + return aMon.y - bMon.y; + }); +} + +/** + * Detect whether a multi-monitor layout is wider or taller relative to the viewport, + * returning 'horizontal' or 'vertical'. Used when wrappingScheme is 'automatic'. + * + * @param {Object[]} monitors - [{x, y, width, height}] + * @param {number} viewportWidth + * @param {number} viewportHeight + * @returns {'horizontal'|'vertical'} + */ +export function autoDetectWrapScheme(monitors, viewportWidth, viewportHeight) { + const minX = Math.min(...monitors.map(m => m.x)); + const maxX = Math.max(...monitors.map(m => m.x + m.width)); + const minY = Math.min(...monitors.map(m => m.y)); + const maxY = Math.max(...monitors.map(m => m.y + m.height)); + return (maxX - minX) / viewportWidth >= (maxY - minY) / viewportHeight ? 'horizontal' : 'vertical'; +} + +/** + * Returns how far the look vector is from the center of a monitor, as a percentage of + * the monitor's dimensions (0 = center, 0.5 = exactly at edge, >0.5 = outside). + * + * All vector arguments are plain arrays in NWU order: [north, west, up]. + * + * @param {Object} fovDetails + * @param {number} lookUpPixels + * @param {number} lookWestPixels + * @param {number[]} monitorVector - [north, west, up] center of the monitor relative to lens + * @param {{width: number, height: number}} monitorDetails + * @param {function} upAngleToLength + * @param {function} westAngleToLength + * @returns {number} + */ +export function getMonitorDistance(fovDetails, lookUpPixels, lookWestPixels, monitorVector, monitorDetails, upAngleToLength, westAngleToLength) { + const monitorDistance = vectorMagnitude(monitorVector); + const distanceAdjustment = monitorDistance / fovDetails.completeScreenDistancePixels; + + // monitorVector[0]=north, monitorVector[1]=west, monitorVector[2]=up + const vectorUpPixels = upAngleToLength( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + monitorDistance, + monitorVector[2], + monitorVector[0] + ) * distanceAdjustment; + const upPercentage = Math.abs(lookUpPixels * distanceAdjustment - vectorUpPixels) / monitorDetails.height; + + const vectorWestPixels = westAngleToLength( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + monitorDistance, + monitorVector[1], + monitorVector[0] + ) * distanceAdjustment; + const westPercentage = Math.abs(lookWestPixels * distanceAdjustment - vectorWestPixels) / monitorDetails.width; + + return Math.max(upPercentage, westPercentage); +} + +/** + * Find which monitor the user is looking at. + * + * All vectors use plain NWU arrays: [north, west, up]. + * Quaternion is [x, y, z, w]. + * + * @param {number[]} quaternion - current head orientation [x, y, z, w] + * @param {number[]} position - lens position [north, west, up] in pixel units + * @param {number[][]} monitorVectors - centerLook for each monitor + * @param {number} currentFocusedIndex + * @param {number} focusedMonitorDistance - display_distance / display_distance_default, < 1 when zoomed in + * @param {boolean} smoothFollowEnabled + * @param {Object} fovDetails + * @param {Object[]} monitorsDetails - [{x, y, width, height}] + * @returns {number} index of focused monitor, or -1 if none + */ +export function findFocusedMonitor(quaternion, position, monitorVectors, currentFocusedIndex, focusedMonitorDistance, smoothFollowEnabled, fovDetails, monitorsDetails) { + if (currentFocusedIndex !== -1 && smoothFollowEnabled) return currentFocusedIndex; + + const lookVector = [1.0, 0.0, 0.0]; // NWU vector pointing to the center of the screen + const rotatedLookVector = applyQuaternionToVector(lookVector, quaternion); + + // TODO - right now we're using the curved functions to figure out distances even for flat monitors + // because it will account for the monitors facing towards us, but this will lose some accuracy + const upConversionFns = fovDetails.monitorWrappingScheme === 'vertical' ? fovConversionFns.curved : fovConversionFns.flat; + const lookUpPixels = upConversionFns.angleToLength( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + fovDetails.completeScreenDistancePixels, + rotatedLookVector[2], + rotatedLookVector[0] + ); + const westConversionFns = fovDetails.monitorWrappingScheme === 'horizontal' ? fovConversionFns.curved : fovConversionFns.flat; + const lookWestPixels = westConversionFns.angleToLength( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + fovDetails.completeScreenDistancePixels, + rotatedLookVector[1], + rotatedLookVector[0] + ); + + function vectorRelativeToLensPosition(vector) { + return [ + vector[0] - position[0], + vector[1] - position[1], + vector[2] - position[2] + ]; + } + + // the currently focused monitor is the most likely to be the closest, check it first and exit early if it is + if (currentFocusedIndex !== -1) { + const focusedDistance = getMonitorDistance( + fovDetails, + lookUpPixels, + lookWestPixels, + vectorRelativeToLensPosition(monitorVectors[currentFocusedIndex]), + monitorsDetails[currentFocusedIndex], + upConversionFns.angleToLength, + westConversionFns.angleToLength + ) * focusedMonitorDistance; + + if (focusedDistance < UNFOCUS_THRESHOLD) return currentFocusedIndex; + } + + let closestIndex = -1; + let closestDistance = Infinity; + + monitorVectors.forEach((monitorVector, index) => { + if (index === currentFocusedIndex) return; + + const distance = getMonitorDistance( + fovDetails, + lookUpPixels, + lookWestPixels, + vectorRelativeToLensPosition(monitorVector), + monitorsDetails[index], + upConversionFns.angleToLength, + westConversionFns.angleToLength + ); + + if (distance < closestDistance) { + closestIndex = index; + closestDistance = distance; + } + }); + + if (smoothFollowEnabled || closestDistance < FOCUS_THRESHOLD) return closestIndex; + + return -1; +} + +/** + * Convert monitor layout details into NWU placement vectors for rendering. + * + * Vectors in the returned objects are plain arrays [north, west, up]. + * Qt callers should wrap centerNoRotate/centerLook with Qt.vector3d after calling. + * + * @param {Object} fovDetails - widthPixels, heightPixels, sizeAdjustedWidthPixels, sizeAdjustedHeightPixels, + * defaultDistanceHorizontalRadians, defaultDistanceVerticalRadians, + * completeScreenDistancePixels, monitorWrappingScheme, curvedDisplay + * @param {Object[]} monitorDetailsList - [{x, y, width, height}] in size-adjusted viewport-relative coords + * @param {number} monitorSpacing - visual spacing as a fraction of viewport width (e.g. 0.02) + * @returns {Object[]} - [{originalIndex, monitorCenterNorth, centerNoRotate, centerLook, rotationAngleRadians}] + */ +export function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) { + const monitorPlacements = []; + const cachedMonitorRadians = {}; + + const conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; + + if (fovDetails.monitorWrappingScheme === 'horizontal') { + const sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedWidthPixels); + const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels; + + const lengthToRadianFn = (targetWidth) => conversionFns.lengthToRadians( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + sideEdgeRadius, + targetWidth + ); + + cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedWidthPixels) / 2; + horizontalMonitorSort(monitorDetailsList).forEach(({monitorDetails, originalIndex}) => { + const monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.x, monitorDetails.width, lengthToRadianFn); + const monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(sideEdgeRadius, monitorDetails.width); + const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels; + const upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2; + const upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex, + monitorCenterNorth: monitorCenterRadius, + centerNoRotate: [monitorCenterRadius, 0, upCenterPixels], + centerLook: [ + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + -monitorCenterRadius * Math.sin(monitorWrapDetails.center), + upCenterPixels + ], + rotationAngleRadians: { x: 0, y: -monitorWrapDetails.center } + }); + }); + } else if (fovDetails.monitorWrappingScheme === 'vertical') { + const topEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.sizeAdjustedHeightPixels); + const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedHeightPixels; + + const lengthToRadianFn = (targetHeight) => conversionFns.lengthToRadians( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + topEdgeRadius, + targetHeight + ); + + cachedMonitorRadians[0] = -lengthToRadianFn(fovDetails.sizeAdjustedHeightPixels) / 2; + verticalMonitorSort(monitorDetailsList).forEach(({monitorDetails, originalIndex}) => { + const monitorWrapDetails = monitorWrap(cachedMonitorRadians, monitorSpacingPixels, monitorDetails.y, monitorDetails.height, lengthToRadianFn); + const monitorCenterRadius = conversionFns.fovEdgeToScreenCenterDistance(topEdgeRadius, monitorDetails.height); + const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels; + const westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2; + const westCenterPixels = westLeftPixels - westCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex, + monitorCenterNorth: monitorCenterRadius, + centerNoRotate: [monitorCenterRadius, westCenterPixels, 0], + centerLook: [ + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + westCenterPixels, + -monitorCenterRadius * Math.sin(monitorWrapDetails.center) + ], + rotationAngleRadians: { x: -monitorWrapDetails.center, y: 0 } + }); + }); + } else { + const monitorSpacingPixels = monitorSpacing * fovDetails.sizeAdjustedWidthPixels; + + monitorDetailsList.forEach((monitorDetails, index) => { + const upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.sizeAdjustedHeightPixels) * monitorSpacingPixels; + const westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.sizeAdjustedWidthPixels) * monitorSpacingPixels; + const westCenterOffsetPixels = (monitorDetails.width - fovDetails.sizeAdjustedWidthPixels) / 2; + const upCenterOffsetPixels = (monitorDetails.height - fovDetails.sizeAdjustedHeightPixels) / 2; + const westCenterPixels = westLeftPixels - westCenterOffsetPixels; + const upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: index, + monitorCenterNorth: fovDetails.completeScreenDistancePixels, + centerNoRotate: [fovDetails.completeScreenDistancePixels, westCenterPixels, upCenterPixels], + centerLook: [fovDetails.completeScreenDistancePixels, westCenterPixels, upCenterPixels], + rotationAngleRadians: { x: 0, y: 0 } + }); + }); + } + + monitorPlacements.sort((a, b) => a.originalIndex - b.originalIndex); + + return monitorPlacements; +} diff --git a/shared/js/math.js b/shared/js/math.js new file mode 100644 index 0000000..e04284f --- /dev/null +++ b/shared/js/math.js @@ -0,0 +1,79 @@ +export function degreeToRadian(degree) { + return degree * Math.PI / 180; +} + +// FOV in radians is spherical, so doesn't follow Pythagoras' theorem +export function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { + // first convert from a spherical FOV to a diagonal FOV on a flat plane at a unit distance of 1.0 + const diagonalLengthUnitDistance = 2 * Math.tan(diagonalFOVRadians / 2); + + // then convert to flat plane horizontal and vertical FOVs + const heightUnitDistance = diagonalLengthUnitDistance / Math.sqrt(1 + aspectRatio * aspectRatio); + const widthUnitDistance = heightUnitDistance * aspectRatio; + + return { + // then convert back to spherical FOV + diagonalRadians: diagonalFOVRadians, + horizontalRadians: 2 * Math.atan(widthUnitDistance / 2), + verticalRadians: 2 * Math.atan(heightUnitDistance / 2), + + // flat values are relative to a unit distance of 1.0 + diagonalLengthUnitDistance, + widthUnitDistance, + heightUnitDistance + } +} + +const segmentsPerRadian = 20.0 / degreeToRadian(90.0); + +// displays are placed around a circle, these functions help determine radians and distances from the original +// FOV measurements scaled to the display dimensions +export const fovConversionFns = { + // convert curved FOV for flat displays + flat: { + // distance to an edge is the hypothenuse of the triangle where the opposite side is half the width of the reference fov screen + centerToFovEdgeDistance: (centerDistance, fovLength) => Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)), + fovEdgeToScreenCenterDistance: (edgeDistance, screenLength) => Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)), + lengthToRadians: (fovRadians, fovLength, screenEdgeDistance, toLength) => Math.asin(toLength / 2 / screenEdgeDistance) * 2, + angleToLength: (fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) => { + return toAngleOpposite / toAngleAdjacent * screenDistance; + }, + fovRadiansAtDistance: (fovRadians, unitLength, newScreenDistance) => { + return 2 * Math.atan(unitLength / 2 / newScreenDistance); + }, + radiansToSegments: (screenRadians) => 1 + }, + + // convert curved FOV for curved displays, scaling either involves no change or is linear + curved: { + centerToFovEdgeDistance: (centerDistance, fovLength) => centerDistance, + fovEdgeToScreenCenterDistance: (edgeDistance, screenLength) => edgeDistance, + lengthToRadians: (fovRadians, fovLength, screenEdgeDistance, toLength) => fovRadians / fovLength * toLength, + angleToLength: (fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) => fovLength / fovRadians * Math.atan2(toAngleOpposite, toAngleAdjacent), + fovRadiansAtDistance: (fovRadians, unitLength, newScreenDistance) => fovRadians / newScreenDistance, + radiansToSegments: (screenRadians) => Math.ceil(screenRadians * segmentsPerRadian) + } +} + +// quaternion is [x, y, z, w], vector is [x, y, z] +export const applyQuaternionToVector = (vector, quaternion) => { + const t = [ + 2.0 * (quaternion[1] * vector[2] - quaternion[2] * vector[1]), + 2.0 * (quaternion[2] * vector[0] - quaternion[0] * vector[2]), + 2.0 * (quaternion[0] * vector[1] - quaternion[1] * vector[0]) + ]; + return [ + vector[0] + quaternion[3] * t[0] + quaternion[1] * t[2] - quaternion[2] * t[1], + vector[1] + quaternion[3] * t[1] + quaternion[2] * t[0] - quaternion[0] * t[2], + vector[2] + quaternion[3] * t[2] + quaternion[0] * t[1] - quaternion[1] * t[0] + ]; +} + +export const vectorMagnitude = (vector) => { + return Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]); +} + +export const normalizeVector = (vector) => { + const length = vectorMagnitude(vector); + return [vector[0] / length, vector[1] / length, vector[2] / length]; +}