2026-10-02 17:02:33 +00:00
<#
. 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 ) .
2026-10-02 17:36:28 +00:00
Une coiffure dont le SkeletalMesh existe deja est sautee ( rien n ' est ecrase ) , sauf avec -Rebuild : la coiffure et son
squelette precedents sont alors deplaces dans < backup > \ < nom > \ precedente_ < date > avant reconstruction .
Options de LPHairFromStaticMesh par coiffure dans le JSON : margin , scale , offset_z , offset_y , skin ( transfer | rigid ) ,
gap ( cm ) .
2026-10-02 17:02:33 +00:00
. 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 ,
2026-10-02 17:36:28 +00:00
[ string ] $Only = '' ,
[ switch ] $Rebuild
2026-10-02 17:02:33 +00:00
)
$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' }
}
2026-10-03 02:07:11 +00:00
# Chemin disque d'un chemin de paquet : /Game/X -> Content/X ; /<Plugin>/X -> Mods/<Plugin>/Content/X (mod, H3)
# ou Plugins/<Plugin>/Content/X.
function Get-DiskPath([string ] $PackagePath ) {
$parts = $PackagePath . TrimStart ( '/' ) . Split ( '/' , 2 )
if ( $parts [ 0 ] -eq 'Game' ) { return Join-Path $Root ( 'Content\' + ( $parts [ 1 ] -replace '/' , '\' ) ) }
foreach ( $base in 'Mods' , 'Plugins' ) {
$pluginDir = Join-Path $Root " $base \ $( $parts [ 0 ] ) "
if ( Test-Path $pluginDir ) { return Join-Path $pluginDir ( 'Content\' + ( $parts [ 1 ] -replace '/' , '\' ) ) }
}
throw " Dossier de contenu introuvable pour $PackagePath "
}
2026-10-02 17:02:33 +00:00
$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 }
2026-10-03 02:07:11 +00:00
$existing = ( Get-DiskPath $meshPath ) + '.uasset'
2026-10-02 17:36:28 +00:00
if ( Test-Path $existing ) {
if ( -not $Rebuild ) { $report + = " $name : $meshPath existe deja, sautee (-Rebuild pour la refaire) " ; continue }
# -Rebuild : coiffure et squelette precedents deplaces vers la sauvegarde (materiaux et textures mis a jour sur place).
$previous = Join-Path $dir ( 'precedente_' + ( Get-Date -Format 'yyyyMMdd_HHmmss' ) )
New-Item -ItemType Directory -Force $previous | Out-Null
Move-Item $existing $previous
Move-Item ( $existing -replace '\.uasset$' , '_Skeleton.uasset' ) $previous -ErrorAction SilentlyContinue
}
2026-10-02 17:02:33 +00:00
$common = @ ( " -Static= $( $h . mesh ) " , " -FitHead= $( $h . head ) " , " -Out= $meshPath " )
if ( $h . margin ) { $common + = " -Margin= $( $h . margin ) " }
2026-10-02 17:36:28 +00:00
foreach ( $k in 'scale' , 'offset_z' , 'offset_y' , 'skin' , 'gap' ) { if ( $h . $k ) { $common + = ( '-' + @ { scale = 'Scale' ; offset_z = 'OffsetZ' ; offset_y = 'OffsetY' ; skin = 'Skin' ; gap = 'Gap' } [ $k ] + '=' + $h . $k ) } }
2026-10-02 17:02:33 +00:00
$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.
2026-10-03 02:07:11 +00:00
$workDir = Get-DiskPath $work
2026-10-02 17:02:33 +00:00
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 ) " ,
2026-10-02 17:36:28 +00:00
'// Modifications (Tools/HairPipeline.ps1) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), ajustee a la tete de Bridget, materiaux DazHair.' ,
2026-10-02 17:02:33 +00:00
" 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 ) )
2026-10-03 02:07:11 +00:00
# Credits : fichier du jeu par defaut ; un mod (H3) declare le sien ("credits", relatif a la racine).
$credits = Join-Path $Root $ ( if ( $cfg . credits ) { $cfg . credits } else { 'Licenses\Free_3D_Models_Credits.txt' } )
if ( -not ( Test-Path $credits ) ) { New-Item -ItemType File -Force $credits | Out-Null }
2026-10-02 17:02:33 +00:00
if ( -not ( Select-String -LiteralPath $credits -SimpleMatch $h . url -Quiet ) ) {
2026-10-02 17:36:28 +00:00
Add-Content -LiteralPath $credits -Value ( " - $credit . Changes: skinned to the Genesis 8 skeleton by weight transfer from the body (head, neck, chest), fitted to the Bridget head, DazHair materials ( $meshPath *). " ) -Encoding utf8
2026-10-02 17:02:33 +00:00
}
$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