Feature Complete Shipping V1.0

This commit is contained in:
2026-08-12 22:57:52 -04:00
parent 19e4ac0cce
commit 56b6a46811
30 changed files with 9934 additions and 9361 deletions
BIN
View File
Binary file not shown.

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
File diff suppressed because it is too large Load Diff
@@ -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"
}
+10
View File
@@ -0,0 +1,10 @@
module PaintMyKI5
{
item Sandpaper
{
DisplayCategory = Tool,
ItemType = base:normal,
Weight = 0.1,
Icon = Sandpaper,
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+2 -2
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+8 -2
View File
@@ -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:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+2 -2
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+209 -21
View File
@@ -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,26 +663,116 @@ def _nearest_paint(
)[0] )[0]
def _read_rgba_png(texture_path: Path) -> Image.Image:
try:
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
image = Image.open(texture_path, formats=("PNG",))
with image:
if image.format != "PNG":
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
width, height = image.size
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
raise ScanFileError(
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
)
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( def analyze_colors(
texture_path: Path, texture_path: Path,
paint_cans: Sequence[PaintCan], paint_cans: Sequence[PaintCan],
*,
pixel_mask: bytes | None = None,
preserve_chromatic_hue: bool = False,
) -> list[PaintPercentage]: ) -> list[PaintPercentage]:
"""Map non-transparent PNG pixels to their nearest available B42 paint cans.""" """Map non-transparent PNG pixels to their nearest available B42 paint cans."""
if not paint_cans: if not paint_cans:
raise ValueError("The B42 paint palette is empty") raise ValueError("The B42 paint palette is empty")
palette_labs = tuple((paint, _srgb_to_lab(paint.rgb)) for paint in paint_cans) palette_colors = tuple(
with warnings.catch_warnings(): (paint, _srgb_to_lab(paint.rgb), colorsys.rgb_to_hsv(*paint.rgb))
warnings.simplefilter("error", Image.DecompressionBombWarning) for paint in paint_cans
image = Image.open(texture_path, formats=("PNG",)) )
with image: rgba = _read_rgba_png(texture_path)
if image.format != "PNG": with rgba:
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}") width, height = rgba.size
width, height = image.size if pixel_mask is not None:
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS: if len(pixel_mask) != width * height:
raise ScanFileError( raise ValueError("Variant mask dimensions do not match the texture")
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}" mask = Image.frombytes("L", (width, height), pixel_mask)
) rgba.putalpha(ImageChops.multiply(rgba.getchannel("A"), mask))
rgba = image.convert("RGBA")
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,
+161
View File
@@ -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)
+100 -1
View File
@@ -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()
+164
View File
@@ -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)