------------------------------------------------------------------------------- -- ReawoteFStormBridge.ms -- Bridges Reawote PBR texture downloads with FStorm materials in 3ds Max. -- -- What it does: -- 1. "Search on Reawote" opens a browser search on reawote.com for your query -- (the actual download still happens in your own logged-in browser -- -- this script never touches your Reawote login/credits). -- 2. "Build material from newest download" finds the most recently modified -- .zip in your watch folder (default: Downloads), extracts it, scans the -- extracted files for Reawote's map-name convention (_COL_, _GLOSS_, -- _ROUGH_, _NRM16_/_NRM_, _DISP16_/_DISP_, _AO_, _METAL_) and builds (or -- updates) a fully wired FStorm material from them -- same slot mapping, -- gamma and tiling conventions used by hand on the "travertin" material. -- 3. "Apply to selection" assigns the built material to selected objects. -- -- Install: fileIn this script, or drop it in your 3ds Max scripts\startup -- folder so it loads automatically. Then find it under -- Customize > Customize User Interface, category "ReawoteBridge", or just -- run macros.run "ReawoteBridge" "OpenReawoteFStormBridge" ------------------------------------------------------------------------------- global ReawoteBridge_LastMaterial = undefined global ReawoteBridge_LastWidthCM = undefined global ReawoteBridge_LastHeightCM = undefined global ReawoteBridge_LastAssetURL = "" ------------------------------------------------------------------------------- -- Helpers ------------------------------------------------------------------------------- -- Find newest file matching a wildcard pattern in a folder (non-recursive). -- NOTE: MAXScript's getFileModDate returns a STRING ("9/10/2026 3:07:26 PM"), -- and comparing those with ">" does lexicographic (character-by-character) -- comparison, not chronological comparison -- "9/10/..." sorts BEFORE -- "9/9/..." because '1' < '9'. That silently picked the wrong "newest" file -- for any day-of-month from 10-31 whenever a single-digit day was also -- present. Use .NET FileInfo.LastWriteTime (a real DateTime, comparable with -- CompareTo) instead. fn rwFindNewest folder pattern = ( local files = getFiles (folder + pattern) local newestFile = undefined local newestTime = undefined for f in files do ( local dt = undefined try ( local fi = dotNetObject "System.IO.FileInfo" f dt = fi.LastWriteTime ) catch (dt = undefined) if dt != undefined then ( if newestTime == undefined or (dt.CompareTo newestTime) > 0 then ( newestTime = dt newestFile = f ) ) ) newestFile ) -- Extract a .zip to a destination folder using .NET, return true/false. -- Always starts from a clean (deleted+recreated) destination folder -- -- ZipFile.ExtractToDirectory throws "file already exists" the moment any -- single file in the archive already exists at the destination (e.g. from a -- previous extraction attempt, or a re-downloaded "(1)" duplicate zip with -- identical contents), which otherwise aborts the whole extraction. fn rwExtractZip zipPath destFolder = ( local ok = false try ( if doesFileExist destFolder then ( (dotNetClass "System.IO.Directory").Delete destFolder true ) makeDir destFolder all:true local zipClass = dotNetClass "System.IO.Compression.ZipFile" zipClass.ExtractToDirectory zipPath destFolder ok = true ) catch ( format "ReawoteBridge: zip extraction failed: %\n" (getCurrentException()) ok = false ) ok ) -- Only these extensions are ever considered texture maps. This is the main -- guard against picking up unrelated 3ds Max internal files (.ms, .fraglib, -- .mzp etc.) that happen to sit in the same folder and whose names can -- coincidentally contain words like "color"/"normal"/"metal". fn rwIsImageFile fname = ( local ext = toLower (getFilenameType fname) local exts = #(".jpg", ".jpeg", ".png", ".tif", ".tiff", ".exr", ".bmp", ".tga", ".hdr") local found = false for e in exts do if ext == e then found = true found ) -- Recursively collect image file paths under a folder (depth-limited to 3). -- Non-image files (scripts, libraries, project files, etc.) are skipped -- entirely so they can never be mistaken for a texture map. fn rwCollectFiles folder depth = ( local result = #() local files = getFiles (folder + "*.*") for f in files do if rwIsImageFile f then append result f if depth > 0 then ( local dirs = getDirectories (folder + "*") for d in dirs do ( local sub = d if not (matchPattern sub pattern:"*\\.") and not (matchPattern sub pattern:"*\\..") then join result (rwCollectFiles (sub + "\\") (depth - 1)) ) ) result ) -- True if `tok` appears as a whole underscore-delimited word in nameNoExt -- (case-insensitive) -- e.g. token "COL" matches "..._COL_8K" but not -- "...Color..." run together, and not "...Collection...". fn rwTokenMatch nameNoExt tok = ( local parts = filterString (toUpper nameNoExt) "_" local upperTok = toUpper tok local found = false for p in parts do if p == upperTok then found = true found ) -- Given a list of image file paths, find the best match for a set of -- whole-word suffix tokens (e.g. #("COL","COLOR","ALBEDO")). -- Prefers 16-bit variants when both a "*16*" and a plain version exist. fn rwPickMap fileList tokens = ( local best = undefined local bestIs16 = false for f in fileList do ( local nameOnly = getFilenameFile f -- filename, no path, no extension for tok in tokens do ( if rwTokenMatch nameOnly tok then ( local is16 = matchPattern nameOnly pattern:"*16*" if best == undefined or (is16 and not bestIs16) then ( best = f bestIs16 = is16 ) ) ) ) best ) -- Configure one FStormBitmap texmap with the standard, consistent settings. -- All imported FStorm bitmaps use explicit gamma 1.0, including base color. fn rwConfigureBitmap bmp filePath isColorData isNormal = ( bmp.filename = filePath bmp.mapping = 2 -- Explicit Map Channel bmp.map_channel = 1 bmp.real_world = false bmp.tiling = [1,1,1] bmp.input_gamma = true bmp.gamma = 1.0 bmp.normal_map = isNormal if isNormal then bmp.normal_map_power = 100.0 true ) ------------------------------------------------------------------------------- -- Core: build/update an FStorm material from a folder of texture maps ------------------------------------------------------------------------------- fn ReawoteBridge_BuildMaterialFromFolder folder matName = ( local files = rwCollectFiles folder 3 if files.count == 0 then ( messageBox ("No files found under:\n" + folder) title:"ReawoteBridge" return undefined ) local colMap = rwPickMap files #("COL", "COLOR", "ALBEDO", "DIFFUSE") local aoMap = rwPickMap files #("AO", "OCCLUSION") local glossMap = rwPickMap files #("GLOSS", "GLOSSINESS") local roughMap = rwPickMap files #("ROUGH", "ROUGHNESS") local nrmMap = rwPickMap files #("NRM16", "NRM", "NORMAL") local dispMap = rwPickMap files #("DISP16", "DISP", "HEIGHT", "DISPLACEMENT") local metalMap = rwPickMap files #("METAL", "METALNESS") if colMap == undefined and nrmMap == undefined and roughMap == undefined and glossMap == undefined then ( messageBox ("Couldn't recognize any Reawote/PBR map names under:\n" + folder + "\n\nExpected suffixes like _COL_, _GLOSS_, _ROUGH_, _NRM16_, _DISP16_.") title:"ReawoteBridge" return undefined ) -- find or create the material local mtl = sceneMaterials[matName] if mtl == undefined or classOf mtl != FStorm then ( mtl = FStorm() mtl.name = matName sceneMaterials[matName] = mtl ) if colMap != undefined then ( mtl.diffuse_tex_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp colMap true false mtl.diffuse_tex = bmp ) -- prefer a real glossiness map; otherwise feed roughness with the invert flag if glossMap != undefined then ( mtl.reflection_glossy_tex_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp glossMap false false mtl.reflection_glossy_tex = bmp mtl.refl_glossiness_invert = false ) else if roughMap != undefined then ( mtl.reflection_glossy_tex_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp roughMap false false mtl.reflection_glossy_tex = bmp mtl.refl_glossiness_invert = true ) -- a second gloss-type map (if both existed) goes on reflection_tex as intensity if glossMap != undefined and roughMap != undefined then ( mtl.reflection_tex_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp roughMap false false mtl.reflection_tex = bmp ) if nrmMap != undefined then ( mtl.bump_texture_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp nrmMap false true mtl.bump_texture = bmp mtl.bump_texture_amount = 1.0 ) if dispMap != undefined then ( mtl.displacement_texture_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp dispMap false false mtl.displacement_texture = bmp ) if metalMap != undefined then ( try ( mtl.metalness_tex_on = true local bmp = FStormBitmap() rwConfigureBitmap bmp metalMap false false mtl.metalness_tex = bmp ) catch () ) ReawoteBridge_LastMaterial = mtl local summary = StringStream "" format "Material: %\n" mtl.name to:summary format " diffuse: %\n" (if colMap != undefined then filenameFromPath colMap else "-- none --") to:summary format " glossy: %\n" (if glossMap != undefined then filenameFromPath glossMap else (if roughMap != undefined then (filenameFromPath roughMap + " (inverted, roughness)") else "-- none --")) to:summary format " bump/normal:%\n" (if nrmMap != undefined then filenameFromPath nrmMap else "-- none --") to:summary format " displacement:%\n" (if dispMap != undefined then filenameFromPath dispMap else "-- none --") to:summary format " metalness: %\n" (if metalMap != undefined then filenameFromPath metalMap else "-- none --") to:summary messageBox (summary as string) title:"ReawoteBridge - material built" mtl ) ------------------------------------------------------------------------------- -- Core: locate + (optionally) extract the newest download, then build ------------------------------------------------------------------------------- fn ReawoteBridge_BuildFromNewestDownload watchFolder extractRoot = ( -- Defensive fallback: an empty folder string makes getFiles search 3ds -- Max's own working directory instead of the intended folder, which -- silently finds nothing rather than erroring loudly. if watchFolder == undefined or watchFolder == "" then watchFolder = "C:\\Users\\ROG\\Downloads\\" if extractRoot == undefined or extractRoot == "" then extractRoot = "D:\\Reawote\\tiles\\" -- Always use the newest .zip -- Reawote always delivers as a .zip, so -- that's the right signal for "the newest texture download", regardless -- of whether some unrelated newer *non-zip* file also happens to sit in -- the watch folder (installer leftovers like InstallSettings.ini, -- screenshots, etc -- those aren't texture downloads and shouldn't block -- this). Earlier versions required the zip to also be the single newest -- *file* of any kind, which broke the moment anything else newer landed -- in Downloads. local newestZip = rwFindNewest watchFolder "*.zip" if newestZip == undefined then ( messageBox ("No .zip found in:\n" + watchFolder + "\n\nDownload a texture set from Reawote first (it saves as a .zip), " + "then press Build again.") title:"ReawoteBridge - nothing to build" return undefined ) local base = filenameFromPath newestZip base = substring base 1 (base.count - 4) -- strip ".zip" local destFolder = extractRoot + base + "\\" if rwExtractZip newestZip destFolder then ( ReawoteBridge_BuildMaterialFromFolder destFolder base ) else ( messageBox ("Failed to extract:\n" + newestZip) title:"ReawoteBridge" undefined ) ) ------------------------------------------------------------------------------- -- Physical UVW: public Reawote product page -> Box UVW modifier ------------------------------------------------------------------------------- fn rwCmValue raw unit = ( local v = raw as float if (toLower unit) == "m" then v * 100.0 else v ) fn rwClipboardURL = ( try ( local clip = (dotNetClass "System.Windows.Forms.Clipboard").GetText() if clip != undefined and (matchPattern clip pattern:"https://*" ignoreCase:true or matchPattern clip pattern:"http://*" ignoreCase:true) then clip else "" ) catch ("") ) fn rwLastReawoteURL = ( local result = "" try ( local psi = dotNetObject "System.Diagnostics.ProcessStartInfo" psi.FileName = "C:/Users/ROG/AppData/Local/hermes/hermes-agent/venv/Scripts/python.exe" psi.Arguments = "C:/Users/ROG/Desktop/Hermes/reawote_history_url.py" psi.UseShellExecute = false psi.RedirectStandardOutput = true psi.CreateNoWindow = true local proc = dotNetObject "System.Diagnostics.Process" proc.StartInfo = psi proc.Start() result = (proc.StandardOutput.ReadToEnd()) as string result = substituteString result "\r" "" result = substituteString result "\n" "" proc.WaitForExit() ) catch (result = "") result ) -- Reads only a public product page supplied by the user. The page reader avoids -- passing Reawote login, cookies, credits, or downloads through the bridge. fn rwFetchPhysicalSize pageURL = ( if pageURL == undefined or pageURL == "" then return undefined try ( local wc = dotNetObject "System.Net.WebClient" wc.Headers.Add "User-Agent" "Mozilla/5.0" -- r.jina.ai expects the target without its protocol prefix. -- Passing "https://example.com" directly here previously created -- ".../http://https://example.com", so no dimensions were returned. local cleanURL = pageURL if matchPattern cleanURL pattern:"https://*" ignoreCase:true then cleanURL = substring cleanURL 9 (cleanURL.count - 8) else if matchPattern cleanURL pattern:"http://*" ignoreCase:true then cleanURL = substring cleanURL 8 (cleanURL.count - 7) local readerURL = "https://r.jina.ai/http://" + cleanURL local text = wc.DownloadString readerURL local rx = dotNetClass "System.Text.RegularExpressions.Regex" -- Reawote currently publishes most product sizes as one line, -- e.g. "Texture dimensions: 138x127 cm". local dm = rx.Match text "(?i)(?:Texture dimensions|Physical dimensions|Dimensions)\\s*[:=]?\\s*([0-9.]+)\\s*[xX]\\s*([0-9.]+)\\s*(cm|m)" if dm.Success then ( local unit = dm.Groups.Item[3].Value ReawoteBridge_LastWidthCM = rwCmValue dm.Groups.Item[1].Value unit ReawoteBridge_LastHeightCM = rwCmValue dm.Groups.Item[2].Value unit ReawoteBridge_LastAssetURL = pageURL #(ReawoteBridge_LastWidthCM, ReawoteBridge_LastHeightCM) ) else ( -- Fallback for pages exposing separate Width and Height fields. local wm = rx.Match text "(?i)Width\\s*(?:[:x]|is)?\\s*([0-9.]+)\\s*(cm|m)" local hm = rx.Match text "(?i)(?:Height|Repeat Height|Vertical Repeat)\\s*(?:[:x]|is)?\\s*([0-9.]+)\\s*(cm|m)" if wm.Success and hm.Success then ( ReawoteBridge_LastWidthCM = rwCmValue wm.Groups.Item[1].Value wm.Groups.Item[2].Value ReawoteBridge_LastHeightCM = rwCmValue hm.Groups.Item[1].Value hm.Groups.Item[2].Value ReawoteBridge_LastAssetURL = pageURL #(ReawoteBridge_LastWidthCM, ReawoteBridge_LastHeightCM) ) else undefined ) ) catch (undefined) ) fn rwApplyPhysicalUVW widthCM heightCM = ( if selection.count == 0 then return 0 local mapW = units.decodeValue ((widthCM as string) + "cm") local mapH = units.decodeValue ((heightCM as string) + "cm") local applied = 0 for obj in selection where (superClassOf obj == GeometryClass) do ( local uvw = UVWMap() uvw.name = "Reawote Physical UVW " + (widthCM as string) + "x" + (heightCM as string) + "cm" uvw.maptype = 4 -- Box: suitable for walls, floors and solid objects uvw.width = mapW uvw.length = mapH uvw.height = mapW uvw.realWorldMapSize = false addModifier obj uvw applied += 1 ) applied ) ------------------------------------------------------------------------------- -- UI ------------------------------------------------------------------------------- try (destroyDialog ReawoteBridge_Rollout) catch () -- Layout notes: the rollout was originally 340px wide with an edittext -- (fieldWidth:230) sitting directly next to a button (width:70) on the same -- "across:2" row -- 230+70=300px of controls in a ~320px-usable-width -- rollout left almost no margin, so at this user's Windows text scaling the -- field and button visually crowded/overlapped. Widened the rollout and -- gave every same-row control pair real breathing room instead. rollout ReawoteBridge_Rollout "Reawote <-> FStorm Bridge" width:440 ( group "1. Find on Reawote" ( -- NOTE: edittext's positional string argument is the CAPTION, not the -- field's content -- the actual initial value must go through text:. -- (This bug meant every watch/extract folder field silently started -- empty, no matter what looked like a default in the source.) -- Stacked on its own line rather than "across:2" next to the button -- -- MAXScript's across-row column split didn't respect fieldWidth here -- and the field visually ran on top of the Search button ("Searh" -- with the button's "c" hidden underneath it). One control per row -- can't overlap another. edittext rwt_search text:"" fieldWidth:404 button rwt_searchBtn "Search" width:120 ) label rwt_hint1 "Download picks up your Reawote login/credits in your own browser." height:16 group "2. Build FStorm material" ( label rwt_wfLabel "Watch folder (where downloads land):" height:16 edittext rwt_watchFolder text:"C:\\Users\\ROG\\Downloads\\" fieldWidth:300 across:2 button rwt_watchBrowse "..." width:80 label rwt_erLabel "Extract zips into:" height:16 edittext rwt_extractRoot text:"D:\\Reawote\\tiles\\" fieldWidth:300 across:2 button rwt_extractBrowse "..." width:80 button rwt_buildBtn "Build material from newest download" width:404 height:32 ) group "3. Physical UVW from Reawote product page" ( edittext rwt_assetURL text:"" fieldWidth:404 button rwt_fetchSize "Fetch physical size from page" width:404 height:24 edittext rwt_widthCM "Width cm" text:"" fieldWidth:190 across:2 edittext rwt_heightCM "Height cm" text:"" fieldWidth:190 label rwt_sizeNote "Paste a public Reawote product URL, or enter the repeat dimensions in cm." height:18 ) group "4. Apply" ( button rwt_applyBtn "Apply last built material to selection" width:404 height:26 button rwt_applyUVW "Apply physical UVW to selection" width:404 height:26 label rwt_status "" height:16 ) on rwt_searchBtn pressed do ( local q = rwt_search.text local url = "https://reawote.com/textures?search=" + (dotNetClass "System.Uri").EscapeDataString(q) ShellLaunch url "" ) on rwt_watchBrowse pressed do ( local p = getSavePath caption:"Choose watch folder" initialDir:rwt_watchFolder.text if p != undefined then rwt_watchFolder.text = p + "\\" ) on rwt_extractBrowse pressed do ( local p = getSavePath caption:"Choose extraction folder" initialDir:rwt_extractRoot.text if p != undefined then rwt_extractRoot.text = p + "\\" ) on rwt_buildBtn pressed do ( -- guard against the field somehow being/going blank (seen after a -- rollout re-create) rather than silently searching the wrong folder if rwt_watchFolder.text == "" then rwt_watchFolder.text = "C:\\Users\\ROG\\Downloads\\" if rwt_extractRoot.text == "" then rwt_extractRoot.text = "D:\\Reawote\\tiles\\" rwt_status.text = "Working..." local mtl = ReawoteBridge_BuildFromNewestDownload rwt_watchFolder.text rwt_extractRoot.text rwt_status.text = (if mtl != undefined then ("Built: " + mtl.name) else "Failed -- see message") ) on rwt_fetchSize pressed do ( local pageURL = rwt_assetURL.text if pageURL == undefined or pageURL == "" then pageURL = ReawoteBridge_LastAssetURL if pageURL == undefined or pageURL == "" then pageURL = rwLastReawoteURL() if pageURL == undefined or pageURL == "" then pageURL = rwClipboardURL() if pageURL != undefined and pageURL != "" then rwt_assetURL.text = pageURL if pageURL == undefined or pageURL == "" then ( rwt_sizeNote.text = "Paste the exact public Reawote product page URL." rwt_status.text = "No product page URL" ) else ( rwt_assetURL.text = pageURL rwt_status.text = "Reading public Reawote page..." local size = rwFetchPhysicalSize pageURL if size == undefined then ( rwt_sizeNote.text = "Size not found on page. Enter Width and Height/repeat in cm manually." rwt_status.text = "No physical size found" ) else ( rwt_widthCM.text = formattedPrint size[1] format:".3f" rwt_heightCM.text = formattedPrint size[2] format:".3f" rwt_sizeNote.text = "Page size: " + rwt_widthCM.text + " x " + rwt_heightCM.text + " cm" rwt_status.text = "Physical size read from Reawote page" ) ) ) on rwt_applyUVW pressed do ( local w = undefined local h = undefined try (w = rwt_widthCM.text as float; h = rwt_heightCM.text as float) catch() if w == undefined or h == undefined or w <= 0 or h <= 0 then ( messageBox "Fetch a physical size from a Reawote product page, or enter positive Width and Height values in cm." title:"ReawoteBridge" ) else if selection.count == 0 then ( messageBox "Select at least one geometry object first." title:"ReawoteBridge" ) else ( local count = rwApplyPhysicalUVW w h rwt_status.text = "Applied " + (w as string) + " x " + (h as string) + " cm UVW to " + (count as string) + " object(s)." ) ) on rwt_applyBtn pressed do ( if ReawoteBridge_LastMaterial == undefined then ( messageBox "No material built yet this session." title:"ReawoteBridge" ) else if selection.count == 0 then ( messageBox "Select at least one object first." title:"ReawoteBridge" ) else ( for o in selection do o.material = ReawoteBridge_LastMaterial rwt_status.text = "Applied to " + (selection.count as string) + " object(s)." ) ) ) fn OpenReawoteFStormBridge = ( createDialog ReawoteBridge_Rollout ) macroScript OpenReawoteFStormBridge category:"ReawoteBridge" toolTip:"Reawote <-> FStorm Bridge" buttonText:"Reawote Bridge" ( on execute do OpenReawoteFStormBridge() ) OpenReawoteFStormBridge()