improved grid-layout for openbrush plugin

This commit is contained in:
Leon van Kammen 2026-09-11 18:42:12 +02:00
parent e1ea486212
commit 86eaf1556f
23 changed files with 635 additions and 146 deletions

View file

@ -14,7 +14,7 @@ HidePath('/usr/share/ssl/')
-- create
app = require("soakbean") {
cache = { items = {}, url = {}, shared = require('sharedcache') },
cache = {}, -- populated by reset()
plugin = {},
opts = {},
forkpids = {},
@ -25,37 +25,47 @@ app = require("soakbean") {
createplugin = {file="cmd/createplugin.lua", info="generates skeleton plugin"},
generate = {file="cmd/generate.lua", info="generate www-folder with XR experiences"},
test = {file="cmd/test.lua", info="run testsuite (selftest)"}
},
addPlugin = function(file)
local p = require(file)
table.insert(app.plugin,p)
-- ensure plugin config-defaults
foreach( app.plugin, function(name,plugin)
foreach( plugin.opts, function(k,opt)
local key = opt.key:gsub("app.opts.","")
if not app.opts[ key ] then
app.opts[ key ] = opt.default
end
end)
end)
app.set('app.opts', app.opts)
return p
end,
addExperience = function(experience, ino)
if app.cache.items[ino] then -- handle duplicate symlinks, symlinks to symlinks e.g.
app.cache.items[ino .. ' ' .. experience.parent.id ] = experience
else
app.cache.items[ino] = experience
end
end
}
}
app.reset = function()
app.cache = { items = {}, url = {}, shared = require('sharedcache'), dirs={} }
end
app.addPlugin = function(file)
local p = require(file)
table.insert(app.plugin,p)
-- ensure plugin config-defaults
foreach( app.plugin, function(name,plugin)
foreach( plugin.opts, function(k,opt)
local key = opt.key:gsub("app.opts.","")
if not app.opts[ key ] then
app.opts[ key ] = opt.default
end
end)
end)
app.set('app.opts', app.opts)
return p
end
app.addExperience = function(experience, ino)
if app.cache.items[ino] then -- handle duplicate symlinks, symlinks to symlinks e.g.
app.cache.items[ino .. ' ' .. experience.parent.id ] = experience
else
app.cache.items[ino] = experience
end
end
app.markViewable = function(exp)
exp.view3D = true
exp.parent.redirect = exp.url -- mark parent to redirect to this standalone url instead (not glb assets e.g.)
end
-- api skeleton (implemented via soakbean's app.on(...))
app.set = function(k,v) end
app.init = function() end
app.init = function()
app.reset()
end
-- load config
app.opts = require('config')(app)
@ -80,7 +90,7 @@ app.plugin.git = app.addPlugin('plugin/git')
app.plugin.webhook = app.addPlugin('plugin/webhook')
app.plugin.archive = app.addPlugin('plugin/archive')
app.plugin.openbrush = app.addPlugin('plugin/openbrush')
--app.plugin.scheduler = app.addPlugin('plugin/scheduler') -- should always be second last
app.plugin.scheduler = app.addPlugin('plugin/scheduler') -- should always be second last
app.plugin.staticwebgen = app.addPlugin('plugin/staticwebgen') -- should always be last
if unix.getpid() == 0 then unix.exit(0) end -- child-threads shall not pass beyond here

24
src/.lua/apiSync.lua Normal file
View file

@ -0,0 +1,24 @@
local apiSync = {
syncHashtags = function(opts)
local synced = 0
local tags = opts.tags
if #tags == 0 then table.insert(tags,'') end
foreach( tags, function(k,tag)
local url = opts.apiCall( #tag > 0 and tag or nil)
opts.body = nil -- erase previous url result
print('🤞 fetch ' .. url)
local t = opts.fetch(url,opts)
if t then
synced = synced + opts.download(t,tag)
end
end, true)
if synced > 0 then
app.plugin.diskscan.scan(app.opts.plugin_diskscan_dir)
end
return synced
end
}
return apiSync

View file

@ -234,9 +234,15 @@ util.call = function(fn,a,b,c,d,e,f)
end
end
function foreach(t, f, sort)
function foreach(t, f, alwayscontinue)
if t ~= nil then
for k, v in pairs(t) do f(k, v) end
for k, v in pairs(t) do
if alwayscontinue then
local success, err = pcall(f, k, v)
else
f(k, v)
end
end
end
end
@ -410,5 +416,41 @@ function util.traverseXML( parsedXml, cb)
)
end
util.hyphenate = function(str)
local str,match = str:lower()
:gsub("[^%w]", "-")
:gsub("%-+", "-")
:gsub("^%-", "")
:gsub("%-$", "")
return str
end
util.dehyphenate = function(str)
local str,match = str:gsub("_[a-zA-Z0-9]+","") -- remove ids starting with _
:gsub("%-%-","-") -- remove double dash
:gsub("-"," ") -- convert to spaces
:gsub("^%s*(.-)%s*$", "%1") -- trim
return str
end
util.stripId = function(path)
return path:gsub("_[a-zA-Z0-9]+",""), path:match("_[a-zA-Z0-9]+")
end
util.parseIsoDate = function(iso_str)
--local iso_str = "2017-11-03T08:04:45.076Z"
local year, month, day, hour, min, sec, ms = iso_str:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)%.(%d+)Z")
year, month, day = tonumber(year), tonumber(month), tonumber(day)
hour, min, sec = tonumber(hour), tonumber(min), tonumber(sec)
ms = tonumber("0." .. ms) -- Optional: keep as decimal seconds fraction
local local_time = os.time({year=year, month=month, day=day, hour=hour, min=min, sec=sec})
local utc_offset = os.difftime(local_time, os.time(os.date("!*t", local_time)))
local timestamp = local_time + utc_offset
return timestamp, ms
end
return util

BIN
src/img/banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

BIN
src/img/layout_grid.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
src/img/layout_portal.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -109,6 +109,7 @@ div#xrecosystem{
end}
-->
<br>
<img src="img/banner.jpg" style="width:100%"/>
<br>
<h3>Why people want XRForge</h3>
@ -216,12 +217,50 @@ div#xrecosystem{
</div>
<div class="spectrum">
<div></div>
</div>
<div>
<h3 id="autocompose">Auto-compose virtual worlds</h3>
Instead of manually composing virtual worlds from multiple assets, XRForge's philosophy is <b>auto-layout</b> and <b>interoperability</b>.<br>
This means that assets produced in <a href="https://openblocks.app" target="_blank">OpenBlocks</a> or <a href="https://openbrush.app" target="_blank">OpenBrush</a> can <b>(in)directly</b> get imported</b> (via online 3D stores like <a href="https://icosa.gallery" target="_blank">icosa.gallery-</a> and <a href="https://manyfold.app" target="_blank">manyfold.app</a>).<br>
This allows for infinitely scalable virtual worlds, yet completely downloadable and portable.<br>
XRForge features <b>layout-engines</b> which allows <b>crowdsourcing virtual worlds</b> via assets using various apps/platforms:<br>
<br>
<img src="img/layout_grid.jpg" style="width:100%; max-width:1024px;"/>
<br><br>
<img src="img/layout_portal.jpg" style="width:100%; max-width:1024px"/>
<br>
<br>
An example:<br>
<blockquote>
1. designers agree on a hashtag (#myproject e.g)<br>
2. add the hashtag to the asset-title (in openbrush/openblocks/manyfold e.g.)<br>
3. add the hashtag to XRForge (in the respective plugin)<br>
4. specify the layout (grid, portals e.g.) for rendering the assets<br>
4. XRForge will pull or reference all hashtag-related assets<br>
5. Profit! XRForge will now automatically render a virtual world<br>
</blockquote>
<br>
By default all scenes are layered on top of eachother, which is great for horizon, sky etc.<br>
However, auto-placement in a grid (buildings e.g.) is possible by adding a dimensional hashtag (<b>#2x2</b> e.g.) to the asset-title.
</blockquote>
<br>
<br>
</div>
<div class="spectrum">
<div></div>
</div>
<center>
<h3>Comparison</h3>
<h2>Comparison</h2>
<table class="table table-dark table-striped" id="comparison">
<thead>
<tr>

View file

@ -52,7 +52,7 @@ return {
if v.dir or v.view3D then
local parent = app.cache.items[ v.parent.id ]
res.items[ v.id ] = v
res.itemdir = v
res.itemdir = v.parent
res.parent = parent
end
if isroot then
@ -68,6 +68,7 @@ return {
local data = util.merge({
janusurl = req.param.url, -- query hint to render janus.url in iframe (index.html)
items = {},
itemdir = res.itemdir,
isroot = isroot,
referrer = referrer,
req = req,
@ -98,6 +99,7 @@ return {
-- what to do here? no generated portals?
data.janusxr = app.tpl( util.readfile(v.path), data )
end
print_r(data.item)
end
end
end)

View file

@ -1,6 +1,6 @@
{if true then
sidecarfile = (parent and parent.path or '') .. '/' .. app.opts.plugin_janusxr_wrap_custom
hasSidecarfile = util.fileExist(sidecarfile) and true or false
wrapperfile = (parent and parent.path or '') .. '/' .. app.opts.plugin_janusxr_wrap_custom
hasWrapperfile = util.fileExist(wrapperfile) and true or false
showfediportals = app.opts.plugin_federation_strategy == 'always' or
(app.opts.plugin_federation_strategy == 'homepage' and urlpath == '/')
@ -47,11 +47,17 @@ end}
if not defined(item) and defined(items) then
room = app.opts.plugin_janusxr_portalroom
end
if defined(item) and item.janusxr and item.janusxr.wrap then
pos = '0 0 2'
room = app.plugin.janusxr.wrapRoomAroundFile(item)
function initRoom(i)
if i.janusxr and i.janusxr.room then
pos = '0 0 1' -- todo make startup location configurable
room = i.janusxr.room
end
end
if hasSidecarfile or (defined(item) and item.tag.xrfragments and item.tag.xrfragments.href) then
initRoom(item or {})
initRoom(itemdir or {})
if (hasWrapperfile == true) or (defined(item) and item.tag.xrfragments and item.tag.xrfragments.href) then
room = '' -- xrfragments 3D files dont need to be wrapped with room
end
print(' <room skybox="true" pos="' .. pos .. '" showavatar="false" use_local_asset="' .. room .. '">')
@ -60,50 +66,27 @@ end}
end
end}
<light js_id="dan9-janus:light/light_cone_angle=0-6" pos="0 3 2" collision_trigger="false" />
{if true then
function addPortal(id,x,v,url,i,z,thumb,rotation,scale)
local title = v.title
print('\t<object js_id="' .. id .. '_' .. i .. '" pos="' .. x .. ' 0 ' .. z .. '" rotation="' .. rotation .. '">')
print('\t <link url="' .. util.baseURLify(url) .. '" ' .. thumb .. ' title_col="0.4 0.37 0.4" pos="0 0 0.001" draw_glow="' .. (thumb:match("shader") and 'false' or 'true') .. '" scale="3 3 0.1" />')
print('\t <text text="' .. util.truncate(title,20) .. '" pos="0 3.4 0" col="1 1 1" font_scale="false" scale="' .. scale .. '" />')
--TODO: fix janusweb paragraph for markdown urls
--if v.README ~= nil then
-- print('\t <paragraph js_id="README" url="' .. v.README .. '" width="600" height="900" pos="0 1 2" transparent="true"/>')
--end
print('\t</object>')
end
end}
<text js_id="location" pos="0 6 -5" billboard="y" col="#BBFFFF" scale="10 10 1" text="${url}" emissive="0 0 0" roughness="0.3" metalness="0" />
{if not defined(item) and defined(items) and #items > 0 then
for i,v in pairs(items) do
local width = 3.2
local center = -((#items*width)/1.3) + (i*width)
local url = v.url
local thumb = ' shader_id="defaultportal"'
if #v.thumbnail > 0 then
thumb = ' thumb_id="img' .. i .. '" '
end
if v.dir then url = url .. "/" end
if v.redirect then url = v.redirect end -- dont wrap standalone glb's in janusxr
addPortal('dir',center, v, url,i, -4, thumb, '0 0 0', '0.2 0.2 0.2')
local layout = itemdir.layout or ''
if #layout > 0 and app.plugin.janusxr.layout[ layout ] then
print( app.plugin.janusxr.layout[ layout ].render(items) )
else
print( app.plugin.janusxr.layout.portals.render(items) )
end
end}
{if not defined(item) and defined(itemsremote) and #itemsremote > 0 then
for i,v in pairs(itemsremote) do
local width = 3.2
local width = v.portal_width or tonumber( app.opts.plugin_global_portal_width )
local center = -((#itemsremote*width)/1.3) + (i*width)
local url = v.url
--if url:match("://") then
-- url = url .. ( url:match("#") and '&' or '#' ) .. 'janus.referrer=' ..
-- EscapeFragment( util.getURL() )
--end
local thumb = ' shader_id="defaultportal"'
local thumb = ' shader_id="defaultportal"'
if #v.thumbnail > 0 then
thumb = ' thumb_id="imgremote' .. i .. '" '
end
if v.dir then url = url .. "/" end
if v.redirect then url = v.redirect end -- dont wrap standalone glb's in janusxr
addPortal('remote',center, v, url,i, 4, thumb, '0 180 0', '0.2 0.2 0.2')
print( app.plugin.janusxr.addPortal('remote',center, v, url,i, 4, thumb, '0 180 0', '0.2 0.2 0.2') )
end
print([[
<text js_id="locationremote" pos="0 6 5" billboard="y" col="#BBFFFF" scale="10 10 1" text="${opts.plugin_global_remote_title}" emissive="0 0 0" roughness="0.3" metalness="0" />
@ -116,10 +99,10 @@ end}
url = referrer,
title = app.opts.plugin_janusxr_portal_up,
}
addPortal('parent',5, v, v.url, 0, 0, ' shader_id="defaultportal"', '0 -90 0', '0.2 0.2 0.2')
print( app.plugin.janusxr.addPortal('parent',5, v, v.url, 0, 0, ' shader_id="defaultportal"', '0 -90 0', '0.2 0.2 0.2') )
end}
{if isroot then
{if os.getenv('DEBUG') then
local plugins = {}
foreach(app.plugin, function(name,p)
@ -150,12 +133,13 @@ end}
end}
<object id="cube" rotation="0 45 0" pos="${pos}" scale="2 0.1 2"/> <!-- something to stand on -->
{if not defined(item) and not hasSidecarfile then
{if not defined(item) and hasWrapperfile == false then
print('${janusxr_jml_deco}')
end}
{if hasSidecarfile then
{if hasWrapperfile == true then
local wrapperURL = util.stripPath( parent.path .. '/' .. app.opts.plugin_janusxr_wrap_custom)
print(' <!-- inserted by XRForge server: ' .. app.opts.plugin_janusxr_wrap_custom .. ' -->')
print(' <link url="' .. parent.path .. '/' .. app.opts.plugin_janusxr_wrap_custom .. '" url_pos="0 0 0" pos="0 0 1" url_scale="1 1 1" js_id="index_jml" auto_load="true" />')
print(' <link url="' .. wrapperURL .. '" url_pos="0 0 0" pos="0 0 1" url_scale="1 1 1" js_id="index_jml" auto_load="true" />')
end}

View file

@ -45,13 +45,13 @@ local plugin = {
title='When to trigger',
info='when should archive.org check this xrforge?',
default = "boot",
values={disabled="disabled", daily="daily"}
values={disabled="disabled", boot="on boot", daily="daily"}
},
{
key='app.opts.plugin_archive_url',
title='Startingpoint URL',
info='absolute URL which archive.org (waybackmachine) should start with',
default = "/myverse",
default = "",
placeholder = "/myverse",
values=''
},
@ -104,6 +104,7 @@ local plugin = {
start = function()
local me = app.plugin.archive.scraper
os.execute('rm scraper.txt 2>&1')
me.scraperStart = os.time()
util.fetchServer('/', nil, function(req,res)
me.scrapeLinks( req,res, 0 )
@ -127,7 +128,7 @@ local plugin = {
:gsub("#.*",'')
)
if not util.fileExist( newdir ) then
util.execute('janusxr scrape "' .. node.prop.url .. '" "' .. newdir .. '" 1>&2')
util.execute('janusxr scrape "' .. node.prop.url .. '" "' .. newdir .. '" 1>&2 | tee -a scraper.txt')
else
print("already scraped: " .. node.prop.url )
end
@ -136,7 +137,6 @@ local plugin = {
end,
scan = {
JML = function(req,res)
local me = app.plugin.archive.scraper
-- JML heuristic

View file

@ -43,6 +43,7 @@ return {
end,
scan = function(scandir)
app.reset() -- flush cache
local me = app.plugin.diskscan
local seen = {symlinks={}}
-- ensure rootdir as experience
@ -51,7 +52,7 @@ return {
url = "/" .. util.stripPath(scandir),
name = scandir:gsub(".*/",""),
thumbnail = "",
title = scandir:gsub(".*/",""):gsub("-"," "),
title = util.dehyphenate( scandir:gsub(".*/","") ),
file = { ino = scandir },
id = scandir
},scandir)
@ -79,11 +80,14 @@ return {
v.file.state = nil
end
end)
table.sort( app.cache.dirs)
end,
scanDir = function(scandir,parent,seen)
local me = app.plugin.diskscan
parent = parent or app.cache.items[scandir]
app.cache.dirs[ scandir ] = util.stripPath(scandir)
-- scan dirs
local scanFile = function(path,stat,state,name,kind,ino,off,attr,fullpath)
@ -91,7 +95,7 @@ return {
path = fullpath,
url = "/" .. util.stripPath(fullpath),
name = name,
title = name:gsub("-"," "),
title = util.dehyphenate( name ),
sidecarfile = {},
thumbnail = '',
tag = {},

View file

@ -198,6 +198,14 @@ You've got this!
default="Remote / Federated",
values=''
},
{
key='app.opts.plugin_global_portal_width',
title='Portal spacing',
info='Adjust spacing between portals (prevent thumbnail-overlap)',
placeholder = "3.2",
default = "3.2",
values=''
},
{
key='app.opts.plugin_global_backup_trigger',
title='Backup',

View file

@ -29,14 +29,16 @@ app.on('scan_experience_file', function(exp)
end
end)
if wrap then
exp.view3D = true
app.markViewable(exp)
exp.janusxr = { wrap = { object = true }}
exp.janusxr.room = me.wrapRoomAroundFile( exp )
app.pub("scan_experience_janusxr", nil, {exp=exp, item=item}) -- be nice to other plugins
app.addExperience( exp, exp.file.ino )
print("✅ └─ plugin/janusxr: wrapping " .. exp.path)
end
end
if me.isWrapFile(exp.path) and size < 100000 then -- skip large binary files > 100kb
if (not me.isWrapFile(exp.path)) and size < 100000 then -- skip large binary files > 100kb
local data = util.readfile( exp.file.fullpath)
if data then
if data:match(pattern_fireboxroom) or
@ -45,16 +47,15 @@ app.on('scan_experience_file', function(exp)
if title then
exp.title = title:gsub("<.*","")
end
app.pub("scan_experience_janusxr", nil, {exp=exp, item=item}) -- be nice to other plugins
app.addExperience( exp, exp.file.ino )
exp.view3D = true
exp.parent.redirect = exp.url -- mark parent to redirect to this standalone url instead (not glb assets e.g.)
app.markViewable(exp)
exp.janusxr = exp.janusxr or {}
print("✅ └─ plugin/janusxr: " .. exp.file.name)
if data:match(pattern_aframe) and app.opts.plugin_janusxr_wrap_aframe == 'enabled' then
exp.janusxr.wrap = {aframe = true }
print("✅ └─ plugin/janusxr: AFRAME markup detected")
end
app.pub("scan_experience_janusxr", nil, {exp=exp, item=item}) -- be nice to other plugins
app.addExperience( exp, exp.file.ino )
end
else
-- symlink to directory
@ -65,7 +66,7 @@ end)
app.on('set', function(k,v) end)
-- this registers the plugin UI
local rooms = {random="random room",randomurl="random room (URL-deterministic)",room_plane="wirefloor",room1="room1",room2="room2",room3="room3",room4="room4",room5="room5",room6="room6",room7="room7"}
local rooms = { [""]="empty",random="random room",randomurl="random room (URL-deterministic)",room_plane="wirefloor",room1="room1",room2="room2",room3="room3",room4="room4",room5="room5",room6="room6",room7="room7"}
local plugin = {
name = "JanusXR",
@ -91,44 +92,44 @@ local plugin = {
{
key='app.opts.plugin_janusxr_wrap_custom',
title='VR wrap custom',
info='include "index.jml" files (if found) in generated JML',
default="index.jml",
values={ ["index.jml"] = "index.jml"}
info='include "wrapper.jml" file (if present) in generated JML',
default="wrapper.jml",
values={ ["wrapper.jml"] = "wrapper.jml"}
},
{
key='app.opts.plugin_janusxr_wrap_3dfile',
title='VR wrap 3D file',
info='render 3D files in <a href="https://coderofsalvation.github.io/janus-guide/#/build/room" target="_blank">janusxr.xml</a> room',
default="randomurl",
values=util.merge(rooms,{disabled="-- disabled --"})
values=rooms
},
{
key='app.opts.plugin_janusxr_wrap_image',
title='VR wrap images',
info='render images in <a href="https://coderofsalvation.github.io/janus-guide/#/build/room" target="_blank">janusxr.xml</a> room',
default="randomurl",
values=util.merge(rooms,{disabled="-- disabled --"})
values=rooms
},
{
key='app.opts.plugin_janusxr_wrap_video',
title='VR wrap videos',
info='render videos in <a href="https://coderofsalvation.github.io/janus-guide/#/build/room" target="_blank">janusxr.xml</a> room',
default="randomurl",
values=util.merge(rooms,{disabled="-- disabled --"})
values=rooms
},
{
key='app.opts.plugin_janusxr_wrap_aframe',
title='AFRAME support',
info='render <a href="https://aframe.io">AFRAME</a> (geometry) markup when detected in URLs',
default="room_plane",
values=util.merge(rooms,{disabled="-- disabled --"})
values=rooms
},
{
key='app.opts.plugin_janusxr_portalroom',
title='Directory room',
info='What kind of space should directories be rendered into?',
default="room_plane",
values={random="random room",room_plane="wirefloor",room1="room1",room2="room2",room3="room3",room4="room4",room5="room5",room6="room6",room7="room7"}
values=rooms
},
{
key='app.opts.plugin_janusxr_portal_up',
@ -178,9 +179,27 @@ local plugin = {
}
},
layout = {
portals = require('plugin/janusxr/layout/portals'),
grid = require('plugin/janusxr/layout/grid')
},
addPortal = function(id,x,v,url,i,z,thumb,rotation,scale)
local title = v.title
local jml = ''
jml = jml .. '\t<object js_id="' .. id .. '_' .. i .. '" pos="' .. x .. ' 0 ' .. z .. '" rotation="' .. rotation .. '">\n'
jml = jml .. '\t <link url="' .. util.baseURLify(url) .. '" ' .. thumb .. ' title_col="0.4 0.37 0.4" pos="0 0 0.001" draw_glow="' .. (thumb:match("shader") and 'false' or 'true') .. '" scale="3 3 0.1" />\n'
jml = jml .. '\t <text text="' .. util.truncate(title,20) .. '" pos="0 3.4 0" col="1 1 1" font_scale="false" scale="' .. scale .. '" />\n'
--TODO: fix janusweb paragraph for markdown urls
if v.README ~= nil then
jml = jml .. '\t <paragraph js_id="README" url="' .. v.README .. '" width="600" height="900" pos="0 1 2" transparent="true"/>\n'
end
jml = jml .. '\t</object>\n'
return jml
end,
isWrapFile = function(path)
return tostring(path):match("/" .. app.opts.plugin_janusxr_wrap_custom:gsub("%.",".") .. "jml$")
and true or false
return tostring(path):match( app.opts.plugin_janusxr_wrap_custom )
end,
wrapRoomAroundFile = function(item)
@ -194,6 +213,7 @@ local plugin = {
if room == 'randomurl' then
room = app.plugin.janusxr.getRandomRoom(item.url)
end
if room == 'disabled' then room = '' end
return room
end,

View file

@ -0,0 +1,69 @@
local grid = {
--render = function(items)
-- local jml = ''
-- local gridsize = tonumber( app.opts.plugin_openbrush_layout_grid_size )
-- for i,v in pairs(items) do
-- local url = v.redirect and v.redirect or v.url
-- local pos = {x=0,y=0,z=0}
-- local posStr = pos.x .. ' ' .. pos.y .. ' ' .. pos.z
-- jml = jml .. ' <link url="' .. url .. '" url_pos="' .. posStr .. '" url_scale="1 1 1" auto_load="true"/>\n'
-- end
-- return jml
--end
render = function(items)
local jml = ''
-- Distance between items in meters. gridsize = 5 means 5m x 5m spacing.
local gridsize = tonumber(app.opts.plugin_openbrush_layout_grid_size) or 5
-- Square spiral: Start at (0, 0) right, up, left, down, increasing the step length
local x = 0
local z = 0
local dx = 1
local dz = 0
local steps = 1
local stepsTaken = 0
local turns = 0
local index = 0
for i, v in pairs(items) do
local url = v.redirect and v.redirect or v.url
-- Position current item
local pos = {
x = v.title:match("#overlay") and 0 or x * gridsize,
y = 0,
z = v.title:match("#overlay") and 0 or z * gridsize
}
local posStr = pos.x .. ' ' .. pos.y .. ' ' .. pos.z
local is_nested_room = false
if is_nested_room then
jml = jml .. ' <link url="' .. url .. '" url_pos="' .. posStr
.. '" url_scale="1 1 1" auto_load="true"/>\n'
else
jml = jml .. ' <object id="' .. url .. '" pos="' .. posStr .. '"/>\n'
end
index = index + 1
-- Move to next position in the spiral
if index < #items then
x = x + dx
z = z + dz
stepsTaken = stepsTaken + 1
if stepsTaken == steps then
stepsTaken = 0
-- Rotate clockwise: right -> up -> left -> down
local oldDx = dx
dx = -dz
dz = oldDx
turns = turns + 1
-- Increase the length after every second turn
if turns % 2 == 0 then
steps = steps + 1
end
end
end
end
return jml
end
}
return grid

View file

@ -0,0 +1,22 @@
local portals = {
render = function(items)
local jml = ''
for i,v in pairs(items) do
local width = v.portal_width or tonumber(app.opts.plugin_global_portal_width )
local center = -((#items*width)/1.3) + (i*width)
local url = v.url
local thumb = ' shad..er_id="defaultportal"'
if #v.thumbnail > 0 then
thumb = ' thumb_id="img' .. i .. '" '
end
if v.dir then url = url .. "/" end
if v.redirect then url = v.redirect end -- dont wrap standalone glb's in janusxr
jml = jml
.. app.plugin.janusxr.addPortal('dir',center, v, url,i, -4, thumb, '0 0 0', '0.2 0.2 0.2')
.. '\n'
end
return jml
end
}
return portals

View file

@ -1,21 +1,69 @@
local util = require('util')
local apiSync = require('apiSync')
local syncIcosaGallery = function()
if #app.opts.plugin_openbrush_sync_URL > 0 then
local synced = apiSync.syncHashtags({
tags = util.split( app.opts.plugin_openbrush_sync_hashtag or '', ' '),
apiCall = app.plugin.openbrush.apiCall,
fetch = app.plugin.openbrush.fetch,
download = app.plugin.openbrush.download
})
end
end
app.on('init', function()
end)
app.on('scan_experience', function(exp)
end)
app.on('scan_experience_file', function(exp)
end)
app.on('set', function(k,v)
foreach( app.plugin.openbrush.opts, function(k,opt)
if opt.key == 'app.opts.plugin_openbrush_sync_folder' then
opt.values = app.cache.dirs
end
end)
if util.contains(argv, 'generate') then
syncIcosaGallery()
end
end)
app.on('scheduler', function(opts)
-- if opts.interval == app.opts.plugin_archive_when then
--
-- end
if util.contains(argv, 'server') then
if util.cmdExist('wget') and opts.interval == app.opts.plugin_openbrush_sync_when then
syncIcosaGallery()
end
end
end)
local function scanTiltFile(exp)
if exp.path:lower():match(".tilt$") then
app.markViewable(exp)
end
end
local function scanForOpenBrush(exp)
local tags = app.plugin.openbrush.tags
if not tags then
tags = util.split( app.opts.plugin_openbrush_sync_hashtag, ' ')
table.insert( tags,"openbrush")
app.plugin.openbrush.tags = tags
end
foreach( tags, function(k,tag)
tag = tag:gsub("#","")
if exp.path:match('/' .. tag .. '/') then -- might not be precise enough
if not exp.parent.janusxr then
exp.parent.janusxr = {}
end
exp.parent.janusxr.room = app.opts.plugin_openbrush_wrap_room
exp.parent.portal_width = tonumber( app.opts.plugin_openbrush_portal_width )
exp.parent.layout = app.opts.plugin_openbrush_layout
end
end)
scanTiltFile(exp)
end
app.on('scan_experience', scanForOpenBrush )
app.on('scan_experience_file', scanForOpenBrush )
app.on("scan_experience_janusxr", function(opts) scanForOpenBrush(opts.exp) end)
app.on('set', function(k,v)
end)
local sync_URL = app.opts_plugin_openbrush_sync_URL or ''
@ -28,38 +76,91 @@ local plugin = {
<img src="img/openbrush.png" style="width:50%; max-width:233px; display:inline-block; float:left; margin-right:20px;"/>
<a href="https://openbrush.app/" target="_blank">OpenBrush</a> lets you paint in 3D space with virtual reality.<br>
XRForge's webviewer (<a href="https://github.com/jbaicoianu/janusweb">JanusWeb</a>) supports interconnecting OpenBrush projects via 3D portals.<br/>
OpenBrush projects are stored in a (selfhosted) <a href="https://icosa.gallery" target="_blank">icosa.gallery</a> instance, which can be configured below.
OpenBrush projects are stored in a (selfhosted) <a href="https://icosa.gallery" target="_blank">icosa.gallery</a> instance, which can be configured below.<br><br>
<b>NOTE:</b> for collective virtualworld building use <b>hashtags in project-titles</b>. Each hashtag will become its own folder, which allows for <a href="/about#autocompose">crowdsourcing/combining scenes</a> designed in openbrush.
]],
opts = {
{
key='app.opts.plugin_openbrush_sync_when',
title='When to sync',
info='when to check for new experiences?',
default = "hourly",
values={disabled="disabled", daily="daily", hourly="hourly"}
key='app.opts.plugin_openbrush_wrap_room',
title='Wrap VR space',
info='render 3D files in <a href="https://coderofsalvation.github.io/janus-guide/#/build/room" target="_blank">janusxr.xml</a> room',
default="room_plane",
values= app.plugin.janusxr.opts[4].values -- reuse rooms from janusxr plugin
},
{
key='app.opts.plugin_openbrush_sync_folder_thumbnail',
title='Sync to folder',
info='openbrush (icosa.gallery) assets will be saved here',
placeholder = "openbrush",
default = "openbrush",
key='app.opts.plugin_openbrush_layout',
title='Layout assets',
info='NOTE: use #overlay in asset-title to skip layout (sky/floor/e.g.)',
default="portals",
values={portals="As portals", grid="As grid"}
},
{
key='app.opts.plugin_openbrush_layout_grid_size',
title='Grid size',
info='in square meters (unused for non-grid layout)',
default="3",
placeholder="3",
values=''
},
{
key='app.opts.plugin_openbrush_sync_strategy',
title='How to sync',
info='mirroring = most resilient; references = instant updates',
default = "mirror",
values={mirror="mirror locally",reference="reference remote URL"}
},
{
key='app.opts.plugin_openbrush_sync_when',
title='Sync interval',
info='when to sync?',
default = "boot",
values={disabled="disabled", boot="on boot", hourly="hourly", daily="daily"}
},
{
key='app.opts.plugin_openbrush_sync_bruteforce',
title='Sync anyways',
info='ignore timestamps',
default = "enabled",
values={disabled="disabled", enabled="enabled"}
},
{
key='app.opts.plugin_openbrush_sync_folder',
title='Sync to folder',
info='openbrush (icosa.gallery) assets will be saved here',
placeholder = "openbrush",
default = app.opts.plugin_diskscan_dir,
values = {}
},
{
key='app.opts.plugin_openbrush_sync_folder_thumbnail',
title='Thumbnail',
info='URL of Thumbnail for folder/portal',
placeholder = "img/openbrush.png",
default = "img/openbrush.png",
values=''
},
{
key='app.opts.plugin_openbrush_portal_width',
title='Portal spacing',
info='Adjust spacing between portals (prevent thumbnail-overlap)',
placeholder = "4.2",
default = "4.2",
values=''
},
{
key='app.opts.plugin_openbrush_sync_URL',
title='Icosa gallery URL',
info='public/selfhosted <a href="https://github.com/icosa-foundation/icosa-gallery target="_blank">icosa.gallery</a> server-instance',
placeholder = "https://icosa.gallery",
default = "https://icosa.gallery",
placeholder = "icosa.gallery",
default = "icosa.gallery",
values=''
},
{
key='app.opts.plugin_openbrush_sync_hashtag',
title='tags',
info=( sync_URL and 'space-separated tags will generate their own folders' or ''),
default = "#1 #xrforge",
placeholder = "#1 #xrforge",
values=''
},
{
@ -78,14 +179,6 @@ local plugin = {
default = "",
values=''
},
{
key='app.opts.plugin_openbrush_sync_tag',
title='tags',
info=( sync_URL and 'space-separated, leave empty or see <a href="https://api.' .. sync_URL .. '/v1/docs#/Assets/icosa_api_assets_get_assets">API docs</a>' or ''),
placeholder = "xrforge",
default = "",
values=''
},
{
key='app.opts.plugin_openbrush_sync_triangle_max',
title='Polygons maximum',
@ -97,7 +190,7 @@ local plugin = {
{
key='app.opts.plugin_openbrush_sync_author',
title='Author name',
info='filter assets on specific Author name',
info='space-separated: filter assets on specific Author name',
placeholder='sarah',
default = "",
values=""
@ -105,19 +198,132 @@ local plugin = {
{
key='app.opts.plugin_openbrush_sync_authorid',
title='Author id',
info='filter assets on specific Author id',
info='space-separated: filter assets on specific Author id',
placeholder='9e8b98fbe',
default = "",
values=""
},
{
key='app.opts.plugin_openbrush_sync_maxitems',
title='Max latest items',
info='limit the total amount of result',
placeholder='3',
default = "3",
values=""
},
{
key='app.opts.plugin_openbrush_sync_collection',
title='Collection required',
title='In collection',
info='should assets be part of a collection?',
default = "enabled",
values={enabled="enabled", disabled="disabled"}
default = "disabled",
values={enabled="yes", disabled="no"}
}
}
},
apiCall = function(tag)
return 'https://api.' .. app.opts.plugin_openbrush_sync_URL .. '/v1/assets?xrforge=' .. EscapeFragment(app.host)
.. '&xrforge_strategy=' .. EscapeFragment(app.opts.plugin_openbrush_sync_strategy)
.. (tag and #tag > 0 and '&keywords=' .. EscapeFragment(tag) or '')
.. (#app.opts.plugin_openbrush_sync_category > 0 and '&category=' .. EscapeFragment(app.opts.plugin_openbrush_sync_category) or '')
.. (#app.opts.plugin_openbrush_sync_keywords > 0 and '&keywords=' .. EscapeFragment(app.opts.plugin_openbrush_sync_keywords) or '')
.. (#app.opts.plugin_openbrush_sync_triangle_max > 0 and '&triangleCountMax=' .. EscapeFragment(app.opts.plugin_openbrush_sync_triangle_max) or '')
.. (app.opts.plugin_openbrush_sync_strategy == 'mirror' and '&license=ALL_CC' or '' )
.. (app.opts.plugin_openbrush_sync_collection == 'enabled' and '&inCollection=true' or '' )
.. (#app.opts.plugin_openbrush_sync_maxitems > 0 and '&pageSize=' .. EscapeFragment(app.opts.plugin_openbrush_sync_maxitems) or '' )
-- TODO: filter AFTER the request
-- .. (#app.opts.plugin_openbrush_sync_author > 0 and '&authorName=' .. EscapeFragment(app.opts.plugin_openbrush_sync_author) or '')
-- .. (#app.opts.plugin_openbrush_sync_authorid > 0 and '&authorId=' .. EscapeFragment(app.opts.plugin_openbrush_sync_authorid) or '')
end,
getFolder = function(tag)
local folder = app.opts.plugin_openbrush_sync_folder
if tag then
folder = folder .. '/' .. util.hyphenate(tag)
end
return folder
end,
fetch = function(url,opts)
opts = opts or {}
if not opts.body then
local ok, headers, body = Fetch( url )
if not ok then
return print("[openbrush.lua] error: could not fetch: " .. app.plugin.openbrush.apiCall(tag))
end
opts.body = body
end
return DecodeJson(opts.body)
end,
download = function(t,tag)
local me = app.plugin.openbrush
local synced = 0
foreach( t.assets, function(k,asset)
if type(asset) == 'table' then
local dirname = '_' .. asset.assetId .. '-' .. util.hyphenate( asset.displayName)
local dirnameFull = me.getFolder(tag) .. '/' .. dirname
local jsonfile = dirnameFull .. '/asset.json'
local sync = true
local assetPrevious = false
local f = false
function preferFormat(f)
local found = false
foreach( asset.formats, function(k,v)
if v.formatType == f then
found = v
end
end)
return found
end
f = preferFormat('TILT')
if not f then f = preferFormat('GLTF2') end
if not f then f = preferFormat('GLTF1') end
if not f then f = preferFormat('OBJ') end
if f then
if not util.fileExist( dirnameFull ) then
os.execute("mkdir -p " .. dirnameFull)
end
if util.fileExist(jsonfile) then
assetPrevious = DecodeJson( util.readfile(jsonfile) )
if util.parseIsoDate(asset.updateTime) == util.parseIsoDate(assetPrevious.updateTime) then
sync = false -- already finished
end
end
if sync or app.opts.plugin_openbrush_sync_bruteforce == 'enabled' then
util.writefile( jsonfile, EncodeJson(asset) )
local rootfile = dirnameFull .. '/' .. f.root.url:gsub(".*/","")
local thumbnailfile = dirnameFull .. '.' .. asset.thumbnail.url:gsub(".*%.",'')
if app.opts.plugin_openbrush_layout == 'portals' then
print('🤞 fetch ' .. asset.thumbnail.url)
os.execute('wget "' ..asset.thumbnail.url .. '" -O "' .. thumbnailfile .. '"')
end
print('🤞 fetch ' .. f.root.url)
os.execute('wget "' .. f.root.url .. '" -O "' .. rootfile .. '"')
if #f.resources > 0 then
foreach( f.resources, function(k,resource)
local file = dirnameFull .. '/' .. resource.url:gsub(".*/","")
print('🤞 fetch ' .. resource.url)
os.execute('wget "' .. resource.url .. '" -O "' .. file .. '"')
end)
end
synced = synced + 1
else
print('[openbrush.lua] ' .. asset.displayName .. ' is up to date')
end
else
print('[openbrush.lua] error: no suitable format found for ' .. asset.displayName .. ' ' .. asset.assetId)
end
end
end, true)
return synced
end
}
if not util.cmdExist('wget') then
plugin.info = plugin.info .. '<br><br><b>NOTE:</b> wget command not installed..plugin is disabled'
end
return plugin

View file

@ -16,8 +16,9 @@ return {
if pid == 0 then -- child has zero pid
function periodically()
local check_step = os.getenv('DEBUG') and 1 or 5
local elapsed = { min5 = 0, hourly = 0, daily = 0, daily2 = 0, weekly = 0}
local elapsed = { boot = 0, min5 = 0, hourly = 0, daily = 0, daily2 = 0, weekly = 0}
local intervals = {
boot = 1,
min5 = 60*5, -- 5 mins worth seconds
hourly = 60*60, -- 1 hours worth of seconds
daily = 60*60*24, -- 24 hours worth of seconds

View file

@ -11,9 +11,15 @@ app.on('scan_experience_file', function(exp)
foreach( util.split( app.opts.plugin_xrfragments_filetypes, ' '), function(k,ext)
if exp.path:lower():match("."..ext.."$") then
local hasHref = false
local tree = false
-- detect XR Fragment exp https://xrfragment.org/#%F0%9F%93%9C%20level0%3A%20File
if ext == 'glb' then
-- detect XR Fragment exp https://xrfragment.org/#%F0%9F%93%9C%20level0%3A%20File
local tree = me.extract_glb_json( exp.path ) --util.stripPath(exp.path) )
tree = me.extract_glb_json( exp.path ) --util.stripPath(exp.path) )
end
if ext == 'gltf' then
tree = DecodeJson( util.readfile( exp.path ) )
end
if three then
hasHref = me.detectHrefInStruct(tree)
end
-- check sidecarfiles: https://xrfragment.org/#sidecar%20files
@ -39,7 +45,7 @@ app.on('scan_experience_file', function(exp)
href = hasHref
}
if exp.tag.xrfragments.qualify then
exp.view3D = true
app.markViewable(exp)
print("✅ └─ plugin/xrfragments: detected xrfragments experience")
end
if exp.tag.xrfragments.href then
@ -66,7 +72,7 @@ return {
key='app.opts.plugin_xrfragments_filetypes',
title='Filetypes',
info='Space-separated 3D filetypes, <a href="http://lua-users.org/wiki/PatternsTutorial" target="_blank">lua patterns</a> are allowed',
default="glb gltf obj dae",
default="glb gltf obj dae tilt",
values=''
}
},

File diff suppressed because one or more lines are too long

View file

@ -5,10 +5,12 @@ local describe, it, expect = lust.describe, lust.it, lust.expect
lust.queueServerRequest = util.queueServerRequest
require('test/plugin_global')(lust)
require('test/plugin_git')(lust)
require('test/plugin_diskscan')(lust)
require('test/plugin_xrfragment')(lust)
--require('test/plugin_global')(lust)
--require('test/plugin_git')(lust)
--require('test/plugin_diskscan')(lust)
--require('test/plugin_xrfragment')(lust)
require('test/plugin_openbrush')(lust)
require('test/util')(lust)
if lust.errors == 0 then
print("✅ all tests passed")

View file

@ -0,0 +1,31 @@
return function(lust)
local describe, it, expect = lust.describe, lust.it, lust.expect
describe('src/plugin/openbrush.lua', function()
describe('get API url', function() -- Can be nested
local url = app.plugin.openbrush.apiCall()
expect( url:match('https://') ).to.be.truthy()
end)
describe('parse icosa.gallery json', function() -- Can be nested
local json = util.readfile('/zip/test/json/icosagallery.json')
local opts = {body=json}
local t = app.plugin.openbrush.fetch('',opts)
expect( t.assets and #t.assets > 0 ).to.be.truthy()
end)
--describe('download icosa.gallery assets', function() -- Can be nested
-- local json = util.readfile('/zip/test/json/icosagallery.json')
-- local opts = {body=json}
-- foreach( app.plugin.openbrush.fetch(opts), function(i,t)
-- local res = app.plugin.openbrush.download(t)
-- expect( res ).to.be.truthy()
-- end)
--end)
end)
end

18
src/test/util.lua Normal file
View file

@ -0,0 +1,18 @@
return function(lust)
local describe, it, expect = lust.describe, lust.it, lust.expect
describe('src/.lua/util.lua', function()
describe('hyphenate()', function() -- Can be nested
expect( util.hyphenate(" foo bar") == "foo-bar" ).to.be.truthy()
end)
describe('dehyphenate()', function() -- Can be nested
expect( util.dehyphenate("_e3f55-foo-bar-_438394") == "foo bar" ).to.be.truthy()
end)
end)
end

View file

@ -54,7 +54,7 @@ build(){
rebuild(){
cd src
zip -yr ../result/bin/xrforge.com .lua js shader img middleware page plugin cmd css .init.lua .args test myverse
zip -yr ../result/bin/xrforge.com .lua js shader img layout middleware page plugin cmd css .init.lua .args test myverse
}
"$@"