WIP pull shared JS logic out of gnome/kwin

This commit is contained in:
wheaney 2026-05-27 16:54:54 -07:00
parent 0df496770c
commit 24f240f338
5 changed files with 512 additions and 872 deletions

View File

@ -1,78 +1 @@
export function degreeToRadian(degree) { export { degreeToRadian, diagonalToCrossFOVs, fovConversionFns, applyQuaternionToVector, vectorMagnitude, normalizeVector } from '../../shared/js/math.js';
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];
}

View File

@ -7,405 +7,13 @@ import Shell from 'gi://Shell';
import St from 'gi://St'; import St from 'gi://St';
import { VirtualDisplayEffect, SMOOTH_FOLLOW_SLERP_TIMELINE_MS } from './virtualdisplayeffect.js'; 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 * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Globals from './globals.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({ export const VirtualDisplaysActor = GObject.registerClass({
Properties: { Properties: {

View File

@ -1,71 +1,62 @@
import QtQuick import QtQuick
import "../../../shared/js/math.js" as SharedMath
import "../../../shared/js/displayPlacement.js" as SharedPlacement
QtObject { QtObject {
readonly property real focusThreshold: 0.95 / 2.0 readonly property real focusThreshold: SharedPlacement.FOCUS_THRESHOLD
readonly property real unfocusThreshold: 1.1 / 2.0 readonly property real unfocusThreshold: SharedPlacement.UNFOCUS_THRESHOLD
// Converts degrees to radians // --- Qt coordinate-space helpers (not in shared because they use Qt types) ---
function degreeToRadian(degree) {
return degree * Math.PI / 180;
}
function radianToDegree(radian) { function radianToDegree(radian) {
return radian * 180 / Math.PI; return radian * 180 / Math.PI;
} }
function nwuToEusVector(vector) { function nwuToEusVector(vector) {
// Converts NWU vector to EUS vector
return Qt.vector3d(-vector.y, vector.z, -vector.x); return Qt.vector3d(-vector.y, vector.z, -vector.x);
} }
function eusToNwuVector(vector) { function eusToNwuVector(vector) {
// Converts EUS vector to NWU vector
return Qt.vector3d(-vector.z, -vector.x, vector.y); return Qt.vector3d(-vector.z, -vector.x, vector.y);
} }
function eusToNwuQuat(quaternion) { function eusToNwuQuat(quaternion) {
// Converts EUS quaternion to NWU quaternion
return Qt.quaternion(quaternion.scalar, -quaternion.z, -quaternion.x, quaternion.y); 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 slerpVector(from, to, progress) {
function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { const inverseProgress = 1.0 - progress;
// first convert from a spherical FOV to a diagonal FOV on a flat plane at a unit distance of 1.0 return Qt.vector3d(
const diagonalLengthUnitDistance = 2 * Math.tan(diagonalFOVRadians / 2); from.x * inverseProgress + to.x * progress,
from.y * inverseProgress + to.y * progress,
// then convert to flat plane horizontal and vertical FOVs from.z * inverseProgress + to.z * progress
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 actualWrapScheme(screens, viewportWidth, viewportHeight) { // --- Delegated to shared (re-exported for callers that go through this object) ---
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));
if ((maxX - minX) / viewportWidth >= (maxY - minY) / viewportHeight) { function degreeToRadian(degree) { return SharedMath.degreeToRadian(degree); }
return 'horizontal'; function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { return SharedMath.diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio); }
} else {
return 'vertical'; 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) { function buildFovDetails(screens, viewportWidth, viewportHeight, viewportDiagonalFOV, lensDistanceRatio, defaultDisplayDistance, wrappingChoice, distanceAdjustedSize) {
const aspect = viewportWidth / viewportHeight; const aspect = viewportWidth / viewportHeight;
const fovLengths = diagonalToCrossFOVs(degreeToRadian(viewportDiagonalFOV), aspect); const fovLengths = SharedMath.diagonalToCrossFOVs(SharedMath.degreeToRadian(viewportDiagonalFOV), aspect);
let monitorWrappingScheme = actualWrapScheme(screens, viewportWidth, viewportHeight); let monitorWrappingScheme = actualWrapScheme(screens, viewportWidth, viewportHeight);
if (wrappingChoice === 1) monitorWrappingScheme = 'horizontal'; if (wrappingChoice === 1) monitorWrappingScheme = 'horizontal';
@ -74,8 +65,8 @@ QtObject {
const lensDistanceComplement = 1.0 - lensDistanceRatio; const lensDistanceComplement = 1.0 - lensDistanceRatio;
const lensDistanceFactor = (1.0 / lensDistanceComplement) - 1.0; const lensDistanceFactor = (1.0 / lensDistanceComplement) - 1.0;
const horizontalConversions = effect.curvedDisplay && monitorWrappingScheme === 'horizontal' ? fovConversionFns.curved : fovConversionFns.flat; const horizontalConversions = effect.curvedDisplay && monitorWrappingScheme === 'horizontal' ? SharedMath.fovConversionFns.curved : SharedMath.fovConversionFns.flat;
const verticalConversions = effect.curvedDisplay && monitorWrappingScheme === 'vertical' ? fovConversionFns.curved : fovConversionFns.flat; const verticalConversions = effect.curvedDisplay && monitorWrappingScheme === 'vertical' ? SharedMath.fovConversionFns.curved : SharedMath.fovConversionFns.flat;
const defaultDistanceVerticalRadians = verticalConversions.fovRadiansAtDistance( const defaultDistanceVerticalRadians = verticalConversions.fovRadiansAtDistance(
fovLengths.verticalRadians, fovLengths.verticalRadians,
@ -88,16 +79,9 @@ QtObject {
defaultDisplayDistance defaultDisplayDistance
); );
// distance needed for the FOV-sized monitor to fill up the screen, as measured from the lenses
const lensToUnitDistancePixels = viewportWidth / fovLengths.widthUnitDistance; const lensToUnitDistancePixels = viewportWidth / fovLengths.widthUnitDistance;
// distance from pivot point to lens
const lensDistancePixels = lensToUnitDistancePixels * lensDistanceFactor; const lensDistancePixels = lensToUnitDistancePixels * lensDistanceFactor;
// distance from pivot point to full screen (monitor at unit distance from lens)
const fullScreenDistancePixels = lensToUnitDistancePixels + lensDistancePixels; const fullScreenDistancePixels = lensToUnitDistancePixels + lensDistancePixels;
// distance of a display at the default (most zoomed out) distance from the pivot point
const completeScreenDistancePixels = fullScreenDistancePixels * defaultDisplayDistance; const completeScreenDistancePixels = fullScreenDistancePixels * defaultDisplayDistance;
return { return {
@ -116,362 +100,43 @@ QtObject {
}; };
} }
// Utility constant // Wraps SharedPlacement.monitorsToPlacements, converting plain-array vectors to Qt.vector3d.
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%)
function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) { function monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing) {
var monitorPlacements = []; return SharedPlacement.monitorsToPlacements(fovDetails, monitorDetailsList, monitorSpacing)
var cachedMonitorRadians = {}; .map(p => ({
originalIndex: p.originalIndex,
var conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; monitorCenterNorth: p.monitorCenterNorth,
centerNoRotate: Qt.vector3d(p.centerNoRotate[0], p.centerNoRotate[1], p.centerNoRotate[2]),
if (fovDetails.monitorWrappingScheme === 'horizontal') { centerLook: Qt.vector3d(p.centerLook[0], p.centerLook[1], p.centerLook[2]),
// monitors wrap around us horizontally rotationAngleRadians: p.rotationAngleRadians
}));
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);
} }
// Wraps SharedPlacement.findFocusedMonitor, adapting Qt types to plain arrays.
function findFocusedMonitor(quaternion, position, monitorVectors, currentFocusedIndex, smoothFollowEnabled, fovDetails, monitorsDetails) { 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 // convert Qt.vector3d position plain array
var rotatedLookVector = quaternion.times(lookVector); 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 // convert Qt.vector3d monitor centerLook vectors plain arrays
// because it will account for the monitors facing towards us, but this will lose some accuracy const vectorArrays = monitorVectors.map(v => [v.x, v.y, v.z]);
var upConversionFns = fovDetails.monitorWrappingScheme === "vertical" ? fovConversionFns.curved : fovConversionFns.flat;
var lookUpPixels = upConversionFns.angleToLength( // adapt monitorsDetails from screen.geometry shape to plain {x,y,width,height}
fovDetails.defaultDistanceVerticalRadians, const detailsArrays = monitorsDetails.map(d => ({
fovDetails.heightPixels, x: d.x, y: d.y, width: d.width, height: d.height
fovDetails.completeScreenDistancePixels, }));
rotatedLookVector.z,
rotatedLookVector.x // 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;
}
}

View File

@ -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 pixelradian 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;
}

79
shared/js/math.js Normal file
View File

@ -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];
}