<# .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 \\src ; copie le FBX et les textures sous des noms sans espaces ; 2. importe le FBX en maillages statiques (Tools/HairImportFbx.py) dans /_Work_ ; 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_ deplace vers \\work (jamais supprime) ; 4. Modules//Hairstyles/.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), sauf avec -Rebuild : la coiffure et son squelette precedents sont alors deplaces dans \\precedente_ avant reconstruction. Options de LPHairFromStaticMesh par coiffure dans le JSON : margin, scale, offset_z, offset_y, skin (transfer|rigid), gap (cm). Mod d'assets (H3b) : avec "mod": "" et "folder": "//Hairs", les sources ModSources\ sont montees dans Mods\ pendant le travail (Tools/ModSource.ps1) puis remises en place ; les .lphair et les credits vont dans ModSources\\Module ; -Rebuild deplace aussi les textures du mod vers la sauvegarde (pas de remplacement de texture dans un plugin). Le mod se cuit ensuite avec Tools/BuildMod.ps1. .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 = '', [switch] $Rebuild ) $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 . (Join-Path $PSScriptRoot 'ModSource.ps1') $cfg = Get-Content $Config -Raw | ConvertFrom-Json $backup = $cfg.backup $folder = $cfg.folder $mod = $cfg.mod 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' } } # Chemin disque d'un chemin de paquet : /Game/X -> Content/X ; //X -> Mods//Content/X (mod, H3) # ou Plugins//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" } $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/.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 = @() if ($mod) { if ($folder -notlike "/$mod/*") { throw "folder ($folder) doit etre dans /$mod/" } Mount-ModSource $Root $mod | Out-Null } try { 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 } $existing = (Get-DiskPath $meshPath) + '.uasset' 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 if ($mod) { foreach ($suffix in '_Diffuse', '_StrandsOpacity', '_CapOpacity') { Move-Item ($existing -replace '\.uasset$', "$suffix.uasset") $previous -ErrorAction SilentlyContinue } } } $common = @("-Static=$($h.mesh)", "-FitHead=$($h.head)", "-Out=$meshPath") if ($h.margin) { $common += "-Margin=$($h.margin)" } 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) } } $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 = Get-DiskPath $work if (Test-Path $workDir) { Move-Item $workDir (Join-Path $dir 'work') -Force } # Declaration et credits. # Mod (H3b) : module texte du mod, dans ses sources (montees dans Mods\ pendant le travail). $moduleDir = if ($mod) { Join-Path $Root "Mods\$mod\Module" } else { Join-Path $Root "Content\Modules\$($cfg.module)" } $lphairDir = Join-Path $moduleDir '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) : liee au squelette Genesis 8 par transfert de poids depuis le corps (tete, cou, poitrine), 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 : fichier du jeu, ou Module\CREDITS.txt d'un mod. $credits = if ($mod) { Join-Path $moduleDir 'CREDITS.txt' } else { Join-Path $Root 'Licenses\Free_3D_Models_Credits.txt' } if (-not (Test-Path $credits)) { New-Item -ItemType File -Force $credits | Out-Null } if (-not (Select-String -LiteralPath $credits -SimpleMatch $h.url -Quiet)) { 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 } $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 } } finally { if ($mod) { Dismount-ModSource $Root $mod } } $reportFile = Join-Path $backup "rapport_$Step.txt" $report | Set-Content $reportFile -Encoding utf8 Write-Host "`n=== Bilan ($Step) : $reportFile" $report | Write-Host