366 lines
17 KiB
JavaScript
366 lines
17 KiB
JavaScript
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;
|
|
}
|