|
6 | 6 | import os |
7 | 7 | import bpy |
8 | 8 | from html import escape |
| 9 | +from pathlib import Path |
9 | 10 | from struct import pack |
| 11 | +import xml.etree.ElementTree as ET |
10 | 12 |
|
11 | 13 | from ...f3d.f3d_gbi import ( |
12 | 14 | DPFullSync, |
@@ -137,6 +139,127 @@ def getDynamicCosmeticXmlAttrs(cosmeticEntry: str, cosmeticCategory: str): |
137 | 139 | return attrs |
138 | 140 |
|
139 | 141 |
|
| 142 | +def _get_cosmetic_manifest_path(modelDirPath: str, objectPath: str) -> str: |
| 143 | + model_path = Path(modelDirPath) |
| 144 | + object_parts = [part for part in (objectPath or "").replace("\\", "/").split("/") if part] |
| 145 | + if not object_parts: |
| 146 | + manifest_root = model_path.parent if model_path.name.lower() == "alt" else model_path |
| 147 | + return str(manifest_root / "CosmeticEntries") |
| 148 | + |
| 149 | + model_parts = list(model_path.parts) |
| 150 | + if len(model_parts) >= len(object_parts): |
| 151 | + tail = model_parts[-len(object_parts) :] |
| 152 | + if [part.lower() for part in tail] == [part.lower() for part in object_parts]: |
| 153 | + manifest_root = Path(*model_parts[: len(model_parts) - len(object_parts)]) |
| 154 | + if manifest_root.name.lower() == "alt": |
| 155 | + manifest_root = manifest_root.parent |
| 156 | + return str(manifest_root / "CosmeticEntries") |
| 157 | + |
| 158 | + manifest_root = model_path.parent if model_path.name.lower() == "alt" else model_path |
| 159 | + return str(manifest_root / "CosmeticEntries") |
| 160 | +def _read_existing_cosmetic_manifest_entries(manifestPath: str) -> list[dict[str, str]]: |
| 161 | + if not os.path.exists(manifestPath): |
| 162 | + return [] |
| 163 | + |
| 164 | + try: |
| 165 | + root = ET.parse(manifestPath).getroot() |
| 166 | + except ET.ParseError as exc: |
| 167 | + raise PluginError(f"Unable to parse existing cosmetic manifest at {manifestPath}: {exc}") from exc |
| 168 | + |
| 169 | + if root.tag != "CustomCosmetics": |
| 170 | + raise PluginError(f'Unexpected cosmetic manifest root "{root.tag}" in {manifestPath}.') |
| 171 | + |
| 172 | + entries: list[dict[str, str]] = [] |
| 173 | + for entry in root.findall("Entry"): |
| 174 | + entries.append( |
| 175 | + { |
| 176 | + "CosmeticCategory": entry.get("CosmeticCategory", ""), |
| 177 | + "CosmeticEntry": entry.get("CosmeticEntry", ""), |
| 178 | + "MaterialPath": entry.get("MaterialPath", ""), |
| 179 | + "CosmeticType": entry.get("CosmeticType", ""), |
| 180 | + } |
| 181 | + ) |
| 182 | + return entries |
| 183 | + |
| 184 | + |
| 185 | +def _serialize_cosmetic_manifest(entries: list[dict[str, str]]) -> str: |
| 186 | + lines = ["<CustomCosmetics>"] |
| 187 | + for entry in entries: |
| 188 | + attrs = " ".join( |
| 189 | + [ |
| 190 | + f'CosmeticCategory="{escape(entry["CosmeticCategory"], quote=True)}"', |
| 191 | + f'CosmeticEntry="{escape(entry["CosmeticEntry"], quote=True)}"', |
| 192 | + f'MaterialPath="{escape(entry["MaterialPath"], quote=True)}"', |
| 193 | + f'CosmeticType="{escape(entry["CosmeticType"], quote=True)}"', |
| 194 | + ] |
| 195 | + ) |
| 196 | + lines.append(f" <Entry {attrs} />") |
| 197 | + lines.append("</CustomCosmetics>") |
| 198 | + return "\n".join(lines) |
| 199 | + |
| 200 | + |
| 201 | +def _collect_material_cosmetic_manifest_entries(fMaterial, objectPath: str) -> list[dict[str, str]]: |
| 202 | + materialPath = format_asset_path(objectPath, fMaterial.material.name) |
| 203 | + entries: list[dict[str, str]] = [] |
| 204 | + |
| 205 | + for command in fMaterial.material.commands: |
| 206 | + cosmeticType = None |
| 207 | + if isinstance(command, DPSetPrimColor): |
| 208 | + cosmeticType = "Prim" |
| 209 | + elif isinstance(command, DPSetEnvColor): |
| 210 | + cosmeticType = "Env" |
| 211 | + |
| 212 | + if cosmeticType is None: |
| 213 | + continue |
| 214 | + |
| 215 | + cosmeticEntry = (getattr(command, "cosmeticEntry", "") or "").strip() |
| 216 | + if not cosmeticEntry: |
| 217 | + continue |
| 218 | + |
| 219 | + entries.append( |
| 220 | + { |
| 221 | + "CosmeticCategory": (getattr(command, "cosmeticCategory", "") or "").strip(), |
| 222 | + "CosmeticEntry": cosmeticEntry, |
| 223 | + "MaterialPath": materialPath, |
| 224 | + "CosmeticType": cosmeticType, |
| 225 | + } |
| 226 | + ) |
| 227 | + |
| 228 | + return entries |
| 229 | + |
| 230 | + |
| 231 | +def _write_custom_cosmetics_manifest(modelDirPath: str, objectPath: str, manifestEntries: list[dict[str, str]]): |
| 232 | + if not manifestEntries: |
| 233 | + return |
| 234 | + |
| 235 | + manifestPath = _get_cosmetic_manifest_path(modelDirPath, objectPath) |
| 236 | + existingEntries = _read_existing_cosmetic_manifest_entries(manifestPath) |
| 237 | + mergedEntries = list(existingEntries) |
| 238 | + existingKeys = { |
| 239 | + ( |
| 240 | + entry["CosmeticCategory"], |
| 241 | + entry["CosmeticEntry"], |
| 242 | + entry["MaterialPath"], |
| 243 | + entry["CosmeticType"], |
| 244 | + ) |
| 245 | + for entry in existingEntries |
| 246 | + } |
| 247 | + |
| 248 | + for entry in manifestEntries: |
| 249 | + key = ( |
| 250 | + entry["CosmeticCategory"], |
| 251 | + entry["CosmeticEntry"], |
| 252 | + entry["MaterialPath"], |
| 253 | + entry["CosmeticType"], |
| 254 | + ) |
| 255 | + if key in existingKeys: |
| 256 | + continue |
| 257 | + existingKeys.add(key) |
| 258 | + mergedEntries.append(entry) |
| 259 | + |
| 260 | + writeXMLData(_serialize_cosmetic_manifest(mergedEntries), manifestPath) |
| 261 | + |
| 262 | + |
140 | 263 | # --- Extracted methods --- |
141 | 264 |
|
142 | 265 |
|
@@ -509,6 +632,11 @@ def _FMaterial_to_soh_xml(self, modelDirPath, objectPath): |
509 | 632 | scrollData = self.scrollData.to_soh_xml() |
510 | 633 | matData = matData.replace("</DisplayList>", scrollData + "</DisplayList>") |
511 | 634 | writeXMLData(matData, os.path.join(modelDirPath, self.material.name)) |
| 635 | + _write_custom_cosmetics_manifest( |
| 636 | + modelDirPath, |
| 637 | + objectPath, |
| 638 | + _collect_material_cosmetic_manifest_entries(self, objectPath), |
| 639 | + ) |
512 | 640 |
|
513 | 641 | if self.revert is not None and self.revert.tag.Export: |
514 | 642 | revData = self.revert.to_soh_xml(modelDirPath, objectPath) |
|
0 commit comments