├── .gitmodules ├── assets └── preview.png ├── conf.lua ├── .gitignore ├── lovr-playspace.sh ├── LICENSE ├── README.md └── main.lua /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "json.lua"] 2 | path = json 3 | url = https://github.com/rxi/json.lua 4 | -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpookySkeletons/lovr-playspace/HEAD/assets/preview.png -------------------------------------------------------------------------------- /conf.lua: -------------------------------------------------------------------------------- 1 | function lovr.conf(t) 2 | t.headset.overlay = 6 3 | t.window = false 4 | t.identity = "lovr-playspace" 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | config/points.json 3 | 4 | lovr-x86_64.AppImage 5 | 6 | playspace.lovr 7 | 8 | build/ 9 | 10 | *.gz 11 | -------------------------------------------------------------------------------- /lovr-playspace.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | dir="$(dirname "$(readlink -f "$0")")" 3 | "$dir/lovr-x86_64.AppImage" "$dir/playspace.lovr" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lovr-playspace 2 | Room boundary overlay for OpenXR, made with LÖVR. 3 | 4 | [![Preview](assets/preview.png)](assets/preview.png) 5 | 6 | ## Prerequisites 7 | - **LÖVR:** If your distro has no package for it, I recommend [their releases](https://github.com/bjornbytes/lovr/releases). 8 | - **git:** Recommended for pulling this project. 9 | 10 | ## How to run 11 | ``` 12 | git clone "https://github.com/SpookySkeletons/lovr-playspace" 13 | ./lovr-*.AppImage lovr-playspace 14 | ``` 15 | 16 | ## How to use 17 | Press `action_button` (`trigger` by default) to set points. Hold `action_button` to save the points. This will end edit mode. If you want to get back into edit mode, hold `action_button` while the program starts, or delete `config/points.json`. Other settings have to be configured with a text editor, see below. 18 | 19 | ## Configuration 20 | Settings are stored separated in the program directory, in `config/`. 21 | - `action_button.txt`: The button to use to do actions like placing points. [See the LÖVR documentation](https://lovr.org/docs/v0.17.0/DeviceButton). 22 | - `color_close_corners.json`: How to color the points you've set when they're close. Borders of edges of your defined shape, as well as your grid\_top/grid\_bottom. 23 | - `color_close_grid.json`: How to color the lines between the corners when close. 24 | - `color_close_corners.json`: How to color the points you've set when they're far. 25 | - `color_close_grid.json`: How to color the lines between the corners when far. 26 | - `fade_stop.txt`: What is considered far away from a wall, in meters. Affects how colors fade. 27 | - `fade_start.txt`: What is considered close to a wall, in meters. Affects how colors fade. 28 | - `grid_bottom.txt`: Where to start drawing lines from, relative to your ground. 29 | - `grid_density.txt`: How much to divide your lines for drawing a grid into it, in meters. 30 | - `grid_top.txt`: Where to stop drawing lines, relative to your ground. 31 | - `points.json`: The points you've set. Does not exist by default. 32 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | -- Bootstrap 2 | appName = "lovr-playspace" 3 | 4 | function getCurrentTime() 5 | local time = os.time() 6 | local date = os.date("*t", time) 7 | return string.format("%02d:%02d", date.hour, date.min) 8 | end 9 | 10 | -- App 11 | hands = {"hand/right","hand/left"} 12 | limbs = { 13 | "head", 14 | "hand/left", 15 | "hand/right", 16 | "hand/left/grip", 17 | "hand/right/grip", 18 | "elbow/left", 19 | "elbow/right", 20 | "shoulder/left", 21 | "shoulder/right", 22 | "chest", 23 | "waist", 24 | "knee/left", 25 | "knee/right", 26 | "foot/left", 27 | "foot/right" 28 | } 29 | 30 | configDirs = {} 31 | json = require('json/json') 32 | 33 | function userConfig(fileName) 34 | return configDirs[1] .. "/" ..fileName 35 | end 36 | 37 | function getDistanceBetweenPoints3D(x1, y1, z1, x2, y2, z2) 38 | return math.sqrt((x2 - x1)^2 + (z2 - z1)^2) 39 | end 40 | 41 | function getFloorMatrix() 42 | local fx, fy, fz, fangle, fax, fay, faz = lovr.headset.getPose("floor") 43 | return lovr.math.newMat4(lovr.math.vec3(fx, fy, fz), lovr.math.vec3(1, 1, 1), lovr.math.quat(fangle, fax, fay, faz)) 44 | end 45 | 46 | function getHeadMatrix() 47 | local fx, fy, fz, fangle, fax, fay, faz = lovr.headset.getPose("head") 48 | return lovr.math.newMat4(lovr.math.vec3(fx, fy, fz), lovr.math.vec3(1, 1, 1), lovr.math.quat(fangle, fax, fay, faz)) 49 | end 50 | 51 | function getLineDistance(x, _, z, point1, point2) -- Notice the underscore for y 52 | -- Vector from point1 to point2 53 | local dx = point2[1] - point1[1] 54 | local dz = point2[2] - point1[2] 55 | 56 | -- Vector from point1 to the given point (x, y, z) 57 | local px = x - point1[1] 58 | local pz = z - point1[2] 59 | 60 | -- Dot product 61 | local dot = px * dx + pz * dz 62 | local len_sq = dx * dx + dz * dz 63 | local param = -1 64 | if len_sq ~= 0 then -- in case of 0 length line 65 | param = dot / len_sq 66 | end 67 | 68 | local xx, zz 69 | 70 | if param < 0 then 71 | xx = point1[1] 72 | zz = point1[2] 73 | elseif param > 1 then 74 | xx = point2[1] 75 | zz = point2[2] 76 | else 77 | xx = point1[1] + param * dx 78 | zz = point1[2] + param * dz 79 | end 80 | 81 | local dx_ = x - xx 82 | local dz_ = z - zz 83 | return math.sqrt(dx_ * dx_ + dz_ * dz_) 84 | end 85 | 86 | function getClosestDistanceToPerimeter(x, y, z, points) 87 | local lowestDist = 9999 88 | local length = #points 89 | for i = 1, length do 90 | local point1 = points[i] 91 | local point2 = points[(i % length) + 1] 92 | local dist = getLineDistance(x, y, z, point1, point2) 93 | if dist < lowestDist then 94 | lowestDist = dist 95 | end 96 | end 97 | return lowestDist 98 | end 99 | 100 | function getButton(method,button,devices) 101 | for _,device in ipairs(devices) do 102 | if method(device,button) == true then return device end 103 | end 104 | end 105 | 106 | function drawSinglePointGrid(pass, point1, point2, cornerColor, miscColor) 107 | local _, hy, _ = lovr.headset.getPosition("head") 108 | local lx1 = point1[1] 109 | local ly1 = hy 110 | local lz1 = point1[2] 111 | local lx2 = point2[1] 112 | local ly2 = hy 113 | local lz2 = point2[2] 114 | 115 | -- For the grid lines 116 | pass:setColor(unpack(miscColor)) 117 | local drawY = settings.grid_top 118 | while drawY >= settings.grid_bottom do 119 | pass:line({ 120 | lx1, drawY, lz1, 121 | lx2, drawY, lz2 122 | }) 123 | drawY = drawY - settings.grid_density 124 | end 125 | 126 | -- For the perimeter lines 127 | pass:setColor(unpack(cornerColor)) 128 | pass:line({ 129 | lx1, settings.grid_bottom, lz1, 130 | lx1, settings.grid_top, lz1 131 | }) 132 | 133 | pass:line({ 134 | lx1, settings.grid_bottom, lz1, 135 | lx2, settings.grid_bottom, lz2 136 | }) 137 | 138 | pass:line({ 139 | lx1, settings.grid_top, lz1, 140 | lx2, settings.grid_top, lz2 141 | }) 142 | end 143 | 144 | 145 | function drawPointGrid(pass,points,cornerColor,miscColor) 146 | local index = 2 147 | local length = #points 148 | if length < 1 then return end 149 | while index <= length do 150 | drawSinglePointGrid(pass,points[index - 1],points[index],cornerColor,miscColor) 151 | index = index + 1 152 | end 153 | drawSinglePointGrid(pass,points[length],points[1],cornerColor,miscColor) 154 | end 155 | 156 | function printTable(table) 157 | for _,point in ipairs(table) do 158 | print(point[1], " ", point[2]) 159 | end 160 | end 161 | 162 | function lovr.load() 163 | lovr.graphics.setBackgroundColor(0.0, 0.0, 0.0, 0.0) 164 | print("Is tracked floor:", lovr.headset.isTracked('floor')) 165 | 166 | -- Default settings 167 | local defaults = { 168 | action_button = "trigger", 169 | check_density = 0.05, 170 | fade_start = 0.5, 171 | fade_stop = 2.0, 172 | grid_density = 1.0, 173 | grid_bottom = 0.0, 174 | grid_top = 3, 175 | color_close_corners = {0.45, 0.69, 0.79, 1.0}, 176 | color_close_grid = {0.45, 0.69, 0.79, 0.5}, 177 | color_far_corners = {0.45, 0.69, 0.79, 0}, 178 | color_far_grid = {0.45, 0.69, 0.79, 0}, 179 | show_time = 0, 180 | points = {} 181 | } 182 | 183 | -- Helper function to read file with fallback to default 184 | local function loadSetting(filename, default, parser) 185 | print("Checking file:", filename) 186 | 187 | if not lovr.filesystem.isFile(filename) then 188 | print("File doesn't exist, creating:", filename) 189 | local valueToSave 190 | if type(default) == "table" then 191 | valueToSave = json.encode(default) 192 | else 193 | valueToSave = tostring(default) 194 | end 195 | 196 | local success = lovr.filesystem.write(filename, valueToSave) 197 | print("Write success:", success, "for", filename, "with value:", valueToSave) 198 | return default 199 | end 200 | 201 | -- File exists, try to read it 202 | local content = lovr.filesystem.read(filename) 203 | if content and parser then 204 | local success, value = pcall(parser, content) 205 | if success then return value end 206 | elseif content then 207 | return content 208 | end 209 | 210 | return default 211 | end 212 | 213 | -- Initialize settings with fallbacks 214 | settings = { 215 | action_button = loadSetting("action_button.txt", defaults.action_button), 216 | check_density = loadSetting("check_density.txt", defaults.check_density, tonumber), 217 | fade_start = loadSetting("fade_start.txt", defaults.fade_start, tonumber), 218 | fade_stop = loadSetting("fade_stop.txt", defaults.fade_stop, tonumber), 219 | grid_density = loadSetting("grid_density.txt", defaults.grid_density, tonumber), 220 | grid_bottom = loadSetting("grid_bottom.txt", defaults.grid_bottom, tonumber), 221 | grid_top = loadSetting("grid_top.txt", defaults.grid_top, tonumber), 222 | color_close_corners = loadSetting("color_close_corners.json", defaults.color_close_corners, json.decode), 223 | color_close_grid = loadSetting("color_close_grid.json", defaults.color_close_grid, json.decode), 224 | color_far_corners = loadSetting("color_far_corners.json", defaults.color_far_corners, json.decode), 225 | color_far_grid = loadSetting("color_far_grid.json", defaults.color_far_grid, json.decode), 226 | show_time = loadSetting("show_time.json", defaults.show_time, json.decode), 227 | points = {}, 228 | transformed = false 229 | } 230 | 231 | -- Handle points.json 232 | local pointsPath = "points.json" 233 | if not lovr.filesystem.isFile(pointsPath) then 234 | initConfigure() 235 | return 236 | end 237 | 238 | -- Check for action button press 239 | for _, hand in ipairs(hands) do 240 | if lovr.headset.isDown(hand, settings.action_button) then 241 | initConfigure() 242 | return 243 | end 244 | end 245 | 246 | -- Load points if we haven't returned already 247 | local pointsContent = lovr.filesystem.read(pointsPath) 248 | if pointsContent then 249 | settings.points = json.decode(pointsContent) 250 | print("FloorSpace:") 251 | printTable(settings.points) 252 | end 253 | 254 | mode = modeDraw 255 | end 256 | 257 | function initConfigure() 258 | saveProg = 1.0 259 | 260 | lovr.update = function(dt) 261 | deltaTime = dt 262 | end 263 | 264 | mode = modeConfigure 265 | end 266 | 267 | function deinitConfigure() 268 | saveProg = nil 269 | lovr.update = nil 270 | deltaTime = nil 271 | mode = modeDraw 272 | end 273 | 274 | function modeConfigure(pass) 275 | local hx, hy, hz = lovr.headset.getPosition("head") 276 | floorMatrixInv = getFloorMatrix():invert() 277 | 278 | for _, hand in ipairs(hands) do 279 | if lovr.headset.isTracked(hand) then 280 | local cx, cy, cz = lovr.headset.getPosition(hand) 281 | local floorSpaceControlerVector = lovr.math.vec3(cx, cy, cz):transform(floorMatrixInv) 282 | local fcx, fcy, fcz = floorSpaceControlerVector:unpack() 283 | 284 | 285 | -- Compute the direction from the controller to the headset 286 | local dirX = hx - cx 287 | local dirY = hy - cy 288 | local dirZ = hz - cz 289 | 290 | -- Compute the rotation angle to make the text face the headset 291 | local angle = math.atan2(dirX, dirZ) 292 | 293 | pass:setColor(1, 0, 0, 0.5 * saveProg) 294 | pass:sphere(cx, cy, cz, 0.1) 295 | pass:setColor(1, 1, 1, saveProg) 296 | 297 | -- Draw the text with the computed rotation 298 | pass:text( 299 | "- Press '" .. settings.action_button .. "' to add a point -\n" .. 300 | "- Hold '" .. settings.action_button .. "' to save -\n\n" .. 301 | string.format("%.2f", fcx) .. "," .. string.format("%.2f", fcy) .. "," .. string.format("%.2f", fcz), 302 | cx, cy - 0.3, cz, 0.066, angle, 0, 1, 0 303 | ) 304 | end 305 | end 306 | 307 | local inputDev = getButton(lovr.headset.wasReleased,settings.action_button,hands) 308 | if inputDev ~= nil and lovr.headset.isTracked(inputDev) then 309 | local cx, _, cz = lovr.headset.getPosition(inputDev) 310 | table.insert(settings.points,{cx,cz}) 311 | end 312 | 313 | inputDev = getButton(lovr.headset.isDown,settings.action_button,hands) 314 | if inputDev ~= nil then 315 | saveProg = saveProg - (deltaTime / 3) 316 | if saveProg <= 0 then 317 | local floorSpacePoints = {} 318 | for _,point in ipairs(settings.points) do 319 | local floorSpacePoint = lovr.math.vec3(point[1], 0, point[2]):transform(floorMatrixInv) 320 | local x, _, z = floorSpacePoint:unpack() 321 | table.insert(floorSpacePoints,{x,z}) 322 | end 323 | lovr.filesystem.write("points.json", json.encode(floorSpacePoints)) 324 | deinitConfigure() 325 | modeDraw(pass) 326 | return 327 | end 328 | else 329 | saveProg = 1.0 330 | end 331 | 332 | pass:setColor(1,0,0,0.5) 333 | for _,point in ipairs(settings.points) do 334 | pass:sphere(point[1],1.5,point[2],0.1) 335 | end 336 | 337 | modeDraw(pass) 338 | end 339 | 340 | function modeDraw(pass) 341 | local hx, hy, hz = lovr.headset.getPosition("head") 342 | 343 | if not settings.transformed and lovr.headset.isTracked("floor") then 344 | local floorMatrix = getFloorMatrix() 345 | print("floorMatrix: ", floorMatrix) 346 | transformedPoints = {} 347 | for _,point in ipairs(settings.points) do 348 | local point = lovr.math.vec3(point[1], 0, point[2]):transform(floorMatrix) 349 | local x, _, z = point:unpack() 350 | table.insert(transformedPoints, {x, z}) 351 | end 352 | settings.points = transformedPoints 353 | print("HeadSpace:") 354 | printTable(settings.points) 355 | settings.transformed = true 356 | end 357 | 358 | -- Calculate the distance from the head to the perimeter 359 | local perimeterDistHead = getClosestDistanceToPerimeter(hx, hy, hz, settings.points) 360 | 361 | -- Check distance from each hand to the perimeter 362 | local handDistances = {perimeterDistHead} 363 | for _, hand in ipairs(hands) do 364 | if lovr.headset.isTracked(hand) then 365 | local handX, handY, handZ = lovr.headset.getPosition(hand) 366 | local dist = getClosestDistanceToPerimeter(handX, handY, handZ, settings.points) 367 | table.insert(handDistances, dist) 368 | end 369 | end 370 | 371 | -- Take the minimum of the distances for the fade logic 372 | local closestDist = math.min(unpack(handDistances)) 373 | 374 | -- Update the fade logic based on the closest distance 375 | closestDist = (closestDist - settings.fade_stop) / (settings.fade_start - settings.fade_stop) 376 | closestDist = math.max(0, math.min(1, closestDist)) 377 | 378 | local function interpolateColor(startColor, endColor) 379 | return { 380 | startColor[1] + (endColor[1] - startColor[1]) * closestDist, 381 | startColor[2] + (endColor[2] - startColor[2]) * closestDist, 382 | startColor[3] + (endColor[3] - startColor[3]) * closestDist, 383 | startColor[4] + (endColor[4] - startColor[4]) * closestDist 384 | } 385 | end 386 | 387 | local cornerColor = interpolateColor(settings.color_far_corners, settings.color_close_corners) 388 | local gridColor = interpolateColor(settings.color_far_grid, settings.color_close_grid) 389 | 390 | drawPointGrid(pass, settings.points, cornerColor, gridColor) 391 | end 392 | 393 | function lovr.draw(pass) 394 | mode(pass) 395 | 396 | if settings.show_time == 1 then 397 | local hx, hy, hz = lovr.headset.getPosition("head") 398 | local hangle, hax, hay, haz = lovr.headset.getOrientation("head") 399 | local currentTime = getCurrentTime() 400 | pass:setColor(1, 1, 1, 0.25) 401 | local transform = lovr.math.newMat4() 402 | transform:translate(hx, hy + 1.5, hz) 403 | transform:rotate(math.pi / 2, 1, 0, 0) 404 | transform:rotate(math.pi - math.atan2(hax, haz)*2, 0, 0, 1) 405 | transform:translate(0, -0.1, 0) 406 | transform:scale(0.1, 0.1, 0.1) 407 | pass:text(currentTime, transform) 408 | end 409 | end 410 | --------------------------------------------------------------------------------