diff --git a/Docs/Migration/13_Protocole_Coiffure.md b/Docs/Migration/13_Protocole_Coiffure.md index 08fd1bb..2bda904 100644 --- a/Docs/Migration/13_Protocole_Coiffure.md +++ b/Docs/Migration/13_Protocole_Coiffure.md @@ -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 `_Cap` et `_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 `-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/.json`) : + +```json +{ "module": "lp_Revival", "folder": "/Game/Heavy/New5/Hairs", "backup": "D:/Game/LifePlay_Backup_5.5/", + "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\.json -Step Inspect # puis relire \rapport_Inspect.txt +.\Tools\HairPipeline.ps1 -Config Tools\hairs\.json -Step Apply # [-Only ] 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 diff --git a/Docs/Migration/Journal_Modifications.md b/Docs/Migration/Journal_Modifications.md index dc697ed..7cb6fd5 100644 --- a/Docs/Migration/Journal_Modifications.md +++ b/Docs/Migration/Journal_Modifications.md @@ -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` | diff --git a/Licenses/Free_3D_Models_Credits.txt b/Licenses/Free_3D_Models_Credits.txt index 5210f2c..16e5cb2 100644 --- a/Licenses/Free_3D_Models_Credits.txt +++ b/Licenses/Free_3D_Models_Credits.txt @@ -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*). diff --git a/Tools/FbxMaterialMap.py b/Tools/FbxMaterialMap.py new file mode 100644 index 0000000..3d0daba --- /dev/null +++ b/Tools/FbxMaterialMap.py @@ -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 [] +# Sortie : {"": {"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(" "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("= 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) diff --git a/Tools/HairImportFbx.py b/Tools/HairImportFbx.py index 70e08a4..84524c3 100644 --- a/Tools/HairImportFbx.py +++ b/Tools/HairImportFbx.py @@ -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=). + 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) diff --git a/Tools/HairMaterials.py b/Tools/HairMaterials.py index 1a73f77..6cc248d 100644 --- a/Tools/HairMaterials.py +++ b/Tools/HairMaterials.py @@ -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")) diff --git a/Tools/HairPipeline.ps1 b/Tools/HairPipeline.ps1 new file mode 100644 index 0000000..94f68e7 --- /dev/null +++ b/Tools/HairPipeline.ps1 @@ -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 \\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). + +.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/.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 diff --git a/Tools/hairs/HAIRS1.json b/Tools/hairs/HAIRS1.json new file mode 100644 index 0000000..62dabe4 --- /dev/null +++ b/Tools/hairs/HAIRS1.json @@ -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 + } + ] +}