diff --git a/gnome/src/math.js b/gnome/src/math.js index 0eebd6c..2a1c2eb 100644 --- a/gnome/src/math.js +++ b/gnome/src/math.js @@ -14,8 +14,8 @@ export function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { // then convert back to spherical FOV return { diagonal: diagonalFOVRadians, - horizontal: 2 * Math.atan(Math.tan(flatHorizontalFOV / 2)), - vertical: 2 * Math.atan(Math.tan(flatVerticalFOV / 2)) + horizontal: 2 * Math.atan(flatHorizontalFOV / 2), + vertical: 2 * Math.atan(flatVerticalFOV / 2) } } diff --git a/kwin/src/metadata.json b/kwin/src/metadata.json index 3eb43da..41b4103 100644 --- a/kwin/src/metadata.json +++ b/kwin/src/metadata.json @@ -8,7 +8,7 @@ } ], "Category": "Tools", - "Description": "Breezy Desktop XR Effect 2", + "Description": "Breezy Desktop XR Effect", "EnabledByDefault": true, "License": "GPL", "Name": "Breezy Desktop XR", diff --git a/kwin/src/qml/BreezyDesktop.qml b/kwin/src/qml/BreezyDesktop.qml index 71f1ebc..9ee75b4 100644 --- a/kwin/src/qml/BreezyDesktop.qml +++ b/kwin/src/qml/BreezyDesktop.qml @@ -16,15 +16,8 @@ Node { "Rokid Air" ] - property real viewportDiagonalFOV: effect.diagonalFOV + property real viewportDiagonalFOVDegrees: effect.diagonalFOV property var viewportResolution: effect.displayResolution - property real viewportHorizontalFOV: { - const aspectRatio = viewportResolution[0] / viewportResolution[1]; - return viewportDiagonalFOV * aspectRatio / Math.sqrt(aspectRatio * aspectRatio + 1); - } - property real viewportCenterRadius: { - return viewportResolution[0] / (2 * Math.tan(Math.PI * viewportHorizontalFOV / 360)) - } property var screens: KWinComponents.Workspace.screens // .filter(function(screen) { // return supportedModels.includes(screen.model); @@ -58,16 +51,24 @@ Node { return (yMin + yMax) / 2 - (viewportResolution[1] / 2); } + Displays { + id: displays + } + + property var monitorPlacements: { + const fovDetails = displays.fovDetails(viewportResolution[0], viewportResolution[1], viewportDiagonalFOVDegrees, effect.lensDistanceRatio); + const monitorSpacing = 0.0; + return displays.monitorsToPlacements(fovDetails, screens.map(screen => screen.geometry), monitorSpacing); + } + Repeater3D { model: screens.length delegate: BreezyDesktopDisplay { screen: screens[index] + monitorPlacement: monitorPlacements[index] - property real screenRotation: { - const geometry = screen.geometry; - const rot = (viewportHorizontalFOV / viewportResolution[0]) * (geometry.x - screensXMid); - return -rot; - } + property real screenRotationY: displays.radianToDegree(monitorPlacement.rotationAngleRadians.y) + property real screenRotationX: displays.radianToDegree(monitorPlacement.rotationAngleRadians.x) property vector3d screenScale: { const geometry = screen.geometry; @@ -77,14 +78,16 @@ Node { } scale: screenScale - eulerRotation.y: screenRotation - position: { + eulerRotation.y: screenRotationY + eulerRotation.x: screenRotationX + position: { // rotate about the Y (up) axis, to create a horizontal movement const transform = Qt.matrix4x4(); - transform.rotate(screenRotation, Qt.vector3d(0, 1, 0)); + transform.rotate(screenRotationY, Qt.vector3d(0, 1, 0)); + transform.rotate(screenRotationX, Qt.vector3d(1, 0, 0)); // camera looks along the negative Z axis - return transform.times(Qt.vector3d(0, 0, -viewportCenterRadius)); + return transform.times(Qt.vector3d(-monitorPlacement.centerNoRotate[1], monitorPlacement.centerNoRotate[2], -monitorPlacement.centerNoRotate[0])); } } } diff --git a/kwin/src/qml/BreezyDesktopDisplay.qml b/kwin/src/qml/BreezyDesktopDisplay.qml index 3d6182b..9a13e61 100644 --- a/kwin/src/qml/BreezyDesktopDisplay.qml +++ b/kwin/src/qml/BreezyDesktopDisplay.qml @@ -5,6 +5,7 @@ Model { id: display required property QtObject screen + required property var monitorPlacement required property int index source: "#Rectangle" diff --git a/kwin/src/qml/Displays.qml b/kwin/src/qml/Displays.qml new file mode 100644 index 0000000..bc1397d --- /dev/null +++ b/kwin/src/qml/Displays.qml @@ -0,0 +1,304 @@ +import QtQuick + +QtObject { + // Converts degrees to radians + function degreeToRadian(degree) { + return degree * Math.PI / 180; + } + + function radianToDegree(radian) { + return radian * 180 / Math.PI; + } + + // Converts diagonal FOV in radians and aspect ratio to horizontal and vertical FOVs + function diagonalToCrossFOVs(diagonalFOVRadians, aspectRatio) { + var flatDiagonalFOV = 2 * Math.tan(diagonalFOVRadians / 2); + var flatVerticalFOV = flatDiagonalFOV / Math.sqrt(1 + aspectRatio * aspectRatio); + var flatHorizontalFOV = flatVerticalFOV * aspectRatio; + return { + diagonal: diagonalFOVRadians, + horizontal: 2 * Math.atan(flatHorizontalFOV / 2), + vertical: 2 * Math.atan(flatVerticalFOV / 2) + } + } + + function displayDistanceDefault() { + return 1.0; + } + + function fovDetails(viewportWidth, viewportHeight, viewportDiagonalFOV, lensDistanceRatio) { + const aspect = viewportWidth / viewportHeight; + const fovRadians = diagonalToCrossFOVs(degreeToRadian(viewportDiagonalFOV), aspect); + const defaultDistanceVerticalRadians = 2 * Math.atan(Math.tan(fovRadians.vertical / 2) / displayDistanceDefault()); + const defaultDistanceHorizontalRadians = 2 * Math.atan(Math.tan(fovRadians.horizontal / 2) / displayDistanceDefault()); + + // distance needed for the FOV-sized monitor to fill up the screen + const fullScreenDistance = viewportHeight / 2 / Math.tan(fovRadians.vertical / 2); + const lensDistancePixels = fullScreenDistance / (1.0 - lensDistanceRatio) - fullScreenDistance; + + // distance of a display at the default (most zoomed out) distance, plus the lens distance constant + const lensToScreenDistance = viewportHeight / 2 / Math.tan(defaultDistanceVerticalRadians / 2); + const completeScreenDistancePixels = lensToScreenDistance + lensDistancePixels; + + return { + widthPixels: viewportWidth, + heightPixels: viewportHeight, + defaultDistanceVerticalRadians, + defaultDistanceHorizontalRadians, + lensDistancePixels, + completeScreenDistancePixels, + monitorWrappingScheme: 'horizontal', // or 'vertical' or 'none' + curvedDisplay: false // or true + }; + } + + // Utility constant + readonly property real segmentsPerRadian: 20.0 / degreeToRadian(90.0) + + // FOV conversion functions for flat and curved displays + property var fovConversionFns: ({ + flat: { + centerToFovEdgeDistance: function(centerDistance, fovLength) { + return Math.sqrt(Math.pow(fovLength / 2, 2) + Math.pow(centerDistance, 2)); + }, + fovEdgeToScreenCenterDistance: function(edgeDistance, screenLength) { + return Math.sqrt(Math.pow(edgeDistance, 2) - Math.pow(screenLength / 2, 2)); + }, + lengthToRadians: function(fovRadians, fovLength, screenEdgeDistance, toLength) { + return Math.asin(toLength / 2 / screenEdgeDistance) * 2; + }, + angleToLength: function(fovRadians, fovLength, screenDistance, toAngleOpposite, toAngleAdjacent) { + return toAngleOpposite / toAngleAdjacent * screenDistance; + }, + 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); + }, + radiansToSegments: function(screenRadians) { + return Math.ceil(screenRadians * segmentsPerRadian); + } + } + }) + + // Applies a quaternion to a 3D vector + function applyQuaternionToVector(vector, quaternion) { + var 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] + ]; + } + + // Normalizes a 3D vector + function normalizeVector(vector) { + var length = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]); + return [vector[0] / length, vector[1] / length, vector[2] / length]; + } + + 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) { + var monitorPlacements = []; + var cachedMonitorRadians = {}; + + var conversionFns = fovDetails.curvedDisplay ? fovConversionFns.curved : fovConversionFns.flat; + + if (fovDetails.monitorWrappingScheme === 'horizontal') { + var sideEdgeRadius = conversionFns.centerToFovEdgeDistance(fovDetails.completeScreenDistancePixels, fovDetails.widthPixels); + var monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels; + var lengthToRadianFn = function(targetWidth) { + return conversionFns.lengthToRadians( + fovDetails.defaultDistanceHorizontalRadians, + fovDetails.widthPixels, + sideEdgeRadius, + targetWidth + ); + }; + + cachedMonitorRadians[0] = -fovDetails.defaultDistanceHorizontalRadians / 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.heightPixels) * monitorSpacingPixels; + var upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2; + var upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: originalIndex, + centerNoRotate: [ + monitorCenterRadius, + 0, + upCenterPixels + ], + centerLook: normalizeVector([ + 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.heightPixels); + var monitorSpacingPixels = monitorSpacing * fovDetails.heightPixels; + var lengthToRadianFn = function(targetHeight) { + return conversionFns.lengthToRadians( + fovDetails.defaultDistanceVerticalRadians, + fovDetails.heightPixels, + topEdgeRadius, + targetHeight + ); + }; + + cachedMonitorRadians[0] = -fovDetails.defaultDistanceVerticalRadians / 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.widthPixels) * monitorSpacingPixels; + var westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2; + var westCenterPixels = westLeftPixels - westCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: originalIndex, + centerNoRotate: [ + monitorCenterRadius, + westCenterPixels, + 0 + ], + centerLook: normalizeVector([ + monitorCenterRadius * Math.cos(monitorWrapDetails.center), + westCenterPixels, + -monitorCenterRadius * Math.sin(monitorWrapDetails.center) + ]), + rotationAngleRadians: { + x: -monitorWrapDetails.center, + y: 0 + } + }); + }); + } else { + var monitorSpacingPixels = monitorSpacing * fovDetails.widthPixels; + monitorDetailsList.forEach(function(monitorDetails, index) { + var upTopPixels = -monitorDetails.y - (monitorDetails.y / fovDetails.heightPixels) * monitorSpacingPixels; + var westLeftPixels = -monitorDetails.x - (monitorDetails.x / fovDetails.widthPixels) * monitorSpacingPixels; + var westCenterOffsetPixels = (monitorDetails.width - fovDetails.widthPixels) / 2; + var upCenterOffsetPixels = (monitorDetails.height - fovDetails.heightPixels) / 2; + var westCenterPixels = westLeftPixels - westCenterOffsetPixels; + var upCenterPixels = upTopPixels - upCenterOffsetPixels; + + monitorPlacements.push({ + originalIndex: index, + centerNoRotate: [ + fovDetails.completeScreenDistancePixels, + westCenterPixels, + upCenterPixels + ], + centerLook: normalizeVector([ + fovDetails.completeScreenDistancePixels, + westCenterPixels, + upCenterPixels + ]), + rotationAngleRadians: { + x: 0, + y: 0 + } + }); + }); + } + + monitorPlacements.sort(function(a, b) { return a.originalIndex - b.originalIndex; }); + return monitorPlacements; + } +} \ No newline at end of file