Compare commits
2 Commits
19e4ac0cce
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
110371d09d
|
|||
|
56b6a46811
|
|
After Width: | Height: | Size: 8.8 KiB |
@@ -4,6 +4,7 @@ require "ISUI/ISButton"
|
|||||||
require "Vehicles/TimedActions/ISPathFindAction"
|
require "Vehicles/TimedActions/ISPathFindAction"
|
||||||
require "PaintMyKI5/PaintRequirements"
|
require "PaintMyKI5/PaintRequirements"
|
||||||
require "PaintMyKI5/PaintInventory"
|
require "PaintMyKI5/PaintInventory"
|
||||||
|
require "PaintMyKI5/TextureName"
|
||||||
require "PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle"
|
require "PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle"
|
||||||
|
|
||||||
ISPaintMyKI5UI = ISCollapsableWindow:derive("ISPaintMyKI5UI")
|
ISPaintMyKI5UI = ISCollapsableWindow:derive("ISPaintMyKI5UI")
|
||||||
@@ -14,9 +15,9 @@ local FONT_MEDIUM = UIFont.Medium
|
|||||||
local SMALL_HEIGHT = getTextManager():getFontHeight(FONT_SMALL)
|
local SMALL_HEIGHT = getTextManager():getFontHeight(FONT_SMALL)
|
||||||
local MEDIUM_HEIGHT = getTextManager():getFontHeight(FONT_MEDIUM)
|
local MEDIUM_HEIGHT = getTextManager():getFontHeight(FONT_MEDIUM)
|
||||||
|
|
||||||
local function textureLabel(textureData)
|
local function textureLabel(textureData, displayName)
|
||||||
local name = string.match(textureData.texture or "", "([^/]+)$") or ""
|
return getText("IGUI_PaintMyKI5_Skin", textureData.skinIndex + 1)
|
||||||
return getText("IGUI_PaintMyKI5_Skin", textureData.skinIndex + 1) .. " - " .. name
|
.. " - " .. displayName
|
||||||
end
|
end
|
||||||
|
|
||||||
local function getPaletteColor(itemType)
|
local function getPaletteColor(itemType)
|
||||||
@@ -28,6 +29,24 @@ local function getPaletteColor(itemType)
|
|||||||
return 0.5, 0.5, 0.5
|
return 0.5, 0.5, 0.5
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function buildMissingTooltip(missingPaint, missingTools)
|
||||||
|
local lines = { getText("IGUI_PaintMyKI5_MissingRequirements") }
|
||||||
|
for _, missing in ipairs(missingTools or {}) do
|
||||||
|
table.insert(lines, getText(
|
||||||
|
"IGUI_PaintMyKI5_MissingTool",
|
||||||
|
getItemNameFromFullType(missing.item)
|
||||||
|
))
|
||||||
|
end
|
||||||
|
for _, missing in ipairs(missingPaint or {}) do
|
||||||
|
local available = math.floor(missing.available * 100 + 0.5) / 100
|
||||||
|
table.insert(lines, getText(
|
||||||
|
"IGUI_PaintMyKI5_MissingPaint",
|
||||||
|
getItemNameFromFullType(missing.item), missing.required, available
|
||||||
|
))
|
||||||
|
end
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
function ISPaintMyKI5UI.drawSkinItem(list, y, item, alt)
|
function ISPaintMyKI5UI.drawSkinItem(list, y, item, alt)
|
||||||
if item.itemindex == list.selected then
|
if item.itemindex == list.selected then
|
||||||
list:drawRect(0, y, list:getWidth(), item.height, 0.25, 0.3, 0.7, 1.0)
|
list:drawRect(0, y, list:getWidth(), item.height, 0.25, 0.3, 0.7, 1.0)
|
||||||
@@ -49,8 +68,10 @@ function ISPaintMyKI5UI:createChildren()
|
|||||||
self.skinList.drawBorder = true
|
self.skinList.drawBorder = true
|
||||||
self:addChild(self.skinList)
|
self:addChild(self.skinList)
|
||||||
|
|
||||||
for _, textureData in ipairs(self.vehicleData.textures or {}) do
|
local textures = self.vehicleData.textures or {}
|
||||||
self.skinList:addItem(textureLabel(textureData), textureData)
|
local textureNames = PaintMyKI5.TextureName.buildLabels(textures)
|
||||||
|
for index, textureData in ipairs(textures) do
|
||||||
|
self.skinList:addItem(textureLabel(textureData, textureNames[index]), textureData)
|
||||||
end
|
end
|
||||||
local currentIndex = self.vehicle:getSkinIndex()
|
local currentIndex = self.vehicle:getSkinIndex()
|
||||||
for index, row in ipairs(self.skinList.items) do
|
for index, row in ipairs(self.skinList.items) do
|
||||||
@@ -84,6 +105,8 @@ function ISPaintMyKI5UI:prerender()
|
|||||||
ISCollapsableWindow.prerender(self)
|
ISCollapsableWindow.prerender(self)
|
||||||
local textureData = self:getSelectedTexture()
|
local textureData = self:getSelectedTexture()
|
||||||
local enabled = false
|
local enabled = false
|
||||||
|
local missingPaint = {}
|
||||||
|
local missingTools = {}
|
||||||
if textureData then
|
if textureData then
|
||||||
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
|
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
|
||||||
self.vehicle, self.character
|
self.vehicle, self.character
|
||||||
@@ -91,13 +114,26 @@ function ISPaintMyKI5UI:prerender()
|
|||||||
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
||||||
self.vehicle, textureData.skinIndex
|
self.vehicle, textureData.skinIndex
|
||||||
)
|
)
|
||||||
enabled = requirements ~= nil
|
local ready = false
|
||||||
and PaintMyKI5.PaintRequirements.isValidTarget(
|
if requirements then
|
||||||
self.character, self.vehicle, textureData.skinIndex, area
|
ready, missingPaint, missingTools =
|
||||||
|
PaintMyKI5.PaintInventory.checkPaintingRequirements(
|
||||||
|
self.character, requirements
|
||||||
|
)
|
||||||
|
end
|
||||||
|
enabled = ready and area ~= nil
|
||||||
|
and PaintMyKI5.PaintRequirements.isValidSelection(
|
||||||
|
self.character, self.vehicle, textureData.skinIndex
|
||||||
)
|
)
|
||||||
and PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
|
|
||||||
end
|
end
|
||||||
self.paintButton:setEnable(enabled)
|
self.paintButton:setEnable(enabled)
|
||||||
|
if enabled then
|
||||||
|
self.paintButton:setTooltip(nil)
|
||||||
|
elseif #missingPaint > 0 or #missingTools > 0 then
|
||||||
|
self.paintButton:setTooltip(buildMissingTooltip(missingPaint, missingTools))
|
||||||
|
else
|
||||||
|
self.paintButton:setTooltip(getText("IGUI_PaintMyKI5_Unavailable"))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function ISPaintMyKI5UI:render()
|
function ISPaintMyKI5UI:render()
|
||||||
@@ -162,12 +198,14 @@ function ISPaintMyKI5UI:onPaint()
|
|||||||
self.vehicle, textureData.skinIndex
|
self.vehicle, textureData.skinIndex
|
||||||
)
|
)
|
||||||
if not requirements then return end
|
if not requirements then return end
|
||||||
local enough = PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
|
local enough = PaintMyKI5.PaintInventory.checkPaintingRequirements(
|
||||||
|
self.character, requirements
|
||||||
|
)
|
||||||
if not enough then return end
|
if not enough then return end
|
||||||
local area = PaintMyKI5.PaintRequirements.findInteractionArea(self.vehicle, self.character)
|
local area = PaintMyKI5.PaintRequirements.findInteractionArea(self.vehicle, self.character)
|
||||||
if not area then return end
|
if not area then return end
|
||||||
if not PaintMyKI5.PaintRequirements.isValidTarget(
|
if not PaintMyKI5.PaintRequirements.isValidSelection(
|
||||||
self.character, self.vehicle, textureData.skinIndex, area
|
self.character, self.vehicle, textureData.skinIndex
|
||||||
) then return end
|
) then return end
|
||||||
|
|
||||||
ISTimedActionQueue.add(ISPathFindAction:pathToVehicleArea(self.character, self.vehicle, area))
|
ISTimedActionQueue.add(ISPathFindAction:pathToVehicleArea(self.character, self.vehicle, area))
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function PaintVehicleContextMenu.onFillWorldObjectContextMenu(player, context, w
|
|||||||
PaintVehicleContextMenu.open,
|
PaintVehicleContextMenu.open,
|
||||||
vehicle
|
vehicle
|
||||||
)
|
)
|
||||||
option.iconTexture = getTexture("media/textures/PaintBrush.png")
|
option.iconTexture = getTexture("media/textures/PaintMyKI5_ContextMenu.png")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
PaintMyKI5 = PaintMyKI5 or {}
|
||||||
|
PaintMyKI5.SandpaperDistribution = PaintMyKI5.SandpaperDistribution or {}
|
||||||
|
|
||||||
|
local SandpaperDistribution = PaintMyKI5.SandpaperDistribution
|
||||||
|
local SANDPAPER_ITEM = "PaintMyKI5.Sandpaper"
|
||||||
|
local SANDPAPER_WEIGHT = 0.1
|
||||||
|
local FULL_PAINT_CANS = {
|
||||||
|
PaintBlack = true,
|
||||||
|
PaintBlue = true,
|
||||||
|
PaintBrown = true,
|
||||||
|
PaintCyan = true,
|
||||||
|
PaintGreen = true,
|
||||||
|
PaintGrey = true,
|
||||||
|
PaintLightBlue = true,
|
||||||
|
PaintLightBrown = true,
|
||||||
|
PaintOrange = true,
|
||||||
|
PaintPink = true,
|
||||||
|
PaintPurple = true,
|
||||||
|
PaintRed = true,
|
||||||
|
PaintTurquoise = true,
|
||||||
|
PaintWhite = true,
|
||||||
|
PaintYellow = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function isFullPaintCan(itemType)
|
||||||
|
if type(itemType) ~= "string" then return false end
|
||||||
|
if FULL_PAINT_CANS[itemType] then return true end
|
||||||
|
local moduleName, shortType = string.match(itemType, "^([^.]+)%.([^.]+)$")
|
||||||
|
return moduleName == "Base" and FULL_PAINT_CANS[shortType] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function inspectItems(items)
|
||||||
|
local hasPaint = false
|
||||||
|
local hasSandpaper = false
|
||||||
|
if type(items) ~= "table" then return hasPaint, hasSandpaper end
|
||||||
|
|
||||||
|
for index = 1, #items, 2 do
|
||||||
|
local itemType = items[index]
|
||||||
|
if itemType == SANDPAPER_ITEM then
|
||||||
|
hasSandpaper = true
|
||||||
|
elseif isFullPaintCan(itemType) then
|
||||||
|
hasPaint = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return hasPaint, hasSandpaper
|
||||||
|
end
|
||||||
|
|
||||||
|
local function injectIntoItems(items)
|
||||||
|
local hasPaint, hasSandpaper = inspectItems(items)
|
||||||
|
if not hasPaint or hasSandpaper then return 0 end
|
||||||
|
table.insert(items, SANDPAPER_ITEM)
|
||||||
|
table.insert(items, SANDPAPER_WEIGHT)
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function SandpaperDistribution.inject(distributions, clutterTables)
|
||||||
|
local list = distributions and distributions.list
|
||||||
|
local added = 0
|
||||||
|
if type(list) == "table" then
|
||||||
|
for _, distribution in pairs(list) do
|
||||||
|
if type(distribution) == "table" then
|
||||||
|
added = added + injectIntoItems(distribution.items)
|
||||||
|
if type(distribution.junk) == "table" then
|
||||||
|
added = added + injectIntoItems(distribution.junk.items)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if type(clutterTables) == "table" then
|
||||||
|
added = added + injectIntoItems(clutterTables.ClosetItems)
|
||||||
|
end
|
||||||
|
return added
|
||||||
|
end
|
||||||
|
|
||||||
|
SandpaperDistribution.inject(ProceduralDistributions, ClutterTables)
|
||||||
@@ -4,6 +4,16 @@ PaintMyKI5.PaintInventory = PaintMyKI5.PaintInventory or {}
|
|||||||
local PaintInventory = PaintMyKI5.PaintInventory
|
local PaintInventory = PaintMyKI5.PaintInventory
|
||||||
local EPSILON = 0.0001
|
local EPSILON = 0.0001
|
||||||
local EMPTY_BUCKET = "Base.PaintbucketEmpty"
|
local EMPTY_BUCKET = "Base.PaintbucketEmpty"
|
||||||
|
local TOOL_REQUIREMENTS = {
|
||||||
|
{
|
||||||
|
item = "PaintMyKI5.Sandpaper",
|
||||||
|
alternatives = { "PaintMyKI5.Sandpaper" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
item = "Base.Paintbrush",
|
||||||
|
alternatives = { "Base.Paintbrush", "Base.PaintbrushCrafted" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
local function getItems(character, itemType)
|
local function getItems(character, itemType)
|
||||||
if not character or not character:getInventory() then return nil end
|
if not character or not character:getInventory() then return nil end
|
||||||
@@ -41,6 +51,27 @@ function PaintInventory.checkRequirements(character, requirements)
|
|||||||
return #missing == 0, missing
|
return #missing == 0, missing
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function hasAnyItem(character, itemTypes)
|
||||||
|
for _, itemType in ipairs(itemTypes) do
|
||||||
|
local items = getItems(character, itemType)
|
||||||
|
if items and items:size() > 0 then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintInventory.checkPaintingRequirements(character, requirements)
|
||||||
|
local hasPaint, missingPaint = PaintInventory.checkRequirements(
|
||||||
|
character, requirements
|
||||||
|
)
|
||||||
|
local missingTools = {}
|
||||||
|
for _, tool in ipairs(TOOL_REQUIREMENTS) do
|
||||||
|
if not hasAnyItem(character, tool.alternatives) then
|
||||||
|
table.insert(missingTools, { item = tool.item })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return hasPaint and #missingTools == 0, missingPaint, missingTools
|
||||||
|
end
|
||||||
|
|
||||||
local function buildPlan(character, requirements)
|
local function buildPlan(character, requirements)
|
||||||
local enough = PaintInventory.checkRequirements(character, requirements)
|
local enough = PaintInventory.checkRequirements(character, requirements)
|
||||||
if not enough then return nil end
|
if not enough then return nil end
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function PaintRequirements.findInteractionArea(vehicle, character)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
|
function PaintRequirements.isValidSelection(character, vehicle, skinIndex)
|
||||||
if not character or not vehicle or vehicle:isRemovedFromWorld() then return false end
|
if not character or not vehicle or vehicle:isRemovedFromWorld() then return false end
|
||||||
if character:getVehicle() then return false end
|
if character:getVehicle() then return false end
|
||||||
if math.abs(vehicle:getCurrentSpeedKmHour()) > 0.8 then return false end
|
if math.abs(vehicle:getCurrentSpeedKmHour()) > 0.8 then return false end
|
||||||
@@ -87,6 +87,13 @@ function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
|
|||||||
if skinIndex < 0 or skinIndex >= vehicle:getSkinCount() then return false end
|
if skinIndex < 0 or skinIndex >= vehicle:getSkinCount() then return false end
|
||||||
if vehicle:getSkinIndex() == skinIndex then return false end
|
if vehicle:getSkinIndex() == skinIndex then return false end
|
||||||
if not PaintRequirements.getTextureData(vehicle, skinIndex) then return false end
|
if not PaintRequirements.getTextureData(vehicle, skinIndex) then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
|
||||||
|
if not PaintRequirements.isValidSelection(character, vehicle, skinIndex) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if not area or not vehicle:isInArea(area, character) then return false end
|
if not area or not vehicle:isInArea(area, character) then return false end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
PaintMyKI5 = PaintMyKI5 or {}
|
||||||
|
PaintMyKI5.TextureName = PaintMyKI5.TextureName or {}
|
||||||
|
|
||||||
|
local TextureName = PaintMyKI5.TextureName
|
||||||
|
|
||||||
|
local function filename(textureData)
|
||||||
|
local reference = textureData and textureData.texture or ""
|
||||||
|
if type(reference) ~= "string" then return "" end
|
||||||
|
local name = string.match(reference, "([^/\\]+)$") or reference
|
||||||
|
if string.lower(string.sub(name, -4)) == ".png" then
|
||||||
|
name = string.sub(name, 1, -5)
|
||||||
|
end
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commonPrefix(names)
|
||||||
|
local prefix = names[1] or ""
|
||||||
|
for index = 2, #names do
|
||||||
|
local other = names[index]
|
||||||
|
local length = math.min(#prefix, #other)
|
||||||
|
local matching = 0
|
||||||
|
for character = 1, length do
|
||||||
|
if string.lower(string.sub(prefix, character, character))
|
||||||
|
~= string.lower(string.sub(other, character, character)) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
matching = character
|
||||||
|
end
|
||||||
|
prefix = string.sub(prefix, 1, matching)
|
||||||
|
if prefix == "" then break end
|
||||||
|
end
|
||||||
|
return prefix
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shortenedLabels(names)
|
||||||
|
if #names < 2 then return nil end
|
||||||
|
local prefix = commonPrefix(names)
|
||||||
|
local separator = string.match(prefix, "^.*()[_%- ]")
|
||||||
|
if not separator then return nil end
|
||||||
|
prefix = string.sub(prefix, 1, separator)
|
||||||
|
|
||||||
|
local labels = {}
|
||||||
|
local distinct = {}
|
||||||
|
local distinctCount = 0
|
||||||
|
for index, name in ipairs(names) do
|
||||||
|
local label = string.sub(name, #prefix + 1)
|
||||||
|
if label == "" or #label > 32 then return nil end
|
||||||
|
label = string.gsub(label, "[_%-]+", " ")
|
||||||
|
if not distinct[string.lower(label)] then
|
||||||
|
distinct[string.lower(label)] = true
|
||||||
|
distinctCount = distinctCount + 1
|
||||||
|
end
|
||||||
|
labels[index] = label
|
||||||
|
end
|
||||||
|
return distinctCount > 1 and labels or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TextureName.buildLabels(textures)
|
||||||
|
local names = {}
|
||||||
|
for index, textureData in ipairs(textures or {}) do
|
||||||
|
names[index] = filename(textureData)
|
||||||
|
end
|
||||||
|
return shortenedLabels(names) or names
|
||||||
|
end
|
||||||
|
|
||||||
@@ -17,7 +17,10 @@ function ISPaintMyKI5Vehicle:isValid()
|
|||||||
self.vehicle, self.skinIndex
|
self.vehicle, self.skinIndex
|
||||||
)
|
)
|
||||||
if not requirements then return false end
|
if not requirements then return false end
|
||||||
return PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
|
local ready = PaintMyKI5.PaintInventory.checkPaintingRequirements(
|
||||||
|
self.character, requirements
|
||||||
|
)
|
||||||
|
return ready
|
||||||
end
|
end
|
||||||
|
|
||||||
function ISPaintMyKI5Vehicle:waitToStart()
|
function ISPaintMyKI5Vehicle:waitToStart()
|
||||||
@@ -78,7 +81,7 @@ function ISPaintMyKI5Vehicle:complete()
|
|||||||
self.vehicle, self.skinIndex
|
self.vehicle, self.skinIndex
|
||||||
)
|
)
|
||||||
if not requirements then return false end
|
if not requirements then return false end
|
||||||
local enough = PaintMyKI5.PaintInventory.checkRequirements(
|
local enough = PaintMyKI5.PaintInventory.checkPaintingRequirements(
|
||||||
self.character, requirements
|
self.character, requirements
|
||||||
)
|
)
|
||||||
if not enough then return false end
|
if not enough then return false end
|
||||||
|
|||||||
@@ -5,5 +5,9 @@
|
|||||||
"IGUI_PaintMyKI5_Requirements": "Paint requirements",
|
"IGUI_PaintMyKI5_Requirements": "Paint requirements",
|
||||||
"IGUI_PaintMyKI5_RequirementLine": "%1: %2%%, %3 uses needed, %4 available",
|
"IGUI_PaintMyKI5_RequirementLine": "%1: %2%%, %3 uses needed, %4 available",
|
||||||
"IGUI_PaintMyKI5_OneBucket": "Total: %1 uses — one full bucket",
|
"IGUI_PaintMyKI5_OneBucket": "Total: %1 uses — one full bucket",
|
||||||
"IGUI_PaintMyKI5_PreviewMissing": "Texture preview unavailable"
|
"IGUI_PaintMyKI5_PreviewMissing": "Texture preview unavailable",
|
||||||
|
"IGUI_PaintMyKI5_MissingRequirements": "Missing requirements:",
|
||||||
|
"IGUI_PaintMyKI5_MissingTool": "Missing %1",
|
||||||
|
"IGUI_PaintMyKI5_MissingPaint": "Missing %1 (%2 uses needed, %3 available)",
|
||||||
|
"IGUI_PaintMyKI5_Unavailable": "The vehicle must be parked and a different skin selected."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"PaintMyKI5.Sandpaper": "Sandpaper"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module PaintMyKI5
|
||||||
|
{
|
||||||
|
item Sandpaper
|
||||||
|
{
|
||||||
|
DisplayCategory = Tool,
|
||||||
|
ItemType = base:normal,
|
||||||
|
Weight = 0.1,
|
||||||
|
Icon = Sandpaper,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -1,11 +1,11 @@
|
|||||||
name=Paint my KI5
|
name=Paint my KI5
|
||||||
id=hrsys_paint_my_ki5
|
id=hrsys_paint_my_ki5
|
||||||
poster=../common/media/textures/preview.png
|
poster=preview.png
|
||||||
description=A mod allowing for painting of KI5 vehicles.
|
description=A mod allowing for painting of KI5 vehicles.
|
||||||
author=Riggs0
|
author=Riggs0
|
||||||
category=vehicle
|
category=vehicle
|
||||||
require=damnlib
|
require=damnlib
|
||||||
icon=../common/media/textures/paint_my_ki5_icon.png
|
icon=icon.png
|
||||||
url=https://hudsonriggs.systems
|
url=https://hudsonriggs.systems
|
||||||
modversion=1.1.0
|
modversion=1.1.0
|
||||||
versionMin=42.20
|
versionMin=42.20
|
||||||
|
|||||||
|
After Width: | Height: | Size: 265 KiB |
@@ -8,6 +8,9 @@ in singleplayer, hosted multiplayer, and dedicated servers.
|
|||||||
Stand outside a supported, stopped vehicle and right-click it, then choose
|
Stand outside a supported, stopped vehicle and right-click it, then choose
|
||||||
**Paint vehicle**. The window shows every scanned skin, its raw texture preview,
|
**Paint vehicle**. The window shows every scanned skin, its raw texture preview,
|
||||||
and the paint available in your carried inventory (including nested bags).
|
and the paint available in your carried inventory (including nested bags).
|
||||||
|
When sibling texture filenames share a prefix, the skin list shows only the
|
||||||
|
distinct suffix (`Green`, `Seafoam`, and so on); other entries keep their full
|
||||||
|
filename as a fallback.
|
||||||
|
|
||||||
Every repaint costs exactly one full B42 paint bucket worth of material. B42
|
Every repaint costs exactly one full B42 paint bucket worth of material. B42
|
||||||
paint buckets use `UseDelta = 0.1`, giving 10 uses per bucket. Those 10 uses are
|
paint buckets use `UseDelta = 0.1`, giving 10 uses per bucket. Those 10 uses are
|
||||||
@@ -42,8 +45,11 @@ The scanner:
|
|||||||
|
|
||||||
The tool reads the authoritative palette from `ISPaintMenu.lua` and verifies
|
The tool reads the authoritative palette from `ISPaintMenu.lua` and verifies
|
||||||
each entry against the bucket definitions in `drainable.txt`. Empty buckets and
|
each entry against the bucket definitions in `drainable.txt`. Empty buckets and
|
||||||
spray paint are excluded. Pixels are matched to the nearest paint color in
|
spray paint are excluded. The scanner compares sibling skins with matching UV
|
||||||
perceptual Lab color space.
|
layouts to isolate body-sized areas that actually change between paints, which
|
||||||
|
removes fixed glass, trim, grilles, and interiors from the mix. Within those
|
||||||
|
areas it preserves hue through dark shading; single skins and small livery-only
|
||||||
|
differences retain the conservative perceptual Lab fallback.
|
||||||
|
|
||||||
The development tool and its tests live under [`scanner/`](scanner/). Install
|
The development tool and its tests live under [`scanner/`](scanner/). Install
|
||||||
Python 3.11 or newer, then run it from the repository root:
|
Python 3.11 or newer, then run it from the repository root:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -1,11 +1,11 @@
|
|||||||
name=Paint my KI5
|
name=Paint my KI5
|
||||||
id=hrsys_paint_my_ki5
|
id=hrsys_paint_my_ki5
|
||||||
poster=/common/media/textures/preview.png
|
poster=preview.png
|
||||||
description=A mod allowing for painting of KI5 vehicles.
|
description=A mod allowing for painting of KI5 vehicles.
|
||||||
author=Riggs0
|
author=Riggs0
|
||||||
category=vehicle
|
category=vehicle
|
||||||
require=damnlib
|
require=damnlib
|
||||||
icon=/common/media/textures/paint_my_ki5_icon.png
|
icon=icon.png
|
||||||
url=https://hudsonriggs.systems
|
url=https://hudsonriggs.systems
|
||||||
modversion=1.1.0
|
modversion=1.1.0
|
||||||
versionMin=42.20
|
versionMin=42.20
|
||||||
|
|||||||
|
After Width: | Height: | Size: 265 KiB |
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import colorsys
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -17,7 +18,7 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, Sequence, TextIO
|
from typing import Iterable, Sequence, TextIO
|
||||||
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, ImageChops, UnidentifiedImageError
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_WORKSHOP_ROOT = Path(r"D:\SteamLibrary\steamapps\workshop\content\108600")
|
DEFAULT_WORKSHOP_ROOT = Path(r"D:\SteamLibrary\steamapps\workshop\content\108600")
|
||||||
@@ -55,6 +56,14 @@ MAX_SKINS_PER_VEHICLE = 2_000
|
|||||||
MAX_WARNINGS = 20_000
|
MAX_WARNINGS = 20_000
|
||||||
MAX_METADATA_CHARS = 2_048
|
MAX_METADATA_CHARS = 2_048
|
||||||
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
|
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_SCAN_DECODED_PIXELS = 3_000_000_000
|
||||||
|
MAX_VARIANT_MASK_PIXELS = 268_435_456
|
||||||
|
MAX_VARIANT_MASK_SKINS = 64
|
||||||
|
CHROMATIC_SATURATION_MIN = 0.10
|
||||||
|
CHROMATIC_RGB_RANGE_MIN = 8 / 255
|
||||||
|
MAX_CHROMATIC_HUE_DISTANCE = 0.16
|
||||||
|
VARIANT_RGB_RANGE_MIN = 16
|
||||||
|
MIN_VARIANT_MASK_COVERAGE = 0.20
|
||||||
|
|
||||||
|
|
||||||
class ScanFileError(ValueError):
|
class ScanFileError(ValueError):
|
||||||
@@ -607,11 +616,46 @@ def _nearest_paint(
|
|||||||
red: int,
|
red: int,
|
||||||
green: int,
|
green: int,
|
||||||
blue: int,
|
blue: int,
|
||||||
palette_labs: tuple[tuple[PaintCan, tuple[float, float, float]], ...],
|
palette_colors: tuple[
|
||||||
|
tuple[PaintCan, tuple[float, float, float], tuple[float, float, float]], ...
|
||||||
|
],
|
||||||
|
preserve_chromatic_hue: bool,
|
||||||
) -> PaintCan:
|
) -> PaintCan:
|
||||||
pixel_lab = _srgb_to_lab((red / 255, green / 255, blue / 255))
|
pixel_rgb = red / 255, green / 255, blue / 255
|
||||||
|
pixel_hsv = colorsys.rgb_to_hsv(*pixel_rgb)
|
||||||
|
if (
|
||||||
|
preserve_chromatic_hue
|
||||||
|
and pixel_hsv[1] >= CHROMATIC_SATURATION_MIN
|
||||||
|
and max(pixel_rgb) - min(pixel_rgb) >= CHROMATIC_RGB_RANGE_MIN
|
||||||
|
):
|
||||||
|
chromatic = [
|
||||||
|
entry for entry in palette_colors if entry[2][1] >= CHROMATIC_SATURATION_MIN
|
||||||
|
]
|
||||||
|
if chromatic:
|
||||||
|
def chromatic_distance(
|
||||||
|
entry: tuple[
|
||||||
|
PaintCan,
|
||||||
|
tuple[float, float, float],
|
||||||
|
tuple[float, float, float],
|
||||||
|
],
|
||||||
|
) -> tuple[float, str]:
|
||||||
|
hue_distance = abs(pixel_hsv[0] - entry[2][0])
|
||||||
|
hue_distance = min(hue_distance, 1 - hue_distance)
|
||||||
|
return (
|
||||||
|
(hue_distance * 3) ** 2
|
||||||
|
+ (pixel_hsv[1] - entry[2][1]) ** 2
|
||||||
|
+ 0.02 * (pixel_hsv[2] - entry[2][2]) ** 2,
|
||||||
|
entry[0].item_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
nearest_chromatic = min(chromatic, key=chromatic_distance)
|
||||||
|
hue_distance = abs(pixel_hsv[0] - nearest_chromatic[2][0])
|
||||||
|
if min(hue_distance, 1 - hue_distance) <= MAX_CHROMATIC_HUE_DISTANCE:
|
||||||
|
return nearest_chromatic[0]
|
||||||
|
|
||||||
|
pixel_lab = _srgb_to_lab(pixel_rgb)
|
||||||
return min(
|
return min(
|
||||||
palette_labs,
|
palette_colors,
|
||||||
key=lambda entry: (
|
key=lambda entry: (
|
||||||
sum((pixel_lab[index] - entry[1][index]) ** 2 for index in range(3)),
|
sum((pixel_lab[index] - entry[1][index]) ** 2 for index in range(3)),
|
||||||
entry[0].item_id,
|
entry[0].item_id,
|
||||||
@@ -619,14 +663,8 @@ def _nearest_paint(
|
|||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
|
|
||||||
def analyze_colors(
|
def _read_rgba_png(texture_path: Path) -> Image.Image:
|
||||||
texture_path: Path,
|
try:
|
||||||
paint_cans: Sequence[PaintCan],
|
|
||||||
) -> list[PaintPercentage]:
|
|
||||||
"""Map non-transparent PNG pixels to their nearest available B42 paint cans."""
|
|
||||||
if not paint_cans:
|
|
||||||
raise ValueError("The B42 paint palette is empty")
|
|
||||||
palette_labs = tuple((paint, _srgb_to_lab(paint.rgb)) for paint in paint_cans)
|
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||||
image = Image.open(texture_path, formats=("PNG",))
|
image = Image.open(texture_path, formats=("PNG",))
|
||||||
@@ -638,7 +676,103 @@ def analyze_colors(
|
|||||||
raise ScanFileError(
|
raise ScanFileError(
|
||||||
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
|
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
|
||||||
)
|
)
|
||||||
rgba = image.convert("RGBA")
|
return image.convert("RGBA")
|
||||||
|
except ValueError as error:
|
||||||
|
raise ScanFileError(f"Could not decode PNG {texture_path}: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _png_pixel_count(texture_path: Path) -> int:
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||||
|
with Image.open(texture_path, formats=("PNG",)) as image:
|
||||||
|
if image.format != "PNG":
|
||||||
|
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
|
||||||
|
width, height = image.size
|
||||||
|
except ValueError as error:
|
||||||
|
raise ScanFileError(f"Could not decode PNG {texture_path}: {error}") from error
|
||||||
|
pixels = width * height
|
||||||
|
if width <= 0 or height <= 0 or pixels > MAX_IMAGE_PIXELS:
|
||||||
|
raise ScanFileError(
|
||||||
|
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
|
||||||
|
)
|
||||||
|
return pixels
|
||||||
|
|
||||||
|
|
||||||
|
def _add_decoded_pixels(current: int, additional: int) -> int:
|
||||||
|
total = current + additional
|
||||||
|
if additional < 0 or total > MAX_SCAN_DECODED_PIXELS:
|
||||||
|
raise ScanFileError(
|
||||||
|
f"Scan exceeds the {MAX_SCAN_DECODED_PIXELS}-decoded-pixel safety limit"
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def build_variant_mask(texture_paths: Sequence[Path]) -> bytes | None:
|
||||||
|
"""Select shell pixels whose RGB changes between sibling skin textures."""
|
||||||
|
unique_paths = tuple(dict.fromkeys(texture_paths))
|
||||||
|
if len(unique_paths) < 2 or len(unique_paths) > MAX_VARIANT_MASK_SKINS:
|
||||||
|
return None
|
||||||
|
pixel_counts = tuple(_png_pixel_count(path) for path in unique_paths)
|
||||||
|
if sum(pixel_counts) > MAX_VARIANT_MASK_PIXELS:
|
||||||
|
return None
|
||||||
|
first = _read_rgba_png(unique_paths[0])
|
||||||
|
size = first.size
|
||||||
|
darkest = first.convert("RGB")
|
||||||
|
lightest = darkest.copy()
|
||||||
|
shared_opacity = first.getchannel("A")
|
||||||
|
first.close()
|
||||||
|
for path in unique_paths[1:]:
|
||||||
|
image = _read_rgba_png(path)
|
||||||
|
if image.size != size:
|
||||||
|
image.close()
|
||||||
|
return None
|
||||||
|
rgb = image.convert("RGB")
|
||||||
|
darkest = ImageChops.darker(darkest, rgb)
|
||||||
|
lightest = ImageChops.lighter(lightest, rgb)
|
||||||
|
shared_opacity = ImageChops.darker(shared_opacity, image.getchannel("A"))
|
||||||
|
image.close()
|
||||||
|
shared_alpha = shared_opacity.tobytes()
|
||||||
|
if not any(shared_alpha):
|
||||||
|
return None
|
||||||
|
difference = ImageChops.difference(lightest, darkest)
|
||||||
|
channels = difference.split()
|
||||||
|
maximum_difference = ImageChops.lighter(ImageChops.lighter(channels[0], channels[1]), channels[2])
|
||||||
|
mask = maximum_difference.point(
|
||||||
|
lambda value: 255 if value >= VARIANT_RGB_RANGE_MIN else 0
|
||||||
|
)
|
||||||
|
visible_in_every_skin = shared_opacity.point(lambda value: 255 if value else 0)
|
||||||
|
mask = ImageChops.multiply(mask, visible_in_every_skin)
|
||||||
|
mask_bytes = mask.tobytes()
|
||||||
|
visible_pixels = visible_in_every_skin.tobytes().count(255)
|
||||||
|
masked_pixels = mask_bytes.count(255)
|
||||||
|
if not visible_pixels or masked_pixels / visible_pixels < MIN_VARIANT_MASK_COVERAGE:
|
||||||
|
return None
|
||||||
|
return mask_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_colors(
|
||||||
|
texture_path: Path,
|
||||||
|
paint_cans: Sequence[PaintCan],
|
||||||
|
*,
|
||||||
|
pixel_mask: bytes | None = None,
|
||||||
|
preserve_chromatic_hue: bool = False,
|
||||||
|
) -> list[PaintPercentage]:
|
||||||
|
"""Map non-transparent PNG pixels to their nearest available B42 paint cans."""
|
||||||
|
if not paint_cans:
|
||||||
|
raise ValueError("The B42 paint palette is empty")
|
||||||
|
palette_colors = tuple(
|
||||||
|
(paint, _srgb_to_lab(paint.rgb), colorsys.rgb_to_hsv(*paint.rgb))
|
||||||
|
for paint in paint_cans
|
||||||
|
)
|
||||||
|
rgba = _read_rgba_png(texture_path)
|
||||||
|
with rgba:
|
||||||
|
width, height = rgba.size
|
||||||
|
if pixel_mask is not None:
|
||||||
|
if len(pixel_mask) != width * height:
|
||||||
|
raise ValueError("Variant mask dimensions do not match the texture")
|
||||||
|
mask = Image.frombytes("L", (width, height), pixel_mask)
|
||||||
|
rgba.putalpha(ImageChops.multiply(rgba.getchannel("A"), mask))
|
||||||
counts: Counter[PaintCan] = Counter()
|
counts: Counter[PaintCan] = Counter()
|
||||||
rgba_counts = rgba.getcolors(maxcolors=MAX_UNIQUE_COLORS)
|
rgba_counts = rgba.getcolors(maxcolors=MAX_UNIQUE_COLORS)
|
||||||
if rgba_counts is None:
|
if rgba_counts is None:
|
||||||
@@ -648,7 +782,13 @@ def analyze_colors(
|
|||||||
for pixel_count, (red, green, blue, opacity) in rgba_counts:
|
for pixel_count, (red, green, blue, opacity) in rgba_counts:
|
||||||
if not opacity:
|
if not opacity:
|
||||||
continue
|
continue
|
||||||
paint = _nearest_paint(red, green, blue, palette_labs)
|
paint = _nearest_paint(
|
||||||
|
red,
|
||||||
|
green,
|
||||||
|
blue,
|
||||||
|
palette_colors,
|
||||||
|
preserve_chromatic_hue,
|
||||||
|
)
|
||||||
counts[paint] += pixel_count * opacity
|
counts[paint] += pixel_count * opacity
|
||||||
total = sum(counts.values())
|
total = sum(counts.values())
|
||||||
if total == 0:
|
if total == 0:
|
||||||
@@ -675,11 +815,13 @@ def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanRe
|
|||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
cars_found = 0
|
cars_found = 0
|
||||||
paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
|
paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
|
||||||
|
decoded_pixels = 0
|
||||||
for mod in mods:
|
for mod in mods:
|
||||||
scripts_root = mod.content_root / "media" / "scripts" / "vehicles"
|
scripts_root = mod.content_root / "media" / "scripts" / "vehicles"
|
||||||
vehicles = parse_vehicle_scripts(scripts_root)
|
vehicles = parse_vehicle_scripts(scripts_root)
|
||||||
cars_found += len(vehicles)
|
cars_found += len(vehicles)
|
||||||
for vehicle in vehicles:
|
for vehicle in vehicles:
|
||||||
|
resolved_skins: list[tuple[SkinDefinition, Path, int]] = []
|
||||||
for skin in vehicle.skins:
|
for skin in vehicle.skins:
|
||||||
reference = skin.texture_reference
|
reference = skin.texture_reference
|
||||||
texture_path = resolve_texture(mod, reference)
|
texture_path = resolve_texture(mod, reference)
|
||||||
@@ -689,10 +831,56 @@ def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanRe
|
|||||||
warnings.append(f"Missing texture for {vehicle.vehicle_id}: {reference}")
|
warnings.append(f"Missing texture for {vehicle.vehicle_id}: {reference}")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
paints = paint_cache.get(texture_path)
|
pixel_count = _png_pixel_count(texture_path)
|
||||||
|
decoded_pixels = _add_decoded_pixels(decoded_pixels, pixel_count)
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ScanFileError,
|
||||||
|
UnidentifiedImageError,
|
||||||
|
Image.DecompressionBombError,
|
||||||
|
Image.DecompressionBombWarning,
|
||||||
|
) as error:
|
||||||
|
if len(warnings) >= MAX_WARNINGS:
|
||||||
|
raise ValueError(f"Scan exceeded the {MAX_WARNINGS}-warning safety limit")
|
||||||
|
warnings.append(f"Could not read {texture_path}: {error}")
|
||||||
|
continue
|
||||||
|
resolved_skins.append((skin, texture_path, pixel_count))
|
||||||
|
|
||||||
|
variant_mask = None
|
||||||
|
try:
|
||||||
|
mask_pixels = sum(pixel_count for _skin, _path, pixel_count in resolved_skins)
|
||||||
|
if (
|
||||||
|
len(resolved_skins) <= MAX_VARIANT_MASK_SKINS
|
||||||
|
and mask_pixels <= MAX_VARIANT_MASK_PIXELS
|
||||||
|
):
|
||||||
|
decoded_pixels = _add_decoded_pixels(decoded_pixels, mask_pixels)
|
||||||
|
variant_mask = build_variant_mask(
|
||||||
|
tuple(texture_path for _skin, texture_path, _pixels in resolved_skins)
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ScanFileError,
|
||||||
|
UnidentifiedImageError,
|
||||||
|
Image.DecompressionBombError,
|
||||||
|
Image.DecompressionBombWarning,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
vehicle_paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
|
||||||
|
for skin, texture_path, _pixel_count in resolved_skins:
|
||||||
|
reference = skin.texture_reference
|
||||||
|
try:
|
||||||
|
cache = vehicle_paint_cache if variant_mask is not None else paint_cache
|
||||||
|
paints = cache.get(texture_path)
|
||||||
if paints is None:
|
if paints is None:
|
||||||
paints = tuple(analyze_colors(texture_path, paint_cans))
|
paints = tuple(
|
||||||
paint_cache[texture_path] = paints
|
analyze_colors(
|
||||||
|
texture_path,
|
||||||
|
paint_cans,
|
||||||
|
pixel_mask=variant_mask,
|
||||||
|
preserve_chromatic_hue=variant_mask is not None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cache[texture_path] = paints
|
||||||
except (
|
except (
|
||||||
OSError,
|
OSError,
|
||||||
ScanFileError,
|
ScanFileError,
|
||||||
|
|||||||
@@ -48,6 +48,39 @@ package.loaded["PaintMyKI5/PaintMyKI5VehiclePaintData"] = true
|
|||||||
|
|
||||||
require "PaintMyKI5/PaintRequirements"
|
require "PaintMyKI5/PaintRequirements"
|
||||||
require "PaintMyKI5/PaintInventory"
|
require "PaintMyKI5/PaintInventory"
|
||||||
|
require "PaintMyKI5/TextureName"
|
||||||
|
|
||||||
|
test("texture labels use the unique suffix of a shared filename prefix", function()
|
||||||
|
local labels = PaintMyKI5.TextureName.buildLabels({
|
||||||
|
{ texture = "Vehicles/Vehicles_93mustangGT_Shell_Green" },
|
||||||
|
{ texture = "Vehicles/Vehicles_93mustangGT_Shell_Seafoam" },
|
||||||
|
{ texture = "Vehicles/Vehicles_93mustangGT_Shell_SSP" },
|
||||||
|
})
|
||||||
|
assert(labels[1] == "Green")
|
||||||
|
assert(labels[2] == "Seafoam")
|
||||||
|
assert(labels[3] == "SSP")
|
||||||
|
end)
|
||||||
|
|
||||||
|
test("texture labels keep filenames when there is no useful shared prefix", function()
|
||||||
|
local labels = PaintMyKI5.TextureName.buildLabels({
|
||||||
|
{ texture = "Vehicles/Ford_Green.png" },
|
||||||
|
{ texture = "Vehicles/Chevrolet_Red.png" },
|
||||||
|
})
|
||||||
|
assert(labels[1] == "Ford_Green")
|
||||||
|
assert(labels[2] == "Chevrolet_Red")
|
||||||
|
|
||||||
|
local single = PaintMyKI5.TextureName.buildLabels({
|
||||||
|
{ texture = "Vehicles/Only_Skin" },
|
||||||
|
})
|
||||||
|
assert(single[1] == "Only_Skin")
|
||||||
|
|
||||||
|
local duplicates = PaintMyKI5.TextureName.buildLabels({
|
||||||
|
{ texture = "Vehicles/Repeated_Green" },
|
||||||
|
{ texture = "Vehicles/Repeated_Green" },
|
||||||
|
})
|
||||||
|
assert(duplicates[1] == "Repeated_Green")
|
||||||
|
assert(duplicates[2] == "Repeated_Green")
|
||||||
|
end)
|
||||||
|
|
||||||
local function fakeVehicle()
|
local function fakeVehicle()
|
||||||
return {
|
return {
|
||||||
@@ -78,6 +111,17 @@ test("invalid skin is rejected", function()
|
|||||||
assert(PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 1) == nil)
|
assert(PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 1) == nil)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
test("a paint selection is valid before the player reaches the vehicle area", function()
|
||||||
|
local vehicle = fakeVehicle()
|
||||||
|
vehicle.isInArea = function() return false end
|
||||||
|
local character = { getVehicle = function() return nil end }
|
||||||
|
|
||||||
|
assert(PaintMyKI5.PaintRequirements.isValidSelection(character, vehicle, 2))
|
||||||
|
assert(not PaintMyKI5.PaintRequirements.isValidTarget(
|
||||||
|
character, vehicle, 2, "Engine"
|
||||||
|
))
|
||||||
|
end)
|
||||||
|
|
||||||
local syncCount = 0
|
local syncCount = 0
|
||||||
local removedCount = 0
|
local removedCount = 0
|
||||||
local addedCount = 0
|
local addedCount = 0
|
||||||
@@ -146,6 +190,35 @@ test("a missing color prevents every mutation", function()
|
|||||||
assertNear(green.delta, 1.0)
|
assertNear(green.delta, 1.0)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
test("painting readiness requires reusable sandpaper and a paintbrush", function()
|
||||||
|
local green = fakeItem("Base.PaintGreen", 1.0)
|
||||||
|
local sandpaper = { marker = "sandpaper" }
|
||||||
|
local craftedBrush = { marker = "crafted brush" }
|
||||||
|
local byType = {
|
||||||
|
["Base.PaintGreen"] = javaList({ green }),
|
||||||
|
}
|
||||||
|
local inventory = {
|
||||||
|
getAllTypeRecurse = function(_, itemType)
|
||||||
|
return byType[itemType] or javaList({})
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local character = { getInventory = function() return inventory end }
|
||||||
|
local requirements = { { item = "Base.PaintGreen", uses = 1 } }
|
||||||
|
|
||||||
|
local ready, missingPaint, missingTools =
|
||||||
|
PaintMyKI5.PaintInventory.checkPaintingRequirements(character, requirements)
|
||||||
|
assert(not ready and #missingPaint == 0 and #missingTools == 2)
|
||||||
|
assert(missingTools[1].item == "PaintMyKI5.Sandpaper")
|
||||||
|
assert(missingTools[2].item == "Base.Paintbrush")
|
||||||
|
|
||||||
|
byType["PaintMyKI5.Sandpaper"] = javaList({ sandpaper })
|
||||||
|
byType["Base.PaintbrushCrafted"] = javaList({ craftedBrush })
|
||||||
|
ready, missingPaint, missingTools =
|
||||||
|
PaintMyKI5.PaintInventory.checkPaintingRequirements(character, requirements)
|
||||||
|
assert(ready and #missingPaint == 0 and #missingTools == 0)
|
||||||
|
assert(sandpaper.marker == "sandpaper" and craftedBrush.marker == "crafted brush")
|
||||||
|
end)
|
||||||
|
|
||||||
test("replacement failure rolls back earlier paint", function()
|
test("replacement failure rolls back earlier paint", function()
|
||||||
local green = fakeItem("Base.PaintGreen", 1.0)
|
local green = fakeItem("Base.PaintGreen", 1.0)
|
||||||
local white = fakeItem("Base.PaintWhite", 0.1, true)
|
local white = fakeItem("Base.PaintWhite", 0.1, true)
|
||||||
@@ -184,6 +257,7 @@ isClient = function() return false end
|
|||||||
isServer = function() return true end
|
isServer = function() return true end
|
||||||
dofile(luaRoot .. "/shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua")
|
dofile(luaRoot .. "/shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua")
|
||||||
PaintMyKI5.PaintInventory.checkRequirements = function() return true end
|
PaintMyKI5.PaintInventory.checkRequirements = function() return true end
|
||||||
|
PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return true end
|
||||||
|
|
||||||
local function serverCharacter()
|
local function serverCharacter()
|
||||||
return {
|
return {
|
||||||
@@ -204,6 +278,23 @@ local function mutableVehicle()
|
|||||||
return vehicle
|
return vehicle
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test("server completion rejects missing tools before changing skin or paint", function()
|
||||||
|
local vehicle = mutableVehicle()
|
||||||
|
local consumed = 0
|
||||||
|
PaintMyKI5.PaintInventory.checkPaintingRequirements = function()
|
||||||
|
return false, {}, { { item = "PaintMyKI5.Sandpaper" } }
|
||||||
|
end
|
||||||
|
PaintMyKI5.PaintInventory.consumeRequirements = function()
|
||||||
|
consumed = consumed + 1
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 2, "Engine")
|
||||||
|
action:serverStart()
|
||||||
|
assert(not action:complete())
|
||||||
|
assert(vehicle.skin == 0 and vehicle.transmitted == 0 and consumed == 0)
|
||||||
|
PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return true end
|
||||||
|
end)
|
||||||
|
|
||||||
test("server completion consumes then transmits the selected skin", function()
|
test("server completion consumes then transmits the selected skin", function()
|
||||||
local vehicle = mutableVehicle()
|
local vehicle = mutableVehicle()
|
||||||
local character = serverCharacter()
|
local character = serverCharacter()
|
||||||
@@ -288,4 +379,74 @@ test("client completion never mutates inventory or the vehicle", function()
|
|||||||
isClient = function() return false end
|
isClient = function() return false end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
test("sandpaper is rare wherever full paint cans spawn", function()
|
||||||
|
ProceduralDistributions = {
|
||||||
|
list = {
|
||||||
|
PaintShelf = { items = { "PaintBlack", 10, "Paintbrush", 10 } },
|
||||||
|
PaintJunk = {
|
||||||
|
items = { "Hammer", 1 },
|
||||||
|
junk = { items = { "Base.PaintGreen", 0.4 } },
|
||||||
|
},
|
||||||
|
EmptyBuckets = { items = { "PaintbucketEmpty", 10, "Paintbrush", 10 } },
|
||||||
|
OtherModPaint = { items = { "OtherMod.PaintGreen", 10 } },
|
||||||
|
AlreadyAdded = {
|
||||||
|
items = { "PaintWhite", 1, "PaintMyKI5.Sandpaper", 0.1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ClutterTables = {
|
||||||
|
ClosetItems = { "PaintPurple", 0.1, "Pillow", 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
dofile(luaRoot .. "/server/Items/PaintMyKI5_SandpaperDistribution.lua")
|
||||||
|
|
||||||
|
local function countItem(items, itemType)
|
||||||
|
local count = 0
|
||||||
|
local weight = nil
|
||||||
|
for index = 1, #items, 2 do
|
||||||
|
if items[index] == itemType then
|
||||||
|
count = count + 1
|
||||||
|
weight = items[index + 1]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return count, weight
|
||||||
|
end
|
||||||
|
|
||||||
|
local shelfCount, shelfWeight = countItem(
|
||||||
|
ProceduralDistributions.list.PaintShelf.items,
|
||||||
|
"PaintMyKI5.Sandpaper"
|
||||||
|
)
|
||||||
|
assert(shelfCount == 1 and shelfWeight == 0.1)
|
||||||
|
|
||||||
|
local junkCount, junkWeight = countItem(
|
||||||
|
ProceduralDistributions.list.PaintJunk.junk.items,
|
||||||
|
"PaintMyKI5.Sandpaper"
|
||||||
|
)
|
||||||
|
assert(junkCount == 1 and junkWeight == 0.1)
|
||||||
|
|
||||||
|
local emptyCount = countItem(
|
||||||
|
ProceduralDistributions.list.EmptyBuckets.items,
|
||||||
|
"PaintMyKI5.Sandpaper"
|
||||||
|
)
|
||||||
|
assert(emptyCount == 0)
|
||||||
|
|
||||||
|
local otherModCount = countItem(
|
||||||
|
ProceduralDistributions.list.OtherModPaint.items,
|
||||||
|
"PaintMyKI5.Sandpaper"
|
||||||
|
)
|
||||||
|
assert(otherModCount == 0)
|
||||||
|
|
||||||
|
local existingCount = countItem(
|
||||||
|
ProceduralDistributions.list.AlreadyAdded.items,
|
||||||
|
"PaintMyKI5.Sandpaper"
|
||||||
|
)
|
||||||
|
assert(existingCount == 1)
|
||||||
|
|
||||||
|
local clutterCount, clutterWeight = countItem(
|
||||||
|
ClutterTables.ClosetItems,
|
||||||
|
"PaintMyKI5.Sandpaper"
|
||||||
|
)
|
||||||
|
assert(clutterCount == 1 and clutterWeight == 0.1)
|
||||||
|
end)
|
||||||
|
|
||||||
print("Lua tests passed: " .. passed)
|
print("Lua tests passed: " .. passed)
|
||||||
|
|||||||
@@ -66,11 +66,66 @@ class InGameLuaContractTests(unittest.TestCase):
|
|||||||
"shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua",
|
"shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua",
|
||||||
"client/PaintMyKI5/ISPaintMyKI5UI.lua",
|
"client/PaintMyKI5/ISPaintMyKI5UI.lua",
|
||||||
"client/PaintMyKI5/PaintVehicleContextMenu.lua",
|
"client/PaintMyKI5/PaintVehicleContextMenu.lua",
|
||||||
|
"shared/PaintMyKI5/TextureName.lua",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_runtime_files_exist(self) -> None:
|
def test_runtime_files_exist(self) -> None:
|
||||||
self.assertEqual([], [name for name in self.EXPECTED_FILES if not (LUA_ROOT / name).is_file()])
|
self.assertEqual([], [name for name in self.EXPECTED_FILES if not (LUA_ROOT / name).is_file()])
|
||||||
|
|
||||||
|
def test_art_assets_are_packaged_for_mod_and_context_menus(self) -> None:
|
||||||
|
source_icon = (PROJECT_ROOT / "art/icon.png").read_bytes()
|
||||||
|
source_preview = (PROJECT_ROOT / "art/preview.png").read_bytes()
|
||||||
|
|
||||||
|
for directory in (PROJECT_ROOT, PROJECT_ROOT / "42.20"):
|
||||||
|
self.assertEqual(source_icon, (directory / "icon.png").read_bytes())
|
||||||
|
self.assertEqual(source_preview, (directory / "preview.png").read_bytes())
|
||||||
|
metadata = (directory / "mod.info").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("icon=icon.png", metadata)
|
||||||
|
self.assertIn("poster=preview.png", metadata)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
source_icon,
|
||||||
|
(PROJECT_ROOT / "common/media/textures/PaintMyKI5_ContextMenu.png").read_bytes(),
|
||||||
|
)
|
||||||
|
context_source = (LUA_ROOT / self.EXPECTED_FILES[4]).read_text(encoding="utf-8")
|
||||||
|
self.assertIn(
|
||||||
|
'getTexture("media/textures/PaintMyKI5_ContextMenu.png")',
|
||||||
|
context_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sandpaper_item_icon_translation_and_loot_loader_are_packaged(self) -> None:
|
||||||
|
item_script = (
|
||||||
|
PROJECT_ROOT / "42.20/media/scripts/PaintMyKI5_items.txt"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
sandpaper = re.search(r"item\s+Sandpaper\s*\{(.*?)\}", item_script, re.S)
|
||||||
|
self.assertIsNotNone(sandpaper)
|
||||||
|
self.assertIn("ItemType = base:normal", sandpaper.group(1))
|
||||||
|
self.assertIn("Icon = Sandpaper", sandpaper.group(1))
|
||||||
|
self.assertEqual(
|
||||||
|
(PROJECT_ROOT / "art/sandpaper.png").read_bytes(),
|
||||||
|
(PROJECT_ROOT / "common/media/textures/Item_Sandpaper.png").read_bytes(),
|
||||||
|
)
|
||||||
|
|
||||||
|
translations = json.loads(
|
||||||
|
(
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "42.20/media/lua/shared/Translate/EN/ItemName.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertEqual("Sandpaper", translations["ItemName_PaintMyKI5.Sandpaper"])
|
||||||
|
self.assertFalse(
|
||||||
|
(
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "42.20/media/lua/shared/Translate/EN/ItemName_EN.json"
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
(
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "42.20/media/lua/server/Items/PaintMyKI5_SandpaperDistribution.lua"
|
||||||
|
).is_file()
|
||||||
|
)
|
||||||
|
|
||||||
def test_server_authoritative_timed_action_contract(self) -> None:
|
def test_server_authoritative_timed_action_contract(self) -> None:
|
||||||
source = (LUA_ROOT / self.EXPECTED_FILES[2]).read_text(encoding="utf-8")
|
source = (LUA_ROOT / self.EXPECTED_FILES[2]).read_text(encoding="utf-8")
|
||||||
|
|
||||||
@@ -78,6 +133,7 @@ class InGameLuaContractTests(unittest.TestCase):
|
|||||||
self.assertIn("function ISPaintMyKI5Vehicle:complete()", source)
|
self.assertIn("function ISPaintMyKI5Vehicle:complete()", source)
|
||||||
self.assertIn("PaintRequirements.getRequirements", source)
|
self.assertIn("PaintRequirements.getRequirements", source)
|
||||||
self.assertIn("PaintInventory.consumeRequirements", source)
|
self.assertIn("PaintInventory.consumeRequirements", source)
|
||||||
|
self.assertGreaterEqual(source.count("checkPaintingRequirements"), 2)
|
||||||
self.assertIn("vehicle:setSkinIndex", source)
|
self.assertIn("vehicle:setSkinIndex", source)
|
||||||
self.assertIn("vehicle:transmitSkinIndex", source)
|
self.assertIn("vehicle:transmitSkinIndex", source)
|
||||||
self.assertNotRegex(source, r"sendClientCommand\([^\n]+setSkinIndex")
|
self.assertNotRegex(source, r"sendClientCommand\([^\n]+setSkinIndex")
|
||||||
@@ -91,8 +147,26 @@ class InGameLuaContractTests(unittest.TestCase):
|
|||||||
self.assertIn("getTexture", ui_source)
|
self.assertIn("getTexture", ui_source)
|
||||||
self.assertIn("or getTexture(textureData.texture)", ui_source)
|
self.assertIn("or getTexture(textureData.texture)", ui_source)
|
||||||
self.assertIn("drawTextureScaledAspect", ui_source)
|
self.assertIn("drawTextureScaledAspect", ui_source)
|
||||||
self.assertGreaterEqual(ui_source.count("PaintRequirements.isValidTarget"), 2)
|
self.assertIn("PaintMyKI5.TextureName.buildLabels", ui_source)
|
||||||
|
self.assertIn("paintButton:setTooltip", ui_source)
|
||||||
|
self.assertIn("checkPaintingRequirements", ui_source)
|
||||||
|
self.assertGreaterEqual(ui_source.count("PaintRequirements.isValidSelection"), 2)
|
||||||
|
self.assertNotIn("PaintRequirements.isValidTarget", ui_source)
|
||||||
self.assertIn("ISTimedActionQueue.add", ui_source)
|
self.assertIn("ISTimedActionQueue.add", ui_source)
|
||||||
|
self.assertLess(
|
||||||
|
ui_source.index("ISPathFindAction:pathToVehicleArea"),
|
||||||
|
ui_source.index("ISPaintMyKI5Vehicle:new"),
|
||||||
|
)
|
||||||
|
|
||||||
|
translations = json.loads(
|
||||||
|
(LUA_ROOT / "shared/Translate/EN/IG_UI.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"Missing requirements:",
|
||||||
|
translations["IGUI_PaintMyKI5_MissingRequirements"],
|
||||||
|
)
|
||||||
|
self.assertIn("%1", translations["IGUI_PaintMyKI5_MissingTool"])
|
||||||
|
self.assertIn("%1", translations["IGUI_PaintMyKI5_MissingPaint"])
|
||||||
|
|
||||||
def test_b42_translation_files_are_valid_json(self) -> None:
|
def test_b42_translation_files_are_valid_json(self) -> None:
|
||||||
context = json.loads(
|
context = json.loads(
|
||||||
@@ -121,6 +195,31 @@ class InGameLuaContractTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_generated_mustang_green_skin_is_classified_as_green(self) -> None:
|
||||||
|
manifest = (
|
||||||
|
LUA_ROOT / "shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
vehicle = re.search(
|
||||||
|
r'\["Base\.93mustangGT"\] = \{(.*?)\n \},\n'
|
||||||
|
r' \["Base\.93mustangSSP"\]',
|
||||||
|
manifest,
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(vehicle)
|
||||||
|
green_skin = re.search(
|
||||||
|
r'texture = "Vehicles/Vehicles_93mustangGT_Shell_Green",'
|
||||||
|
r"(.*?)\n \},",
|
||||||
|
vehicle.group(1),
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(green_skin)
|
||||||
|
green_percentage = re.search(
|
||||||
|
r'item = "Base\.PaintGreen", percent = ([\d.]+)',
|
||||||
|
green_skin.group(1),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(green_percentage)
|
||||||
|
self.assertGreaterEqual(float(green_percentage.group(1)), 90.0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from scanner.scan_ki5_workshop import (
|
from scanner.scan_ki5_workshop import (
|
||||||
MAX_METADATA_CHARS,
|
MAX_METADATA_CHARS,
|
||||||
|
MAX_SCAN_DECODED_PIXELS,
|
||||||
|
MAX_VARIANT_MASK_SKINS,
|
||||||
ModCandidate,
|
ModCandidate,
|
||||||
PaintCan,
|
PaintCan,
|
||||||
|
ScanFileError,
|
||||||
|
_add_decoded_pixels,
|
||||||
analyze_colors,
|
analyze_colors,
|
||||||
|
build_variant_mask,
|
||||||
discover_paint_cans,
|
discover_paint_cans,
|
||||||
discover_mods,
|
discover_mods,
|
||||||
main,
|
main,
|
||||||
@@ -168,6 +174,117 @@ class ScannerTests(unittest.TestCase):
|
|||||||
[(color.paint_can.item_id, color.percentage) for color in colors],
|
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_dark_chromatic_shading_preserves_the_paint_hue(self) -> None:
|
||||||
|
paints = (
|
||||||
|
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
|
||||||
|
PaintCan("Base.PaintBlue", "Paint - Blue", (0.35, 0.35, 0.80), 0.1),
|
||||||
|
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
|
||||||
|
PaintCan("Base.PaintGrey", "Paint - Gray", (0.50, 0.50, 0.50), 0.1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "shaded-shell.png"
|
||||||
|
image = Image.new("RGB", (10, 1))
|
||||||
|
image.putdata([(20, 50, 30)] * 8 + [(20, 20, 20)] * 2)
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
colors = analyze_colors(image_path, paints, preserve_chromatic_hue=True)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Base.PaintGreen", 80.0), ("Base.PaintBlack", 20.0)],
|
||||||
|
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dark_chromatic_pixels_remain_conservative_without_a_body_mask(self) -> None:
|
||||||
|
paints = (
|
||||||
|
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
|
||||||
|
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "dark-tinted-trim.png"
|
||||||
|
Image.new("RGB", (1, 1), (20, 50, 30)).save(image_path)
|
||||||
|
|
||||||
|
colors = analyze_colors(image_path, paints)
|
||||||
|
|
||||||
|
self.assertEqual("Base.PaintBlack", colors[0].paint_can.item_id)
|
||||||
|
|
||||||
|
def test_variant_mask_excludes_fixed_shell_trim_from_paint_percentages(self) -> None:
|
||||||
|
paints = (
|
||||||
|
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
|
||||||
|
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
|
||||||
|
PaintCan("Base.PaintRed", "Paint - Red", (0.63, 0.10, 0.10), 0.1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
green_path = root / "shell-green.png"
|
||||||
|
red_path = root / "shell-red.png"
|
||||||
|
green = Image.new("RGB", (10, 1))
|
||||||
|
green.putdata([(20, 50, 30)] * 8 + [(20, 20, 20)] * 2)
|
||||||
|
green.save(green_path)
|
||||||
|
red = Image.new("RGB", (10, 1))
|
||||||
|
red.putdata([(80, 20, 20)] * 8 + [(20, 20, 20)] * 2)
|
||||||
|
red.save(red_path)
|
||||||
|
|
||||||
|
mask = build_variant_mask((green_path, red_path))
|
||||||
|
colors = analyze_colors(
|
||||||
|
green_path,
|
||||||
|
paints,
|
||||||
|
pixel_mask=mask,
|
||||||
|
preserve_chromatic_hue=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Base.PaintGreen", 100.0)],
|
||||||
|
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_variant_mask_is_not_used_when_sibling_uv_layouts_do_not_overlap(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
left_path = root / "left.png"
|
||||||
|
right_path = root / "right.png"
|
||||||
|
left = Image.new("RGBA", (10, 1), (0, 0, 0, 0))
|
||||||
|
left.putdata([(20, 50, 30, 255)] * 5 + [(0, 0, 0, 0)] * 5)
|
||||||
|
left.save(left_path)
|
||||||
|
right = Image.new("RGBA", (10, 1), (0, 0, 0, 0))
|
||||||
|
right.putdata([(0, 0, 0, 0)] * 5 + [(80, 20, 20, 255)] * 5)
|
||||||
|
right.save(right_path)
|
||||||
|
|
||||||
|
self.assertIsNone(build_variant_mask((left_path, right_path)))
|
||||||
|
|
||||||
|
def test_variant_mask_is_not_used_for_small_livery_differences(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
plain_path = root / "plain.png"
|
||||||
|
decal_path = root / "decal.png"
|
||||||
|
Image.new("RGB", (100, 1), (20, 50, 30)).save(plain_path)
|
||||||
|
decal = Image.new("RGB", (100, 1), (20, 50, 30))
|
||||||
|
decal.putdata([(80, 20, 20)] * 10 + [(20, 50, 30)] * 90)
|
||||||
|
decal.save(decal_path)
|
||||||
|
|
||||||
|
self.assertIsNone(build_variant_mask((plain_path, decal_path)))
|
||||||
|
|
||||||
|
def test_variant_mask_rejects_excessive_skin_count_before_decoding(self) -> None:
|
||||||
|
missing_paths = tuple(
|
||||||
|
Path(f"missing-{index}.png") for index in range(MAX_VARIANT_MASK_SKINS + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(build_variant_mask(missing_paths))
|
||||||
|
|
||||||
|
def test_scan_rejects_aggregate_decoded_pixel_budget(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ScanFileError, "decoded-pixel safety limit"):
|
||||||
|
_add_decoded_pixels(MAX_SCAN_DECODED_PIXELS - 1, 2)
|
||||||
|
|
||||||
|
def test_png_metadata_value_error_is_normalized(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
texture = Path(temporary_directory) / "metadata.png"
|
||||||
|
texture.write_bytes(b"placeholder")
|
||||||
|
with patch(
|
||||||
|
"scanner.scan_ki5_workshop.Image.open",
|
||||||
|
side_effect=ValueError("compressed metadata is too large"),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(ScanFileError, "Could not decode PNG"):
|
||||||
|
analyze_colors(texture, self.TEST_PAINTS)
|
||||||
|
|
||||||
def test_ignores_fully_transparent_pixels(self) -> None:
|
def test_ignores_fully_transparent_pixels(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
image_path = Path(temporary_directory) / "alpha.png"
|
image_path = Path(temporary_directory) / "alpha.png"
|
||||||
@@ -252,6 +369,53 @@ class ScannerTests(unittest.TestCase):
|
|||||||
self.assertIn("B42 paint cans: 3", console)
|
self.assertIn("B42 paint cans: 3", console)
|
||||||
self.assertIn("1 mods, 1 cars, 1 textures", console)
|
self.assertIn("1 mods, 1 cars, 1 textures", console)
|
||||||
|
|
||||||
|
def test_scan_uses_sibling_skins_to_ignore_fixed_shell_trim(self) -> None:
|
||||||
|
paints = (
|
||||||
|
PaintCan("Base.PaintBlack", "Paint - Black", (0.20, 0.20, 0.20), 0.1),
|
||||||
|
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
|
||||||
|
PaintCan("Base.PaintRed", "Paint - Red", (0.63, 0.10, 0.10), 0.1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
mod_root = root / "999/mods/shadedCar"
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/mod.info",
|
||||||
|
"name=Shaded Car\nid=shadedCar\nrequire=damnlib\ncategory=vehicle\n",
|
||||||
|
)
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/media/scripts/vehicles/car.txt",
|
||||||
|
"""
|
||||||
|
module Base {
|
||||||
|
vehicle ShadedCar {
|
||||||
|
engineForce = 4000,
|
||||||
|
skin { texture = Vehicles/ShadedCar_Green, }
|
||||||
|
skin { texture = Vehicles/ShadedCar_Red, }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
texture_root = mod_root / "common/media/textures/Vehicles"
|
||||||
|
texture_root.mkdir(parents=True)
|
||||||
|
green = Image.new("RGB", (10, 1))
|
||||||
|
green.putdata([(20, 50, 30)] * 8 + [(20, 20, 20)] * 2)
|
||||||
|
green.save(texture_root / "ShadedCar_Green.png")
|
||||||
|
red = Image.new("RGB", (10, 1))
|
||||||
|
red.putdata([(80, 20, 20)] * 8 + [(20, 20, 20)] * 2)
|
||||||
|
red.save(texture_root / "ShadedCar_Red.png")
|
||||||
|
|
||||||
|
report = scan_workshop(root, paints)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
[("Base.PaintGreen", 100.0)],
|
||||||
|
[("Base.PaintRed", 100.0)],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[(paint.paint_can.item_id, paint.percentage) for paint in texture.paints]
|
||||||
|
for texture in report.textures
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
|
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
root = Path(temporary_directory)
|
root = Path(temporary_directory)
|
||||||
|
|||||||