Hi @iskren_rusimov, thank you so much for your prompt reply!
Your reference for the ultrasonic example was extremely helpful! I took a deeper look at it and made it work.
Just to share with the community in case anyone is also interested:
First I have this surface_toggler.lua file that handles the surface swapping logic:
-- Minimal Surface Toggler Extension
-- Toggles terrain groundmodels for paved and gravel paints
local M = {}
M.dependencies = {"ui_imgui"}
local ui = ui_imgui
-- State
local terrainBlock = nil
local hasTerrain = false
local showUI = false
-- unified scope: apply to all terrain paints
-- Detection helpers
local pavedExact = {
GROUNDMODEL_ASPHALT1 = true,
ICE = true,
ASPHALT = true,
ASPHALT_OLD = true,
ASPHALT_WET2 = true,
ASPHALT_WET3 = true,
ASPHALT_WET_MKDW = true,
CONCRETE2 = true,
CONCRETE = true,
ASPHALT_WET = true,
GROUNDMODEL_ASPHALT_OLD = true,
ASPHALT_ICY = true,
ASPHALT_ICY_MKDW = true,
SLIPPERY = true
}
local function isPaved(gmName)
if not gmName then return false end
local name = tostring(gmName):upper()
if pavedExact[name] then return true end
if pavedSubstringMatch[0] then
if name:find("ASPHALT", 1, true) or name:find("CONCRETE", 1, true) then return true end
end
return false
end
local function isGravel(gmName)
if not gmName then return false end
local name = tostring(gmName):upper()
if name == "GRAVEL" or name == "GRAVEL_WET" then return true end
if gravelSubstringMatch[0] then
if name:find("GRAVEL", 1, true) then return true end
end
return false
end
-- Core apply routine
local function apply(target)
if not hasTerrain then
guihooks.trigger('toastrMsg', {type = "error", title = "Surface Toggler", msg = "No TerrainBlock in this level.", config = {timeOut = 5000}})
return
end
local changed = 0
for index = 0, (terrainBlock:getMaterialCount() - 1) do
local mtl = terrainBlock:getMaterial(index)
local gmName = mtl.groundmodelName
if gmName ~= target then
print(tostring(gmName) .. " -> " .. tostring(target))
mtl.groundmodelName = target
changed = changed + 1
end
end
be:reloadCollision()
guihooks.trigger('toastrMsg', {type = "success", title = "Surface Toggler", msg = ("Applied %s to %d materials"):format(target, changed), config = {timeOut = 4000}})
end
-- UI
local function renderGui()
ui.Begin("Surface Toggler", nil, ui.WindowFlags_AlwaysAutoResize)
if not hasTerrain then
ui.TextColored(ui.ImVec4(1,0.3,0.2,1), "No TerrainBlock found in this level.")
ui.End()
return
end
ui.TextColored(ui.ImVec4(1,0.5,0,1), "Apply to ALL terrain paints")
if ui.Button("Dry (ASPHALT)") then apply("ASPHALT") end
ui.SameLine()
if ui.Button("Wet (ASPHALT_WET)") then apply("ASPHALT_WET") end
ui.SameLine()
if ui.Button("Slippery") then apply("SLIPPERY") end
if ui.Button("Ice") then apply("ICE") end
ui.SameLine()
if ui.Button("Snow") then apply("SNOW") end
ui.Separator()
ui.TextColored(ui.ImVec4(1,0.5,0,1), "Gravel variants (also applied to ALL paints)")
if ui.Button("Dry (GRAVEL)") then apply("GRAVEL") end
ui.SameLine()
if ui.Button("Wet (GRAVEL_WET)") then apply("GRAVEL_WET") end
ui.End()
end
-- Lifecycle
local function onExtensionLoaded()
local terrainObjs = scenetree.findSubClassObjects('TerrainBlock')
if terrainObjs and terrainObjs[1] then
terrainBlock = scenetree.findObject(terrainObjs[1])
hasTerrain = terrainBlock ~= nil
else
hasTerrain = false
end
end
local function onExtensionUnloaded()
end
local function onUpdate(dt)
if showUI then renderGui() end
end
-- Public API function for swapping surface (called by techCore.lua)
local function swap(surfaceType)
if not hasTerrain then
return false, 'No TerrainBlock in this level.'
end
local typeMap = {
ice = "ICE",
asphalt = "ASPHALT",
asphalt_wet = "ASPHALT_WET",
slippery = "SLIPPERY",
snow = "SNOW",
gravel = "GRAVEL",
gravel_wet = "GRAVEL_WET"
}
local target = typeMap[surfaceType]
if not target then
return false, 'Unknown surface type: ' .. tostring(surfaceType)
end
apply(target)
return true, nil
end
-- Public API
M.onExtensionLoaded = onExtensionLoaded
M.onExtensionUnloaded = onExtensionUnloaded
M.onUpdate = onUpdate
M.swap = swap
M.toggleGui = function() showUI = not showUI end
return M
Then we need to add this to the BeamNG.tech.v0.37.6.0/lua/ge/extensions/tech/techCore.lua. Note that we need to add this to the GE techCore instead of the vehicle techCore.
M.handleSwapSurfaceFriction = function(request)
local surfaceType = request['surfaceType']
if not extensions.util_surfaceToggler then
extensions.load('util/surfaceToggler')
end
local success, errorMsg = extensions.util_surfaceToggler.swap(surfaceType)
if success then
request:sendACK('SurfaceFrictionSwapped')
else
request:sendBNGError(errorMsg or 'Failed to swap surface friction')
end
end
which routes the python messages to the extension.
And finally, in the python script:
We need to first load it:
# Pre-load the surface toggler extension and give it time to initialize
beamng_instance.control.queue_lua_command("extensions.load('util/surfaceToggler')")
sleep(0.5) # Give extension time to load before we try to use it
print("Pre-loaded surface toggler GE extension")
Then define a function that passes the type of surface we want to swap:
def swapSurfaceFriction(beamng_instance, surface_type):
data = dict(type='SwapSurfaceFriction', surfaceType=surface_type)
beamng_instance.connection.send(data).ack('SurfaceFrictionSwapped')
print(f"Surface friction swapped to: {surface_type}")
Then you can just call swapSurfaceFriction(beamng_instance, 'gravel_wet') to change the surface friction.
You can also add additional friction types if you want, it is a simple edit.
Hope this helps!
And thank you @iskren_rusimov for the great support! You have been extremely helpful. Really appreciate it!