235 lines
16 KiB
PowerShell
235 lines
16 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Chaine industrialisee de conversion des coiffures (Docs/Migration/13_Protocole_Coiffure.md, section 7).
|
|
|
|
.DESCRIPTION
|
|
Pilotee par un fichier JSON (un lot de coiffures). Deux etapes :
|
|
|
|
-Step Inspect : pour chaque coiffure
|
|
1. extrait l'archive (zips imbriques compris) dans <backup>\<nom>\src ; copie le FBX et les textures sous des
|
|
noms sans espaces ;
|
|
2. importe le FBX en maillages statiques (Tools/HairImportFbx.py) dans <folder>/_Work_<nom> ;
|
|
3. propose : maillage de la coiffure, tete de reference (nom contenant "head"), emplacements de calotte (nom
|
|
contenant base, cap ou scalp), texture de couleur et masque des meches (analyse des images : niveaux de gris,
|
|
proportion de pixels noirs/blancs, orientation) ;
|
|
4. ecrit la proposition dans le fichier JSON (champs "auto" remplaces) et un rapport.
|
|
-> relire le rapport, corriger le JSON si besoin.
|
|
|
|
-Step Apply : pour chaque coiffure inspectee
|
|
1. LPHairFromStaticMesh (essai a blanc, controle des echelles, puis conversion) ;
|
|
2. Tools/HairMaterials.py (textures, materiaux DazHair, controle des dependances) ;
|
|
3. dossier _Work_<nom> deplace vers <backup>\<nom>\work (jamais supprime) ;
|
|
4. Modules/<module>/Hairstyles/<nom>.lphair (attribution comprise) ; credits dans
|
|
Licenses/Free_3D_Models_Credits.txt (une ligne par coiffure, sans doublon) ;
|
|
puis Tools/BuildCollections.py et un bilan. Le build et le controle en jeu restent manuels (protocole, etapes 6 et 7).
|
|
|
|
Une coiffure dont le SkeletalMesh existe deja est sautee (rien n'est ecrase).
|
|
|
|
.EXAMPLE
|
|
.\Tools\HairPipeline.ps1 -Config Tools\hairs\HAIRS1.json -Step Inspect
|
|
.\Tools\HairPipeline.ps1 -Config Tools\hairs\HAIRS1.json -Step Apply
|
|
#>
|
|
param(
|
|
[Parameter(Mandatory)] [string] $Config,
|
|
[Parameter(Mandatory)] [ValidateSet('Inspect', 'Apply')] [string] $Step,
|
|
[string] $Only = ''
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$Root = Split-Path $PSScriptRoot -Parent
|
|
$Proj = Join-Path $Root 'LifePlay.uproject'
|
|
$UE = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
|
|
$Tools = ($PSScriptRoot -replace '\\', '/')
|
|
Add-Type -AssemblyName System.Drawing
|
|
|
|
$cfg = Get-Content $Config -Raw | ConvertFrom-Json
|
|
$backup = $cfg.backup
|
|
$folder = $cfg.folder
|
|
New-Item -ItemType Directory -Force $backup | Out-Null
|
|
|
|
function Invoke-UE([string[]] $Arguments, [string] $Filter) {
|
|
$out = & $UE $Proj @Arguments -unattended -nopause -nosplash 2>&1 | ForEach-Object { "$_" }
|
|
$out | Where-Object { $_ -match $Filter -and $_ -notmatch 'LogInit: Display' }
|
|
}
|
|
|
|
$SevenZip = 'C:\Program Files\7-Zip\7z.exe'
|
|
function Expand-One([string] $Archive, [string] $Dest) {
|
|
# 7-Zip lit zip, rar et 7z (Pocolov 03 : source/Hair3.rar) ; Expand-Archive en secours pour les zip.
|
|
if (Test-Path $SevenZip) { & $SevenZip x -y "-o$Dest" $Archive | Out-Null }
|
|
else { Expand-Archive -LiteralPath $Archive -DestinationPath $Dest -Force }
|
|
}
|
|
|
|
function Expand-All([string] $Archive, [string] $Dest) {
|
|
Expand-One $Archive $Dest
|
|
# Archives imbriquees (Pocolov : source/<nom>.zip ou .rar)
|
|
for ($pass = 0; $pass -lt 3; $pass++) {
|
|
$inner = Get-ChildItem $Dest -Recurse -Include *.zip, *.rar, *.7z | Where-Object { -not (Test-Path ($_.FullName + '.done')) }
|
|
if (-not $inner) { break }
|
|
foreach ($z in $inner) {
|
|
Expand-One $z.FullName (Join-Path $z.DirectoryName ($z.BaseName -replace '\s', '_'))
|
|
New-Item -ItemType File ($z.FullName + '.done') | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
# Statistiques d'une image : saturation moyenne, part de pixels tres sombres / tres clairs, part sombre du bas.
|
|
function Get-ImageStats([string] $Path) {
|
|
try { $bmp = [System.Drawing.Bitmap]::FromFile($Path) } catch { return $null }
|
|
try {
|
|
$step = [Math]::Max(1, [int]([Math]::Sqrt($bmp.Width * $bmp.Height / 20000)))
|
|
$n = 0; $sat = 0.0; $dark = 0; $light = 0; $bottomDark = 0; $bottomN = 0; $sideDark = 0; $sideN = 0
|
|
for ($y = 0; $y -lt $bmp.Height; $y += $step) {
|
|
for ($x = 0; $x -lt $bmp.Width; $x += $step) {
|
|
$c = $bmp.GetPixel($x, $y)
|
|
$max = [Math]::Max($c.R, [Math]::Max($c.G, $c.B)); $min = [Math]::Min($c.R, [Math]::Min($c.G, $c.B))
|
|
$sat += ($max - $min) / 255.0
|
|
$l = ($c.R + $c.G + $c.B) / 765.0
|
|
if ($l -lt 0.15) { $dark++ } elseif ($l -gt 0.85) { $light++ }
|
|
if ($y -gt $bmp.Height * 0.8) { $bottomN++; if ($l -lt 0.3) { $bottomDark++ } }
|
|
if ($x -lt $bmp.Width * 0.05 -or $x -ge $bmp.Width * 0.95) { $sideN++; if ($l -lt 0.3) { $sideDark++ } }
|
|
$n++
|
|
}
|
|
}
|
|
[pscustomobject]@{
|
|
File = $Path; Width = $bmp.Width; Height = $bmp.Height
|
|
Saturation = [Math]::Round($sat / $n, 3)
|
|
Binary = [Math]::Round(($dark + $light) / $n, 3)
|
|
Dark = [Math]::Round($dark / $n, 3)
|
|
BottomDark = if ($bottomN) { [Math]::Round($bottomDark / $bottomN, 3) } else { 0 }
|
|
SideDark = if ($sideN) { [Math]::Round($sideDark / $sideN, 3) } else { 0 }
|
|
}
|
|
} finally { $bmp.Dispose() }
|
|
}
|
|
|
|
function Select-Textures($all, [string[]] $StrandSlots) {
|
|
# Archive avec une tete complete (Pocolov 03 : peau, yeux, sourcils) : ne garder que les textures nommees "hair"
|
|
# s'il y en a.
|
|
$stats = @($all | Where-Object { (Split-Path $_.File -Leaf) -match 'hair' })
|
|
if (-not $stats) { $stats = $all }
|
|
# Masque : niveaux de gris, majoritairement noir/blanc. Masque des meches : vertical (hauteur > largeur ; un masque
|
|
# carre est en general la lisiere de la calotte, Pocolov 16) ; a
|
|
# egalite, celui dont le nom contient un emplacement des meches (Hair_01 -> Hair_01A1), puis l'ordre alphabetique
|
|
# (A1 avant A2). Couleur : le reste ; de preference grise (teintee par HairColor), sinon la plus grande.
|
|
$masks = $stats | Where-Object { $_.Saturation -lt 0.05 -and $_.Binary -gt 0.55 }
|
|
$matchesSlot = { param($f) foreach ($s in $StrandSlots) { if ($s -and (Split-Path $f -Leaf) -match [regex]::Escape($s)) { return $true } }; $false }
|
|
# Puis bords lateraux sombres (carte de meches a bords transparents : Hair_01A1 plutot que Hair_01A, Pocolov 03).
|
|
$strands = $masks | Where-Object { $_.Height -gt $_.Width } | Sort-Object @{ e = { & $matchesSlot $_.File }; Descending = $true }, @{ e = { $_.SideDark -gt 0.5 }; Descending = $true }, @{ e = { Split-Path $_.File -Leaf } } | Select-Object -First 1
|
|
$colors = $stats | Where-Object { $_ -notin $masks }
|
|
$diffuse = $colors | Sort-Object @{ e = { $_.Saturation -lt 0.05 }; Descending = $true }, @{ e = { $_.Height * $_.Width }; Descending = $true } | Select-Object -First 1
|
|
[pscustomobject]@{ Diffuse = $diffuse; Strands = $strands }
|
|
}
|
|
|
|
$report = @()
|
|
foreach ($h in $cfg.hairs) {
|
|
if ($Only -and $h.name -ne $Only) { continue }
|
|
$name = $h.name
|
|
$dir = Join-Path $backup $name
|
|
$meshPath = "$folder/$name"
|
|
$work = "$folder/_Work_$name"
|
|
|
|
if ($Step -eq 'Inspect') {
|
|
Write-Host "== $name : inspection"
|
|
if (-not (Test-Path $h.archive)) { $report += "$name : archive absente ($($h.archive))"; continue }
|
|
Remove-Item (Join-Path $dir 'src') -Recurse -Force -ErrorAction SilentlyContinue
|
|
Expand-All $h.archive (Join-Path $dir 'src')
|
|
$norm = Join-Path $dir 'norm'
|
|
New-Item -ItemType Directory -Force $norm | Out-Null
|
|
$fbx = Get-ChildItem (Join-Path $dir 'src') -Recurse -Filter *.fbx | Sort-Object Length -Descending | Select-Object -First 1
|
|
if (-not $fbx) { $report += "$name : aucun FBX dans l'archive"; continue }
|
|
Copy-Item $fbx.FullName (Join-Path $norm 'hair.fbx') -Force
|
|
$i = 0; $stats = @()
|
|
foreach ($t in Get-ChildItem (Join-Path $dir 'src') -Recurse -Include *.png, *.jpg, *.jpeg, *.tga, *.bmp) {
|
|
$i++
|
|
$copy = Join-Path $norm ("tex{0:D2}_{1}{2}" -f $i, ($t.BaseName -replace '[^A-Za-z0-9_-]', '_'), $t.Extension.ToLower())
|
|
Copy-Item $t.FullName $copy -Force
|
|
$s = Get-ImageStats $copy
|
|
if ($s) { $stats += $s }
|
|
}
|
|
$json = Join-Path $dir 'import.json'
|
|
$fbxArg = (Join-Path $norm 'hair.fbx') -replace '\\', '/'
|
|
Invoke-UE @('-run=pythonscript', "-script=$Tools/HairImportFbx.py fbx=$fbxArg dest=$work report=$($json -replace '\\','/')") 'HAIRIMPORT' | Write-Host
|
|
if (-not (Test-Path $json)) { $report += "$name : import du FBX echoue"; continue }
|
|
$imp = Get-Content $json -Raw | ConvertFrom-Json
|
|
$head = $imp.meshes | Where-Object { $_.name -match 'head' } | Sort-Object triangles -Descending | Select-Object -First 1
|
|
$hairMeshes = @($imp.meshes | Where-Object { $_ -ne $head })
|
|
$hair = $hairMeshes | Sort-Object triangles -Descending | Select-Object -First 1
|
|
# Champs detectes : ceux valant "auto" a la premiere inspection (memorises dans auto_fields, recalcules a chaque
|
|
# inspection) ; une valeur saisie a la main n'est jamais remplacee.
|
|
if (-not $h.auto_fields) { $h | Add-Member auto_fields @('mesh', 'head', 'cap_slots', 'diffuse', 'strands_opacity' | Where-Object { $h.$_ -eq 'auto' }) -Force }
|
|
$auto = { param($f) $f -in $h.auto_fields }
|
|
if (& $auto 'mesh') { $h.mesh = $hair.path }
|
|
if (& $auto 'head') { $h.head = if ($head) { $head.path } else { $hair.path } }
|
|
# Associations materiau -> textures declarees dans le FBX (Tools/FbxMaterialMap.py), pour le rapport et le cas
|
|
# d'un emplacement unique.
|
|
$fbxMap = $null
|
|
try { $fbxMap = (& python (Join-Path $PSScriptRoot 'FbxMaterialMap.py') (Join-Path $norm 'hair.fbx')) -join "`n" | ConvertFrom-Json } catch { }
|
|
$findCopy = { param($fileName) if ($fileName) { $b = [IO.Path]::GetFileNameWithoutExtension($fileName) -replace '[^A-Za-z0-9_-]', '_'; ($stats | Where-Object { (Split-Path $_.File -Leaf) -match ('^tex\d+_' + [regex]::Escape($b) + '\.') } | Select-Object -First 1).File } }
|
|
# Calotte : emplacement nomme base/cap/scalp, seulement s'il reste au moins un autre emplacement pour les meches.
|
|
if (& $auto 'cap_slots') { $h.cap_slots = if ($hair.slots.Count -gt 1) { (@($hair.slots | Where-Object { $_ -match 'base|cap|scalp' }) -join ',') } else { '' } }
|
|
$strandSlots = @($hair.slots | Where-Object { $_ -notin ($h.cap_slots -split ',') })
|
|
$sel = Select-Textures $stats $strandSlots
|
|
if (& $auto 'diffuse') { $h.diffuse = if ($sel.Diffuse) { $sel.Diffuse.File -replace '\\', '/' } else { '' } }
|
|
if (& $auto 'strands_opacity') {
|
|
# Emplacement unique (Pocolov 11) : masque declare par le FBX pour cet emplacement ; sinon masque vertical.
|
|
$declared = if ($strandSlots.Count -eq 1 -and $hair.slots.Count -eq 1 -and $fbxMap) { & $findCopy $fbxMap.($strandSlots[0]).TransparentColor }
|
|
$h.strands_opacity = if ($declared) { $declared -replace '\\', '/' } elseif ($sel.Strands) { $sel.Strands.File -replace '\\', '/' } else { '' }
|
|
}
|
|
$notes = @()
|
|
if ($hairMeshes.Count -gt 1) { $notes += "$($hairMeshes.Count) maillages hors tete : seul '$($hair.name)' est converti" }
|
|
if (-not $head) { $notes += 'pas de tete de reference : ajustement sur la coiffure elle-meme (margin 1.0)'; if (-not $h.margin) { $h | Add-Member margin '1.0' -Force } }
|
|
if (-not $h.diffuse -or -not $h.strands_opacity) { $notes += 'textures non identifiees : renseigner diffuse / strands_opacity' }
|
|
$h | Add-Member inspected $true -Force
|
|
$report += "$name : coiffure $($hair.name) ($($hair.triangles) tri., emplacements $($hair.slots -join ',')), tete $(if ($head) { $head.name } else { '-' }), calotte [$($h.cap_slots)], couleur $(Split-Path $h.diffuse -Leaf), masque $(Split-Path $h.strands_opacity -Leaf)$(if ($notes) { ' ; ' + ($notes -join ' ; ') })"
|
|
if ($fbxMap) { $report += ($fbxMap.PSObject.Properties | ForEach-Object { " FBX $($_.Name) : " + (($_.Value.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join ', ') }) }
|
|
$report += ($stats | ForEach-Object { " texture $(Split-Path $_.File -Leaf) : $($_.Width)x$($_.Height), saturation $($_.Saturation), noir/blanc $($_.Binary), bas sombre $($_.BottomDark), bords sombres $($_.SideDark)" })
|
|
}
|
|
else {
|
|
Write-Host "== $name : application"
|
|
if (-not $h.inspected) { $report += "$name : non inspectee, sautee"; continue }
|
|
if (Test-Path (Join-Path $Root ("Content" + $meshPath.Substring(5) + '.uasset'))) { $report += "$name : $meshPath existe deja, sautee"; continue }
|
|
$common = @("-Static=$($h.mesh)", "-FitHead=$($h.head)", "-Out=$meshPath")
|
|
if ($h.margin) { $common += "-Margin=$($h.margin)" }
|
|
foreach ($k in 'scale', 'offset_z', 'offset_y') { if ($h.$k) { $common += ('-' + @{ scale = 'Scale'; offset_z = 'OffsetZ'; offset_y = 'OffsetY' }[$k] + '=' + $h.$k) } }
|
|
$dry = Invoke-UE (@('-run=LPHairFromStaticMesh') + $common + '-DryRun') 'LogLPHairFromStatic: (Warning|Error)'
|
|
$dry | Write-Host
|
|
$scaleLine = $dry | Where-Object { $_ -match 'echelle X=([\d.]+) Y=([\d.]+)' } | Select-Object -First 1
|
|
if (-not $scaleLine) { $report += "$name : essai a blanc en echec"; continue }
|
|
$sx = [double]$Matches[1]; $sy = [double]$Matches[2]
|
|
if ($sx -lt 0.6 -or $sx -gt 1.8 -or $sy -lt 0.6 -or $sy -gt 1.8) { $report += "$name : echelle hors limites (X=$sx Y=$sy), sautee : verifier la tete de reference"; continue }
|
|
$conv = Invoke-UE (@('-run=LPHairFromStaticMesh') + $common) 'LogLPHairFromStatic: (Warning|Error)'
|
|
if (-not ($conv -match 'sauvegarde :.*' + [regex]::Escape($name) + '\.uasset')) { $conv | Write-Host; $report += "$name : conversion en echec"; continue }
|
|
$placed = ($conv | Where-Object { $_ -match 'bornes' }) -replace '.*bornes ', ''
|
|
$mat = Invoke-UE @('-run=pythonscript', "-script=$Tools/HairMaterials.py mesh=$meshPath diffuse=$($h.diffuse) strands_opacity=$($h.strands_opacity) cap_opacity=$($h.cap_opacity) cap=$($h.cap_slots) thicken=$($h.thicken)") 'HAIRMAT'
|
|
$mat | Write-Host
|
|
$depOk = [bool]($mat -match 'dependances.*-> OK')
|
|
# Nettoyage : dossier de travail deplace vers la sauvegarde.
|
|
$workDir = Join-Path $Root ("Content" + $work.Substring(5))
|
|
if (Test-Path $workDir) { Move-Item $workDir (Join-Path $dir 'work') -Force }
|
|
# Declaration et credits.
|
|
$lphairDir = Join-Path $Root "Content\Modules\$($cfg.module)\Hairstyles"
|
|
New-Item -ItemType Directory -Force $lphairDir | Out-Null
|
|
$credit = "$($h.title) by $($h.author), $($h.license), $($h.url)"
|
|
$lines = @(
|
|
"// $($h.title) - $($h.author) - licence $($h.license)",
|
|
"// Source : $($h.url)",
|
|
'// Modifications (Tools/HairPipeline.ps1) : rattachee au squelette Genesis 8 (os head), ajustee a la tete de Bridget, materiaux DazHair.',
|
|
"HAIRSTYLE: $meshPath.$name", "SEX: $($h.sex)", 'DAZ: true')
|
|
[IO.File]::WriteAllText((Join-Path $lphairDir "$($name.ToLower()).lphair"), ($lines -join "`r`n") + "`r`n", (New-Object Text.UTF8Encoding $false))
|
|
$credits = Join-Path $Root 'Licenses\Free_3D_Models_Credits.txt'
|
|
if (-not (Select-String -LiteralPath $credits -SimpleMatch $h.url -Quiet)) {
|
|
Add-Content -LiteralPath $credits -Value ("- $credit . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials ($meshPath*).") -Encoding utf8
|
|
}
|
|
$report += "$name : OK, echelle X=$sx Y=$sy, bornes $placed, dependances $(if ($depOk) { 'OK' } else { 'A VERIFIER' })"
|
|
}
|
|
}
|
|
|
|
if ($Step -eq 'Inspect') {
|
|
$cfg | ConvertTo-Json -Depth 6 | Set-Content $Config -Encoding utf8
|
|
}
|
|
else {
|
|
Invoke-UE @('-run=pythonscript', "-script=$Tools/BuildCollections.py") 'collection\(s\) ecrite' | Write-Host
|
|
}
|
|
$reportFile = Join-Path $backup "rapport_$Step.txt"
|
|
$report | Set-Content $reportFile -Encoding utf8
|
|
Write-Host "`n=== Bilan ($Step) : $reportFile"
|
|
$report | Write-Host
|