breezy-desktop/shared/js/displayPlacement.js

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