-- @description Creates a new node graph where the -- @author me :) -- @shortcut alt + t -- @version 0.1 -- @script-id texture-scatter --------------------------------------------------------------------------------------------------- -- Configuration local labelWidth = 120 local labelHeight = 20 local editWidth = 100 local editHeight = 20 local buttonWidth = 160 local buttonHeight = 30 local guiDebug = false local settings = octane.gui.createPropertyTable() settings.isLoading = true local imgFileExtensions = { ".jpg",".bmp",".ico",".jpg",".jpeg",".jng",".lbm",".mng",".pbm",".pbm",".pcd",".pcx", ".pgm",".png",".ppm",".ras",".tga",".targa",".tif",".tiff",".wbmp",".psd",".cut",".xbm", ".xpm",".dds",".gif",".hdr",".sgi",".exr",".j2k",".jp2",".pfm",".pic",".pict",".raw" } --------------------------------------------------------------------------------------------------- -- GUI local aspectBox local handleAspectRatioFlag = false local function handleSetAspectRatio(k, v) -- skip any callbacks during initialisation if settings.isLoading then return end if handleAspectRatioFlag then return end handleAspectRatioFlag = true -- figure out which pair of keys we are handling: local prefix = "randScale" local enabled = settings[prefix.."KeepRatio"] aspectBox.enable = enabled if enabled then -- change local ratio = 1 / settings[prefix.."Ratio"] settings[prefix.."VMin"] = ratio * settings[prefix.."UMin"] settings[prefix.."VMax"] = ratio * settings[prefix.."UMax"] settings[prefix.."VStep"] = ratio * settings[prefix.."UStep"] end handleAspectRatioFlag = false end local function handleAspectRatio(k, v) -- skip any callbacks during initialisation if settings.isLoading then return end if handleAspectRatioFlag then return end handleAspectRatioFlag = true -- figure out which pair of keys we are handling: local prefix = "randScale" local pattern = prefix.."([UV])(%w+)" local channelA, suffix = k:match(pattern) local channelB = (channelA == "U") and "V" or "U" local ratio = settings[prefix.."Ratio"] if channelA == "V" then ratio = 1 / ratio end if settings[prefix.."KeepRatio"] then settings[prefix..channelB..suffix] = v end handleAspectRatioFlag = false end -- opens a message box with an error message local function showError(text) octane.gui.showDialog{ type = octane.gui.dialogType.BUTTON_DIALOG, title = "Error", text = text } end local function createFilePathText(list) local txt = list[1] or "" if #list > 1 then txt = ("%s (and %d more)"):format(txt, #list - 1) end return txt end -- workaround for our files text box: local filePathEditor = nil -- creates a group with a directory edit field and a button to select a directory local function createFilePath(boundName) filePathEditor = octane.gui.create { type = octane.gui.componentType.TEXT_EDITOR, text = "", width = 400, height = editHeight, enable = false, } local btnSelectDir = octane.gui.create { type = octane.gui.componentType.BUTTON, text = "...", width = 30, height = editHeight, callback = function(component, event) if event == octane.gui.eventType.BUTTON_CLICK then -- get and check current directory local dir = settings[boundName][1] if dir and octane.file.isAbsolute(dir) then dir = octane.file.getParentDirectory(dir) else dir = nil end -- open file chooser local ret = octane.gui.showDialog { type = octane.gui.dialogType.FILE_DIALOG, wildcards = '*'..table.concat(imgFileExtensions, ";*"), browseDirectory = false, multiFile = true, title = "Choose image files", path = dir, save = false, } -- store directory if one got chosen if ret.multiResult and #ret.multiResult > 0 then settings[boundName] = ret.multiResult end filePathEditor.text = createFilePathText(ret.multiResult) end end } return octane.gui.create { type = octane.gui.componentType.GROUP, border = false, debug = guiDebug, rows = 1, cols = 2, children = { filePathEditor, btnSelectDir }, } end -- creates a text label and returns it local function createLabel(text, labelWidth) return octane.gui.create { type = octane.gui.componentType.LABEL, text = text, width = labelWidth, height = labelHeight, } end -- creates a numeric box and returns it local function createNumBox(value, minValue, maxValue, step, boundName, bindCallback) local displaySlider = (minValue > -500) local numBox = octane.gui.create { type = octane.gui.componentType.NUMERIC_BOX, value = value, minValue = minValue, maxValue = maxValue, step = step, displaySlider = displaySlider, width = editWidth, height = editHeight, } numBox:bind("value", settings, boundName, bindCallback) return numBox end -- creates a combo box and returns it local function createComboBox(choices, selectedIx, boundName) local comboBox = octane.gui.create { type = octane.gui.componentType.COMBO_BOX, items = choices, selectedIx = selectedIx, width = editWidth, height = editHeight, } comboBox:bind("selectedIx", settings, boundName) return comboBox end -- creates a group with a sub-label and 3 min/max/step fields local function createMinMaxStep(subLabel, minValue, maxValue, step, boundName, bindCallback) local lblSub = createLabel(subLabel, 20) local lblMin = createLabel("min", 34) local nboxMin = createNumBox(minValue, minValue, maxValue, step, boundName.."Min", bindCallback) local lblMax = createLabel("max", 36) local nboxMax = createNumBox(maxValue, minValue, maxValue, step, boundName.."Max", bindCallback) local lblStep = createLabel("step", 38) local nboxStep = createNumBox(0, 0, maxValue, step, boundName.."Step", bindCallback) nboxMin.callback = function(component, event) if event == octane.gui.eventType.VALUE_CHANGE and nboxMin.value > nboxMax.value then nboxMin.value = nboxMax.value end end nboxMax.callback = function(component, event) if event == octane.gui.eventType.VALUE_CHANGE and nboxMax.value < nboxMin.value then nboxMax.value = nboxMin.value end end return octane.gui.create { type = octane.gui.componentType.GROUP, text = "", border = false, centre = false, debug = guiDebug, rows = 1, cols = 7, children = { lblSub, lblMin, nboxMin, lblMax, nboxMax, lblStep, nboxStep }, } end -- groups components -- direction may be "V" or "H" local function createGroup(direction, components) local count = #components return octane.gui.create { type = octane.gui.componentType.GROUP, text = "", border = false, centre = false, debug = guiDebug, rows = (direction == "V" and count or 1), cols = (direction == "V" and 1 or count), children = components, } end local function createCheckBox(width, label, boundName, bindCallback) local chk = octane.gui.create { type = octane.gui.componentType.CHECK_BOX, text = label, checked = false, width = width, height = editHeight, } chk:bind("checked", settings, boundName, bindCallback) return chk end -- texture path selection local lblTexPath = createLabel("Image files:", labelWidth) local grpTexPath = createFilePath("textures") -- greyscale texture checkbox local lblMonoTex = createLabel("greyscale images:", labelWidth) local cboxMonoTex = createCheckBox(20, "", "greyscale") -- variation count local lblTexCount = createLabel("number variations:", labelWidth) local nboxTexCount = createNumBox(2, 2, 256, 1, "variationCount") -- inspect octane.gradientInterpType enumeration: local interpolationNames local interpolationValues if octane.gradientInterpType then interpolationNames = {"Constant", "Linear", "Cubic"} interpolationValues = {octane.gradientInterpType.CONSTANT, octane.gradientInterpType.LINEAR, octane.gradientInterpType.CUBIC} else -- version 2.xx, just keep the default (Constant is the only interesting one anyway) interpolationNames = {"linear"} end -- gradient interpolation type lblInterpType = createLabel("Gradient blend type:", labelWidth) boxInterpType = createComboBox(interpolationNames, 1, "interpType") -- random power local lblRandPower = createLabel("random power:", labelWidth) local grpRandPower = createMinMaxStep("", 0, 1, 0.001, "randPower") -- random gamma local lblRandGamma = createLabel("random gamma:", labelWidth) local grpRandGamma = createMinMaxStep("", 0.001, 8, 0.001, "randGamma") -- random rotation local lblRandRot = createLabel("random rotation:", labelWidth) local grpRandRot = createMinMaxStep("", 0, 360, 0.001, "randRot") -- aspect ratio group aspectBox = createNumBox(1, .01, 100, 0.001, "randScaleRatio", handleSetAspectRatio) aspectBox.logarithmic = true local aspectGrp = createGroup("H", { createCheckBox(160, "Lock aspect ratio:", "randScaleKeepRatio", handleSetAspectRatio), aspectBox }) -- scale local lblRandScale = createLabel("random scale:", labelWidth) local grpRandScale = createGroup("V", { createMinMaxStep("U", -1000, 1000, 0.001, "randScaleU", handleAspectRatio), createMinMaxStep("V", -1000, 1000, 0.001, "randScaleV", handleAspectRatio), aspectGrp}) -- translation local lblRandTrans = createLabel("random translation:", labelWidth) local grpRandTrans = createGroup("V", { createMinMaxStep("U", -1000, 1000, 0.001, "randTransU"), createMinMaxStep("V", -1000, 1000, 0.001, "randTransV") }) -- mirroring local lblRandMirror = createLabel("random mirror:", labelWidth) local grpRandMirror = createGroup("V", { createCheckBox(160, "Mirror U", "randMirrorU"), createCheckBox(160, "Mirror V", "randMirrorV") }) -- settings layout group local grpSettings = octane.gui.create { type = octane.gui.componentType.GROUP, text = "", border = false, inset = { 4 }, padding = { 2, 4 }, centre = false, debug = guiDebug, rows = 10, cols = 2, children = { lblTexPath, grpTexPath, lblMonoTex, cboxMonoTex, lblTexCount, nboxTexCount, lblInterpType, boxInterpType, lblRandPower, grpRandPower, lblRandGamma, grpRandGamma, lblRandRot, grpRandRot, lblRandScale, grpRandScale, lblRandTrans, grpRandTrans, lblRandMirror, grpRandMirror, }, } -- button layout group local btnCreate = octane.gui.create { type = octane.gui.componentType.BUTTON, text = "Create texture group", width = buttonWidth, height = buttonHeight, } local btnUpdate = octane.gui.create { type = octane.gui.componentType.BUTTON, text = "Update selected group", width = buttonWidth, height = buttonHeight, } local grpButtons = octane.gui.create { type = octane.gui.componentType.GROUP, text = "", border = false, padding = { 0 }, centre = true, -- width = grpSettings.width, debug = guiDebug, rows = 1, cols = 2, children = { btnCreate, btnUpdate }, } grpButtons.padding = { (grpSettings.width - grpButtons.width) / 4, 10 } -- window that holds all components local grpMain = createGroup("V", {grpSettings, grpButtons}) local winSettings = octane.gui.create { type = octane.gui.componentType.WINDOW, text = "Texture Scatter", children = { grpMain }, width = grpMain.width, height = grpMain.height, } --------------------------------------------------------------------------------------------------- -- Loading/saving settings -- the defaults we use in case of no stored settings settings.textures = {} settings.variationCount = 10 settings.greyscale = false settings.interpType = 1 settings.randPowerMin = 0.5 settings.randPowerMax = 1 settings.randPowerStep = 0 settings.randGammaMin = 2.2 settings.randGammaMax = 2.2 settings.randGammaStep = 0 settings.randRotMin = 0 settings.randRotMax = 360 settings.randRotStep = 90 settings.randScaleUMin = 1 settings.randScaleUMax = 1 settings.randScaleUStep = 0 settings.randScaleVMin = 1 settings.randScaleVMax = 1 settings.randScaleVStep = 0 settings.randTransUMin = 0 settings.randTransUMax = 0 settings.randTransUStep = 0 settings.randTransVMin = 0 settings.randTransVMax = 0 settings.randTransVStep = 0 local function loadSettings() -- which table to use: local savedTable = nil if octane.storage.project and next(octane.storage.project) then -- try if settings were stored in the current project file savedTable = octane.storage.project elseif next(octane.storage.app) then -- try global settings savedTable = octane.storage.app else -- no saved settings return end -- move to our GUI bindings table: for k, v in pairs(savedTable) do if v then settings[k] = v end end end local function saveSettings() -- we write to both the app and the project settings: octane.storage.project = settings["__octane.shadow"] octane.storage.app = settings["__octane.shadow"] end -- These two functions load settings from a file. I left them in, just in case someone -- wants to keep these settings stored in a file with his scene. -- loads settings from a file local function loadSettingsFromFile(fileName) -- open file file, errorMsg = io.open(fileName, "r") if not file then showError("Could not open file '"..fileName.."'. Error:\n"..errorMsg) return end -- read all entries for line in file:lines() do local splitPos = line:find(" ") if splitPos then local key = line:sub(1, splitPos-1) local value = line:sub(splitPos+1) local valueType = type(settings[key]) if valueType == "number" then settings[key] = tonumber(value) elseif valueType == "boolean" then if value == "true" then settings[key] = true elseif value == "false" then settings[key] = false end elseif valueType == "string" then settings[key] = value end end end -- close file file:close() end -- saves settings into a file local function saveSettingsToFile(fileName) -- open file file, errorMsg = io.open(fileName, "w") if not file then showError("Could not open file '"..fileName.."'. Error:\n"..errorMsg) return end -- store all entries from the shadow table of the property table for key,value in pairs(settings["__octane.shadow"]) do file:write(key.." "..tostring(value).."\n") end -- close file file:close() end -- load loadSettings() -- unfortunately we can't bind our file list... filePathEditor.text = createFilePathText(settings.textures) -- finish initializing settings.isLoading = nil -- make sure we have a consistent state by calling some handlers now: handleSetAspectRatio("randScaleKeepRatio", settings.randScaleKeepRatio ) -- see if we currently have one of our graphs selected local selectedGraph = nil local selection = octane.project.getSelection() if #selection == 1 and selection[1].graphType == octane.GT_STANDARD then -- check if this graph has a texture output: if selection[1]:findFirstNode(octane.NT_OUT_TEXTURE) then selectedGraph = selection[1] end end --------------------------------------------------------------------------------------------------- -- Texture scattering -- helper to calculate a random value for a specific min/max/step setting local function calcRandom(propName) -- fetch settings for property local minV = settings[propName.."Min"] local delta = settings[propName.."Max"] - minV local step = settings[propName.."Step"] -- return quantized value if required if step > 0 then return minV + math.floor(math.random() * (delta+step) / step) * step -- otherwise just something in [min..max) else return minV + math.random()*delta end end local function randomFlip(propName) if (settings[propName]) then return math.random(0, 1) * 2 - 1 end return 1 end -- the actual function doing the work -- if graph is nil, a new graph is created, otherwise the existing one is updated local function createScatterTextures(graph) -- save current settings saveSettings() -- check textures if not next(settings.textures) then showError("Please select image files first.") return false end -- get destination node graph (either root or the parent graph of first selected item) local destGraph = octane.project.getSceneGraph() local destPos = { 400, 200 } local selected = octane.project.getSelection()[1] if selected and selected.graphOwned then destGraph = selected.graphOwner destPos = selected.position end -- create node graph with seed input, random color texture, gradient and texture output if not graph then graph = octane.nodegraph.create { type = octane.GT_STANDARD, graphOwner = destGraph, name = "Scattered Texture", position = destPos, } end -- remove anything which is not a linker node local ourContents = graph:getOwnedItems() for _, item in ipairs(ourContents) do if not item.isLinker then item:destroy() end end -- update linkers local seedInputLinkerNode = unpack(graph:setInputLinkers { { type = octane.PT_INT, label = "Seed" }, }) local texOutputLinkerNode = unpack(graph:setOutputLinkers { { type = octane.PT_TEXTURE, label = "Texture" }, }) -- create default node in our input linker if not seedInputLinkerNode:getConnectedNodeIx(1) then octane.node.create { type = octane.NT_INT, pinOwnerNode = seedInputLinkerNode, pinOwnerId = octane.P_INPUT, } end local randomTextureNode = octane.node.create { type = octane.NT_TEX_RANDOMCOLOR, name = "Random", graphOwner = graph, } local gradientTextureNode = octane.node.create { type = octane.NT_TEX_GRADIENT, name = "Gradient", graphOwner = graph, } -- connect the nodes randomTextureNode:connectTo(octane.P_RANDOM_SEED, seedInputLinkerNode) gradientTextureNode:connectTo(octane.P_INPUT, randomTextureNode) texOutputLinkerNode:connectTo(octane.P_INPUT, gradientTextureNode) -- interpolation type local createExtraEntry = false if interpolationValues then local t = interpolationValues[settings.interpType] createExtraEntry = (settings.interpType == 1) gradientTextureNode:setPinValue(octane.P_GRADIENT_INTERP_TYPE, t, true) end -- create enough entries in the gradient texture node to fit settings.variationCount textrues local controlPointCount = settings.variationCount-2 if createExtraEntry then controlPointCount = controlPointCount + 1 end gradientTextureNode:setAttribute(octane.A_NUM_CONTROLPOINTS, controlPointCount, true) -- seed the RNG math.randomseed(os.time()) for i=1,50 do math.random() end -- create the image textures as internal nodes of the gradient node and set the gradient positions local imgTexNodeType = settings.greyscale and octane.NT_TEX_FLOATIMAGE or octane.NT_TEX_IMAGE local imgTexNodeName = octane.apiinfo.getNodeInfo(imgTexNodeType).defaultName local posNodeName = octane.apiinfo.getNodeInfo(octane.NT_FLOAT).defaultName -- Static pin count is not yet exposed through the API: local gradientStaticPinCount = gradientTextureNode.pinCount - 2 * controlPointCount for i=1,settings.variationCount do -- create the image texture node ... local imgTexNode if i == 1 or i == controlPointCount + 2 then -- ... for the min/max pins imgTexNode = octane.node.create { type = imgTexNodeType, graphOwner = graph, name = imgTexNodeName, } if i == 1 then gradientTextureNode:connectTo(octane.P_MIN, imgTexNode) end if i ~= 1 or createExtraEntry then -- If we have an extra entry, create one image texture to both -- "min" and "max" input pins -- If not, then the last node is connected to the "max" pin gradientTextureNode:connectTo(octane.P_MAX, imgTexNode) end else -- ... for the dynamic (inbetween) pins local pinIx = gradientStaticPinCount + (i-1) * 2 imgTexNode = octane.node.create { type = imgTexNodeType, pinOwnerNode = gradientTextureNode, pinOwnerIx = pinIx, name = imgTexNodeName, } -- also create a position node and set its value local posNode = octane.node.create { type = octane.NT_FLOAT, pinOwnerNode = gradientTextureNode, pinOwnerIx = pinIx - 1, name = posNodeName, } posNode:setAttribute(octane.A_VALUE, (i-1) / (controlPointCount + 1)) end -- set up image texture power and gamme imgTexNode:setPinValue(octane.P_POWER, calcRandom("randPower")) imgTexNode:setPinValue(octane.P_GAMMA, calcRandom("randGamma")) -- set up image texture UV transformation local transNode = imgTexNode:getConnectedNode(octane.P_TRANSFORM) transNode:setAttribute(octane.A_ROTATION, { 0, 0, calcRandom("randRot") }, false) local vec = transNode:getAttribute(octane.A_ROTATION) local scale = {0, calcRandom("randScaleV")} if settings.randScaleKeepRatio then scale[1] = scale[2] * settings.randScaleRatio else scale[1] = calcRandom("randScaleU") end transNode:setAttribute(octane.A_SCALE, { scale[1] * randomFlip("randMirrorU"), scale[2] * randomFlip("randMirrorV") }, false) transNode:setAttribute(octane.A_TRANSLATION, { calcRandom("randTransU"), calcRandom("randTransV") }, false) transNode:evaluate() -- set file name imgTexNode:setAttribute(octane.A_FILENAME, settings.textures[math.random(1, #settings.textures)]) end -- unfold the whole mess and return graph:unfold(true) selectedGraph = graph return true end --------------------------------------------------------------------------------------------------- -- Main -- hook up functions to buttons btnCreate.callback = function() if createScatterTextures() then btnUpdate.enable = true end end btnUpdate.callback = function() createScatterTextures(selectedGraph) octane.changemanager.update() end btnUpdate.enable = (selectedGraph ~= nil) -- open window which will block until finished winSettings:showWindow()