2026-09-15 20:41:30 +02:00
local util = require ( ' util ' )
2026-09-11 18:42:12 +02:00
local apiSync = require ( ' apiSync ' )
2026-09-04 16:34:57 +02:00
app.on ( ' init ' , function ( )
2026-09-15 20:41:30 +02:00
app.plugin . openbrush.api = apiSync.init ( ' openbrush ' )
2026-09-04 16:34:57 +02:00
end )
2026-09-11 18:42:12 +02:00
local function scanTiltFile ( exp )
if exp.path : lower ( ) : match ( " .tilt$ " ) then
app.markViewable ( exp )
end
end
2026-09-04 16:34:57 +02:00
2026-09-28 15:22:20 +02:00
local function scanForOpenBrush ( exp , opts )
local tags = app.plugin . openbrush.getTags ( )
2026-09-11 18:42:12 +02:00
foreach ( tags , function ( k , tag )
tag = tag : gsub ( " # " , " " )
2026-09-28 15:22:20 +02:00
if exp.url : match ( app.plugin . openbrush.pattern . collection ( tag ) ) then -- collection
if not exp.janusxr then
exp.janusxr = { }
2026-09-11 18:42:12 +02:00
end
2026-09-28 15:22:20 +02:00
exp.janusxr . layout = app.opts . plugin_openbrush_layout
exp.janusxr . room = app.opts [ " plugin_openbrush_wrap_room_ " .. app.opts . plugin_openbrush_layout ] or ' empty '
exp.janusxr . portal_width = tonumber ( app.opts . plugin_openbrush_portal_width )
exp.janusxr . grid_size = tonumber ( app.opts . plugin_openbrush_grid_size )
2026-09-11 18:42:12 +02:00
end
end )
scanTiltFile ( exp )
end
2026-09-04 16:34:57 +02:00
2026-09-28 15:22:20 +02:00
app.on ( ' scan_experience ' , function ( exp ) scanForOpenBrush ( exp , { dir = true } ) end )
app.on ( ' scan_experience_janusxr ' , function ( opts )
local exp = opts.exp
if exp then
local tags = app.plugin . openbrush.getTags ( )
foreach ( tags , function ( k , tag )
tag = tag : gsub ( " # " , " " )
if exp.url : match ( app.plugin . openbrush.pattern . asset ( tag ) ) then -- asset
-- disable janusxr room wrapping (if any)
exp.janusxr = nil
-- also for the parent, and redirect to indirect child asset
if exp.parent . layout == nil and exp.parent . janusxr then
exp.parent . janusxr = nil
exp.parent . redirect = exp.url
end
end
end )
end
end )
2026-09-11 18:42:12 +02:00
app.on ( ' set ' , function ( k , v )
2026-09-04 16:34:57 +02:00
end )
local sync_URL = app.opts_plugin_openbrush_sync_URL or ' '
-- this registers the plugin UI
local plugin = {
name = " OpenBrush " ,
infowide = true ,
info = [ [
< 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 3 D space with virtual reality . < br >
2026-09-15 20:41:30 +02:00
XRForge ' s webviewer (<a href="https://github.com/jbaicoianu/janusweb">JanusWeb</a>) supports interconnecting OpenBrush projects via 3D portals or a grid.<br/>
2026-09-11 18:42:12 +02:00
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 .
2026-09-04 16:34:57 +02:00
] ] ,
2026-09-15 20:41:30 +02:00
--buttons = {
-- { label = "resync now!", href="/reset" }
--},
2026-09-04 16:34:57 +02:00
opts = {
2026-09-11 18:42:12 +02:00
{
key = ' app.opts.plugin_openbrush_layout ' ,
title = ' Layout assets ' ,
info = ' NOTE: use #overlay in asset-title to skip layout (sky/floor/e.g.) ' ,
2026-09-28 15:24:50 +02:00
default = " grid " ,
2026-09-11 18:42:12 +02:00
values = { portals = " As portals " , grid = " As grid " }
} ,
2026-09-24 17:52:58 +02:00
{
key = ' app.opts.plugin_openbrush_wrap_room_portals ' ,
title = ' grid: wrap ' ,
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_wrap_room_grid ' ,
title = ' portals: wrap ' ,
info = ' render 3D files in <a href="https://coderofsalvation.github.io/janus-guide/#/build/room" target="_blank">janusxr.xml</a> room ' ,
default = " empty_floor " ,
values = app.plugin . janusxr.opts [ 4 ] . values -- reuse rooms from janusxr plugin
} ,
2026-09-11 18:42:12 +02:00
{
key = ' app.opts.plugin_openbrush_layout_grid_size ' ,
title = ' Grid size ' ,
2026-09-15 20:41:30 +02:00
info = ' in square meters (only used for grid layout) ' ,
2026-09-16 13:43:33 +02:00
default = " 74 " ,
placeholder = " 74 " ,
2026-09-11 18:42:12 +02:00
values = ' '
} ,
{
key = ' app.opts.plugin_openbrush_sync_strategy ' ,
title = ' How to sync ' ,
2026-09-15 20:41:30 +02:00
info = ' download assets locally (prevent linkrot) ' ,
2026-09-11 18:42:12 +02:00
default = " mirror " ,
2026-09-15 20:41:30 +02:00
values = { mirror = " mirror locally " }
2026-09-11 18:42:12 +02:00
} ,
2026-09-04 16:34:57 +02:00
{
key = ' app.opts.plugin_openbrush_sync_when ' ,
2026-09-11 18:42:12 +02:00
title = ' Sync interval ' ,
info = ' when to sync? ' ,
default = " boot " ,
values = { disabled = " disabled " , boot = " on boot " , hourly = " hourly " , daily = " daily " }
2026-09-04 16:34:57 +02:00
} ,
2026-09-11 18:42:12 +02:00
{
key = ' app.opts.plugin_openbrush_sync_folder ' ,
2026-09-04 16:34:57 +02:00
title = ' Sync to folder ' ,
info = ' openbrush (icosa.gallery) assets will be saved here ' ,
2026-09-16 13:43:33 +02:00
default = " ./myverse/tronverse " ,
type = " dir " ,
values = { } -- populated by plugin/diskscan.lua
2026-09-04 16:34:57 +02:00
} ,
{
2026-09-11 18:42:12 +02:00
key = ' app.opts.plugin_openbrush_sync_folder_thumbnail ' ,
2026-09-04 16:34:57 +02:00
title = ' Thumbnail ' ,
info = ' URL of Thumbnail for folder/portal ' ,
placeholder = " img/openbrush.png " ,
default = " img/openbrush.png " ,
values = ' '
} ,
2026-09-11 18:42:12 +02:00
{
key = ' app.opts.plugin_openbrush_portal_width ' ,
title = ' Portal spacing ' ,
2026-09-15 20:41:30 +02:00
info = ' Spacing between portals (prevent thumbnail-overlap) ' ,
2026-09-11 18:42:12 +02:00
placeholder = " 4.2 " ,
default = " 4.2 " ,
values = ' '
} ,
2026-09-04 16:34:57 +02:00
{
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 ' ,
2026-09-11 18:42:12 +02:00
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 ' ' ) ,
2026-09-16 13:43:33 +02:00
default = " #tronverse-icosagallery " ,
placeholder = " #tronverse-icosagallery " ,
2026-09-04 16:34:57 +02:00
values = ' '
} ,
{
key = ' app.opts.plugin_openbrush_sync_category ' ,
title = ' category ' ,
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 = " ART " ,
default = " " ,
values = ' '
} ,
{
key = ' app.opts.plugin_openbrush_sync_keywords ' ,
title = ' keywords ' ,
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 ' ,
info = ( sync_URL and ' performance-cap, leave empty or see <a href="https://api. ' .. sync_URL .. ' /v1/docs#/Assets/icosa_api_assets_get_assets">API docs</a> ' or ' ' ) ,
placeholder = " 10000 " ,
default = " " ,
values = ' '
} ,
2026-09-15 20:41:30 +02:00
{
key = ' app.opts.plugin_openbrush_sync_license ' ,
title = ' License ' ,
info = ' filter assets on specific license ' ,
default = " " ,
values = { [ " " ] = " " , ALL_CC = " CreateCommons (ALL) " }
} ,
2026-09-04 16:34:57 +02:00
{
key = ' app.opts.plugin_openbrush_sync_author ' ,
title = ' Author name ' ,
2026-09-11 18:42:12 +02:00
info = ' space-separated: filter assets on specific Author name ' ,
2026-09-04 16:34:57 +02:00
placeholder = ' sarah ' ,
default = " " ,
values = " "
} ,
{
key = ' app.opts.plugin_openbrush_sync_authorid ' ,
title = ' Author id ' ,
2026-09-11 18:42:12 +02:00
info = ' space-separated: filter assets on specific Author id ' ,
2026-09-04 16:34:57 +02:00
placeholder = ' 9e8b98fbe ' ,
default = " " ,
values = " "
} ,
2026-09-11 18:42:12 +02:00
{
key = ' app.opts.plugin_openbrush_sync_maxitems ' ,
title = ' Max latest items ' ,
info = ' limit the total amount of result ' ,
2026-09-16 13:43:33 +02:00
placeholder = ' 8 ' ,
default = " 8 " ,
2026-09-11 18:42:12 +02:00
values = " "
} ,
2026-09-04 16:34:57 +02:00
{
key = ' app.opts.plugin_openbrush_sync_collection ' ,
2026-09-11 18:42:12 +02:00
title = ' In collection ' ,
2026-09-04 16:34:57 +02:00
info = ' should assets be part of a collection? ' ,
2026-09-11 18:42:12 +02:00
default = " disabled " ,
values = { enabled = " yes " , disabled = " no " }
2026-09-04 16:34:57 +02:00
}
2026-09-11 18:42:12 +02:00
} ,
2026-09-28 15:22:20 +02:00
pattern = {
collection = function ( tag ) return ' / ' .. tag : gsub ( " - " , " %%- " ) .. ' $ ' end ,
asset = function ( tag ) return ' / ' .. tag : gsub ( " - " , " %%- " ) .. ' / ' end ,
} ,
getTags = function ( )
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
return tags
end ,
2026-09-15 20:41:30 +02:00
-- expected by src/.lua/apiSync.lua
2026-09-11 18:42:12 +02:00
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 )
2026-09-15 20:41:30 +02:00
.. ' &time= ' .. tostring ( os.time ( ) )
2026-09-11 18:42:12 +02:00
.. ( 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 ' ' )
2026-09-15 20:41:30 +02:00
.. ( # app.opts . plugin_openbrush_sync_license > 0 and ' &license= ' .. app.opts . plugin_openbrush_sync_license or ' ' )
2026-09-11 18:42:12 +02:00
.. ( 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 ,
2026-09-15 20:41:30 +02:00
-- expected by src/.lua/apiSync.lua
2026-09-11 18:42:12 +02:00
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 ,
2026-09-16 13:43:33 +02:00
ensureREADME = function ( tag , file )
if not util.fileExist ( file ) then
util.writefile ( file , app.opts . plugin_global_popup_instructions .. ' \n '
.. [ [ This virtual world is generated in realtime via < a href = ' https://icosa.gallery ' target = ' _blank ' > icosa.gallery </ a > .
You can add / create parcels in icosa.gallery , by publishing via < a href = ' https://openbrush.app ' > OpenBrush </ a > , < a href = ' https://openblocks.app ' > OpenBlocks </ a > , or < a href = ' https://blender.org ' target = ' _blank ' > Blender </ a > . < br >
> NOTE : Make sure to add hashtag ** ] ] .. tag .. [[** to the title (and re-run xrforge for fast re-sync). ]]
)
end
end ,
2026-09-15 20:41:30 +02:00
-- expected by src/.lua/apiSync.lua
2026-09-11 18:42:12 +02:00
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 '
2026-09-16 13:43:33 +02:00
local README = me.getFolder ( tag ) .. ' /README.md '
2026-09-11 18:42:12 +02:00
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
2026-09-16 13:43:33 +02:00
me.ensureREADME ( tag , README )
2026-09-11 18:42:12 +02:00
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
2026-09-15 20:41:30 +02:00
if sync then
print ( ' ✅ ' .. asset.displayName .. ' (syncing) #openbrush ' )
2026-09-11 18:42:12 +02:00
util.writefile ( jsonfile , EncodeJson ( asset ) )
local rootfile = dirnameFull .. ' / ' .. f.root . url : gsub ( " .*/ " , " " )
2026-09-15 20:41:30 +02:00
local thumbnailfile = ' '
if asset.thumbnail and asset.thumbnail . url then
thumbnailfile = dirnameFull .. ' . ' .. asset.thumbnail . url : gsub ( " .*%. " , ' ' )
2026-09-11 18:42:12 +02:00
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
2026-09-15 20:41:30 +02:00
print ( ' ℹ ️ ' .. asset.displayName .. ' (up to date) #openbrush ' )
2026-09-11 18:42:12 +02:00
end
else
print ( ' [openbrush.lua] error: no suitable format found for ' .. asset.displayName .. ' ' .. asset.assetId )
end
end
end , true )
return synced
end
2026-09-04 16:34:57 +02:00
}
2026-09-11 18:42:12 +02:00
if not util.cmdExist ( ' wget ' ) then
plugin.info = plugin.info .. ' <br><br><b>NOTE:</b> wget command not installed..plugin is disabled '
end
2026-09-04 16:34:57 +02:00
return plugin