HAIRS1 : chaine industrialisee de conversion des coiffures (HairPipeline.ps1, FbxMaterialMap.py) testee sur 5 coiffures Pocolov

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
edujiro5972 2026-10-02 19:02:33 +02:00
parent 2744242609
commit 2479a0af9e
8 changed files with 531 additions and 4 deletions

View file

@ -27,6 +27,7 @@ depuis O1). Une coiffure déjà riggée Genesis 8 (export Daz) n'a pas besoin de
| `Tools/HairImportFbx.py` | import du FBX en maillages statiques séparés, dans un dossier de travail ; affiche tailles et emplacements de matériaux |
| commandlet `LPHairFromStaticMesh` (`Source/LifePlayEditorTools`) | ajuste la coiffure sur la tête de Bridget et la convertit en `SkeletalMesh` Genesis 8 |
| `Tools/HairMaterials.py` | importe les textures, crée les matériaux `<Nom>_Cap` et `<Nom>_Strands`, les affecte, contrôle les dépendances |
| `Tools/HairPipeline.ps1`, `Tools/FbxMaterialMap.py` | chaîne industrialisée d'un lot de coiffures (§7) |
| `Tools/BuildCollections.py` | range la coiffure dans la collection `LifePlay_Cheveux` de l'éditeur |
| option de jeu `-LPVerifyHairstyles` | journalise la construction de la liste des coiffures |
@ -173,6 +174,54 @@ Puis : entrée CHG dans `Journal_Modifications.md`, commit, tags `<lot>-apres` /
- **Coiffure apportée par un mod sans recompiler le jeu** : impossible aujourd'hui (le maillage doit être cuit avec le
jeu) ; étape H3 prévue.
## 7. Chaîne industrialisée (`Tools/HairPipeline.ps1`)
Les étapes 0 à 6 sont enchaînées pour un **lot** de coiffures décrit par un fichier JSON (`Tools/hairs/<lot>.json`) :
```json
{ "module": "lp_Revival", "folder": "/Game/Heavy/New5/Hairs", "backup": "D:/Game/LifePlay_Backup_5.5/<lot>",
"hairs": [ { "name": "Pocolov15", "archive": "D:/Game/pocolov-hair-15.zip", "title": "Pocolov Hair 15",
"author": "...", "license": "CC BY 4.0", "url": "https://www.fab.com/listings/...", "sex": "F",
"mesh": "auto", "head": "auto", "cap_slots": "auto", "diffuse": "auto", "strands_opacity": "auto",
"cap_opacity": "white", "thicken": 1 } ] }
```
Options par coiffure : `margin`, `scale`, `offset_z`, `offset_y` (transmises à `LPHairFromStaticMesh`).
```powershell
.\Tools\HairPipeline.ps1 -Config Tools\hairs\<lot>.json -Step Inspect # puis relire <backup>\rapport_Inspect.txt
.\Tools\HairPipeline.ps1 -Config Tools\hairs\<lot>.json -Step Apply # [-Only <nom>] pour une seule coiffure
```
**Inspect** : extraction (zip, rar, 7z, archives imbriquées, avec 7-Zip), copie du FBX et des textures sous des noms
sans espaces, import (`HairImportFbx.py`), lecture des associations matériau → textures du FBX
(`Tools/FbxMaterialMap.py`, lecteur de FBX binaire sans SDK), puis détection des champs `auto` :
| Champ | Règle |
|---|---|
| `mesh` | plus gros maillage qui n'est pas la tête (nom quelconque : `Box001` pour Pocolov 03) |
| `head` | maillage dont le nom contient `head` ; sinon la coiffure elle-même (et `margin` 1,0) |
| `cap_slots` | emplacements nommés `base`, `cap` ou `scalp`, **seulement s'il reste un autre emplacement** pour les mèches |
| `diffuse` | parmi les textures nommées `hair` (si l'archive contient une tête complète : peau, yeux…) : non masque, grise de préférence, la plus grande |
| `strands_opacity` | masque (gris, noir/blanc) **vertical** (un masque carré est la lisière de la calotte) ; préférence au nom d'un emplacement des mèches, puis aux bords latéraux sombres (carte à bords transparents) ; emplacement unique : masque déclaré par le FBX |
Les champs détectés sont mémorisés dans `auto_fields` et recalculés à chaque inspection ; une valeur saisie à la main
n'est jamais écrasée. Le rapport liste chaque texture (taille, saturation, part noir/blanc, bas et bords sombres) et
les associations du FBX.
**Apply** : essai à blanc et contrôle des échelles (entre 0,6 et 1,8, sinon la coiffure est sautée), conversion,
matériaux (`HairMaterials.py` ; matériau de calotte créé seulement s'il sert), dossier de travail déplacé vers la
sauvegarde, `.lphair` avec attribution, ligne de crédits (sans doublon), collections, bilan. Une coiffure dont le
`SkeletalMesh` existe déjà est sautée : la chaîne peut être relancée après une interruption.
**Premier lot (HAIRS1, 2026-10-02)** : Pocolov Hair 03, 06, 11, 15, 16 (CC BY 4.0). Cas rencontrés et intégrés aux
règles : modèle dans un RAR (03) ; archive avec tête complète, textures de peau et d'yeux (03) ; maillage nommé
`Box001` (03) ; masque carré pris pour celui des mèches (16) ; emplacement unique (11) ; masques `A`/`A1` voisins (03,
15). Résultat : 5 coiffures conformes (dépendances limitées au squelette et aux matériaux), toutes ajoutées par le jeu.
**Licences** : seules les licences permettant la redistribution (CC BY, CC0) conviennent, l'archive des sources
publiée contenant `Content/Heavy`. La licence standard Fab (« personal/professional ») ne le permet pas.
## 6. Retour arrière
Retirer le fichier `.lphair` suffit à retirer la coiffure du jeu. Pour retirer aussi les assets : déplacer

View file

@ -936,3 +936,4 @@ Validation L7c/L7d (2026-10-02, parc, même cadrage) : la nuit reste claire (sol
| CHG-089 | 2026-10-02 | H2 | `Source/LifePlay/LPHairstyleSubsystem.h/.cpp` (mode base, plusieurs coiffures par fichier, `NONE`, `MORPHABLE`, vérification `-LPVerifyHairstyles`) ; nouveau `Content/Modules/vin_Base/Hairstyles/vin_Base.lphair` (126 coiffures, 89 morphs) ; `11_Audit_Optimisation.md` §16.9 | liste de base des coiffures sortie du Blueprint (demande utilisateur) ; vérifié : table et morphs reconstruits identiques au Blueprint ; build `LifePlay_Build_5.5_H2` ; validé en jeu le 2026-10-02 (nouvelle partie, liste du menu inchangée avec Pocolov17 en dernier, personnages générés, Continue) | supprimer `vin_Base.lphair` (retour à la table du Blueprint) ; `git checkout H2-avant -- Source/LifePlay` |
| CHG-090 | 2026-10-02 | H2b | `Source/LifePlay/LPHairstyleSubsystem.h/.cpp` (clés `FACIAL_HAIR`, `BODY_HAIR_M`, `BODY_HAIR_F`) ; `Content/Modules/vin_Base/Hairstyles/vin_Base.lphair` (+ 44 noms) ; `11_Audit_Optimisation.md` §16.10 | pilosité sortie du Blueprint (demande utilisateur) : listes `GI.FHairstyles`, `BHairstyles_M`, `BHairstyles` ; `GI.OtherTable` laissée (maillages absents depuis 4.27) ; vérifié : 3 listes identiques au Blueprint ; build `LifePlay_Build_5.5_H2` ; validé en jeu le 2026-10-02 (28 barbes + sans barbe dans le menu de création) | retirer les lignes `*_HAIR` de `vin_Base.lphair` ; `git checkout H2b-avant -- Source/LifePlay` |
| CHG-091 | 2026-10-02 | Doc | nouveaux `Docs/Migration/13_Protocole_Coiffure.md`, `Tools/HairImportFbx.py`, `Tools/HairMaterials.py` ; `README.md` (migration), `08_Guide_Developpeur.md`, `09_Commandes_Utiles.md` | protocole de conversion d'une coiffure (demande utilisateur) ; scripts ponctuels d'O7e rendus paramétrables ; protocole rejoué de bout en bout sur Pocolov 17 sous le nom `Pocolov17P` : bornes identiques au millimètre, dépendances OK ; essai retiré du projet (`D:\Game\LifePlay_Backup_5.5\O7e\proto_essai`) | `git revert` du commit |
| CHG-092 | 2026-10-02 | HAIRS1 | nouveaux `Tools/HairPipeline.ps1`, `Tools/FbxMaterialMap.py`, `Tools/hairs/HAIRS1.json` ; `Tools/HairImportFbx.py` (rapport JSON), `Tools/HairMaterials.py` (calotte seulement si utilisée, contrôle des dépendances) ; assets `Content/Heavy/New5/Hairs/Pocolov03/06/11/15/16*` (hors dépôt) ; `Content/Modules/lp_Revival/Hairstyles/pocolov03/06/11/15/16.lphair` ; `Licenses/Free_3D_Models_Credits.txt` ; `13_Protocole_Coiffure.md` §7 | industrialisation de la conversion (demande utilisateur) et test sur 5 coiffures Pocolov (CC BY 4.0, téléchargées avec l'accord de l'utilisateur) ; 5 cas d'archives différents intégrés aux règles ; build `LifePlay_Build_5.5_HAIRS1` : 6 coiffures ajoutées (index 126 à 131) ; contrôle visuel en jeu en attente | retirer les `.lphair` ; assets et sources dans `D:\\Game\\LifePlay_Backup_5.5\\HAIRS1` ; `git checkout HAIRS1-avant -- Tools Licenses Content/Modules` |

View file

@ -27,3 +27,8 @@ In no particular order, these are the authors of the original (most have been he
LifePlay Revival (UE 5.5) additions:
- Pocolov Hair 17 by Pocolov (Pocolov Studio 2019, artist Thang), Creative Commons Attribution 4.0 (CC BY 4.0), https://www.fab.com/listings/7f6f687d-bb13-40c6-8b69-64044ededc1d . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials (Content/Heavy/New5/Hairs/Pocolov17*, declared by Content/Modules/lp_Revival/Hairstyles/pocolov17.lphair).
- Pocolov Hair 03 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/2257098d-b77c-4eef-9132-85f1454f0ae3 . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials (/Game/Heavy/New5/Hairs/Pocolov03*).
- Pocolov Hair 06 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/14c3cd52-02f9-4bfe-bb40-b5c3acb1f428 . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials (/Game/Heavy/New5/Hairs/Pocolov06*).
- Pocolov Hair 11 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/c31c219e-bc62-4957-91c5-720440375f79 . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials (/Game/Heavy/New5/Hairs/Pocolov11*).
- Pocolov Hair 15 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/ed8e016a-0bf0-4a6c-94b0-2760083b879d . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials (/Game/Heavy/New5/Hairs/Pocolov15*).
- Pocolov Hair 16 by Pocolov (Pocolov Studio, artist Thang), CC BY 4.0, https://www.fab.com/listings/802dc956-6211-4b83-8ba1-37beb3cb338f . Changes: bound to the Genesis 8 skeleton (head bone), fitted to the Bridget head, DazHair materials (/Game/Heavy/New5/Hairs/Pocolov16*).

97
Tools/FbxMaterialMap.py Normal file
View file

@ -0,0 +1,97 @@
# Lecture des associations materiau -> textures d'un FBX binaire, sans SDK (Tools/HairPipeline.ps1).
#
# Un FBX binaire est un arbre de noeuds ; Objects contient les Material, Texture (RelativeFilename) et Video, et
# Connections les liens "OP" (texture -> propriete d'un materiau : DiffuseColor, TransparentColor, OpacityMap...) ou
# "OO" (video -> texture). Ce script les relie et ecrit, pour chaque materiau, le fichier de chaque canal.
#
# Usage : python Tools/FbxMaterialMap.py <fichier.fbx> [<sortie.json>]
# Sortie : {"<materiau>": {"DiffuseColor": "Hair_01_Gray.png", "TransparentColor": "Hair_01A1.jpg", ...}, ...}
import json
import re
import struct
import sys
import zlib
def read_nodes(data, offset, end, wide):
nodes = []
while offset < end:
if wide:
end_offset, num_props, _ = struct.unpack_from("<QQQ", data, offset)
offset += 24
else:
end_offset, num_props, _ = struct.unpack_from("<III", data, offset)
offset += 12
name_len = data[offset]
offset += 1
if end_offset == 0:
break
name = data[offset:offset + name_len].decode("ascii", "replace")
offset += name_len
props = []
for _ in range(num_props):
code = chr(data[offset])
offset += 1
if code in "YCIFDL":
fmt = {"Y": "<h", "C": "<?", "I": "<i", "F": "<f", "D": "<d", "L": "<q"}[code]
props.append(struct.unpack_from(fmt, data, offset)[0])
offset += struct.calcsize(fmt)
elif code in "SR":
length = struct.unpack_from("<I", data, offset)[0]
offset += 4
raw = data[offset:offset + length]
props.append(raw.decode("utf-8", "replace") if code == "S" else raw)
offset += length
elif code in "fdlib":
count, encoding, length = struct.unpack_from("<III", data, offset)
offset += 12 + length
props.append(None) # tableaux inutiles ici
else:
raise ValueError("type de propriete FBX inconnu : " + code)
children = read_nodes(data, offset, end_offset, wide) if offset < end_offset else []
nodes.append((name, props, children))
offset = end_offset
return nodes
def object_name(prop):
# "Nom\x00\x01Classe" -> "Nom"
return prop.split("\x00")[0] if isinstance(prop, str) else str(prop)
def material_map(path):
data = open(path, "rb").read()
if not data.startswith(b"Kaydara FBX Binary"):
raise ValueError("FBX ASCII non gere : " + path)
version = struct.unpack_from("<I", data, 23)[0]
nodes = read_nodes(data, 27, len(data), version >= 7500)
objects = next((c for n, p, c in nodes if n == "Objects"), [])
connections = next((c for n, p, c in nodes if n == "Connections"), [])
materials, textures, videos = {}, {}, {}
for name, props, children in objects:
if not props:
continue
oid = props[0]
if name == "Material":
materials[oid] = object_name(props[1])
elif name in ("Texture", "Video"):
filename = next((c[1][0] for c in children if c[0] in ("RelativeFilename", "FileName") and c[1]), "")
target = textures if name == "Texture" else videos
target[oid] = re.split(r"[\\/]", filename)[-1] if filename else object_name(props[1])
result = {}
for name, props, _ in connections:
if name != "C" or len(props) < 3:
continue
if props[0] == "OP" and props[1] in textures and props[2] in materials:
channel = props[3] if len(props) > 3 else "?"
result.setdefault(materials[props[2]], {})[channel] = textures[props[1]]
return result
if __name__ == "__main__":
mapping = material_map(sys.argv[1])
text = json.dumps(mapping, indent=1, ensure_ascii=False)
if len(sys.argv) > 2:
open(sys.argv[2], "w", encoding="utf-8").write(text)
print(text)

View file

@ -34,6 +34,7 @@ else:
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
count = 0
meshes = []
for path in task.imported_object_paths:
asset = unreal.load_asset(path)
if isinstance(asset, unreal.StaticMesh):
@ -42,5 +43,12 @@ else:
slots = [str(m.material_slot_name) for m in asset.static_materials]
unreal.log_warning("HAIRIMPORT %s : %d triangles, taille X=%.1f Y=%.1f Z=%.1f cm, min Z=%.1f max Z=%.1f, emplacements %s"
% (path, asset.get_num_triangles(0), size.x, size.y, size.z, box.min.z, box.max.z, slots))
meshes.append({"path": path.split(".")[0], "name": asset.get_name(), "triangles": asset.get_num_triangles(0),
"min": [box.min.x, box.min.y, box.min.z], "max": [box.max.x, box.max.y, box.max.z], "slots": slots})
count += 1
unreal.log_warning("HAIRIMPORT %d maillage(s) statique(s) importe(s) dans %s" % (count, dest))
# Rapport lisible par Tools/HairPipeline.ps1 (option report=<fichier.json>).
if args.get("report"):
import json
with open(args["report"], "w", encoding="utf-8") as f:
json.dump({"fbx": fbx, "dest": dest, "meshes": meshes}, f, indent=1)

View file

@ -58,19 +58,24 @@ else:
parent = unreal.load_asset("/Game/Heavy/Daz/Bridget/Hairs/DazHair")
diffuse = import_texture(args["diffuse"], folder, name + "_Diffuse")
strands_opacity = import_texture(args["strands_opacity"], folder, name + "_StrandsOpacity")
cap_arg = args.get("cap_opacity", "white")
cap_opacity = unreal.load_asset("/Engine/EngineResources/WhiteSquareTexture") if cap_arg == "white" else import_texture(cap_arg, folder, name + "_CapOpacity")
strands = make_mic(folder, name + "_Strands", parent, diffuse, strands_opacity, thicken)
cap = make_mic(folder, name + "_Cap", parent, diffuse, cap_opacity, 1.0)
# Calotte : seulement si un emplacement lui est attribue (Pocolov 11 : emplacement unique, pas de calotte).
cap = None
if cap_slots:
cap_arg = args.get("cap_opacity", "white")
cap_opacity = unreal.load_asset("/Engine/EngineResources/WhiteSquareTexture") if cap_arg == "white" else import_texture(cap_arg, folder, name + "_CapOpacity")
cap = make_mic(folder, name + "_Cap", parent, diffuse, cap_opacity, 1.0)
# Affectation : modifier les elements puis reaffecter le tableau entier et sauvegarder sans condition
# (une affectation Python ne marque pas l'asset modifie).
mesh = unreal.load_asset(mesh_path)
materials = []
used = set()
for slot in mesh.get_editor_property("materials"):
slot_name = str(slot.get_editor_property("material_slot_name"))
slot.set_editor_property("material_interface", cap if slot_name in cap_slots else strands)
materials.append(slot)
used.add(folder + "/" + (cap if slot_name in cap_slots else strands).get_name())
unreal.log_warning("HAIRMAT emplacement %s -> %s" % (slot_name, (cap if slot_name in cap_slots else strands).get_name()))
mesh.set_editor_property("materials", materials)
eal.save_loaded_asset(mesh, only_if_is_dirty=False)
@ -79,5 +84,5 @@ else:
registry.scan_modified_asset_files([unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()) + mesh_path[len("/Game/"):] + ".uasset"])
options = unreal.AssetRegistryDependencyOptions(include_soft_package_references=True, include_hard_package_references=True)
deps = [str(d) for d in registry.get_dependencies(mesh_path, options) if not str(d).startswith("/Script/")]
expected = {mesh_path + "_Skeleton", folder + "/" + name + "_Cap", folder + "/" + name + "_Strands"}
expected = {mesh_path + "_Skeleton"} | used
unreal.log_warning("HAIRMAT dependances de %s : %s -> %s" % (name, deps, "OK" if set(deps) == expected else "A VERIFIER"))

235
Tools/HairPipeline.ps1 Normal file
View file

@ -0,0 +1,235 @@
<#
.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

127
Tools/hairs/HAIRS1.json Normal file
View file

@ -0,0 +1,127 @@
{
"module": "lp_Revival",
"folder": "/Game/Heavy/New5/Hairs",
"backup": "D:/Game/LifePlay_Backup_5.5/HAIRS1",
"hairs": [
{
"name": "Pocolov03",
"archive": "D:/Game/pocolov-hair-03.zip",
"title": "Pocolov Hair 03",
"url": "https://www.fab.com/listings/2257098d-b77c-4eef-9132-85f1454f0ae3",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov03/Box001",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov03/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov03/norm/tex10_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov03/norm/tex12_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov06",
"archive": "D:/Game/pocolov-hair-06.zip",
"title": "Pocolov Hair 06",
"url": "https://www.fab.com/listings/14c3cd52-02f9-4bfe-bb40-b5c3acb1f428",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov06/Hair_06",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov06/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov06/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov06/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov11",
"archive": "D:/Game/pocolov-hair-11.zip",
"title": "Pocolov Hair 11",
"url": "https://www.fab.com/listings/c31c219e-bc62-4957-91c5-720440375f79",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov11/Hair_11",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov11/Head",
"cap_slots": "",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov11/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov11/norm/tex04_Hair_Texture_A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov15",
"archive": "D:/Game/pocolov-hair-15.zip",
"title": "Pocolov Hair 15",
"url": "https://www.fab.com/listings/ed8e016a-0bf0-4a6c-94b0-2760083b879d",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov15/Hair_15",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov15/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov15/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov15/norm/tex02_Hair_01A1.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
},
{
"name": "Pocolov16",
"archive": "D:/Game/pocolov-hair-16.zip",
"title": "Pocolov Hair 16",
"url": "https://www.fab.com/listings/802dc956-6211-4b83-8ba1-37beb3cb338f",
"mesh": "/Game/Heavy/New5/Hairs/_Work_Pocolov16/Hair_16",
"head": "/Game/Heavy/New5/Hairs/_Work_Pocolov16/Head",
"cap_slots": "Hair_Base",
"diffuse": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov16/norm/tex01_Hair_01_Gray.png",
"strands_opacity": "D:/Game/LifePlay_Backup_5.5/HAIRS1/Pocolov16/norm/tex04_Hair_01A.jpg",
"cap_opacity": "white",
"thicken": 1,
"sex": "F",
"license": "CC BY 4.0",
"author": "Pocolov (Pocolov Studio, artist Thang)",
"auto_fields": [
"mesh",
"head",
"cap_slots",
"diffuse",
"strands_opacity"
],
"inspected": true
}
]
}