|
| 1 | +import os |
| 2 | +import re |
| 3 | +import argparse |
| 4 | +from collections import defaultdict |
| 5 | +from typing import Dict, Set, List, Tuple |
| 6 | + |
| 7 | +# Global state management for allowed mesh paths. |
| 8 | +ALLOWED_MESHES: Set[str] = set() |
| 9 | + |
| 10 | + |
| 11 | +def collect_mesh_paths(mesh_dir: str) -> None: |
| 12 | + """ |
| 13 | + Scans the specified mesh directory recursively and collects all unique filenames |
| 14 | + into the global ALLOWED_MESHES set. Paths are stored relative to mesh_dir. |
| 15 | +
|
| 16 | + Args: |
| 17 | + mesh_dir: The root path containing all potential mesh assets. |
| 18 | + """ |
| 19 | + global ALLOWED_MESHES |
| 20 | + ALLOWED_MESHES.clear() # Reset state before collection |
| 21 | + |
| 22 | + abs_mesh_dir = os.path.abspath(mesh_dir) |
| 23 | + print(f"\n--- Phase 1/3: Collecting valid mesh assets from: {abs_mesh_dir} ---") |
| 24 | + |
| 25 | + if not os.path.isdir(abs_mesh_dir): |
| 26 | + print(f"[ERROR] Mesh Definition Directory not found at: {abs_mesh_dir}") |
| 27 | + return |
| 28 | + |
| 29 | + file_count = 0 |
| 30 | + for root, _, files in os.walk(abs_mesh_dir): |
| 31 | + for filename in files: |
| 32 | + full_path = os.path.join(root, filename) |
| 33 | + # Store the path relative to the mesh directory and ensure forward slashes for consistency |
| 34 | + relative_path = os.path.relpath(full_path, start=abs_mesh_dir).replace('\\', '/') |
| 35 | + ALLOWED_MESHES.add(relative_path) |
| 36 | + file_count += 1 |
| 37 | + |
| 38 | + print(f"[SUCCESS] Collected {len(ALLOWED_MESHES)} unique mesh assets from {file_count} files.") |
| 39 | + |
| 40 | + |
| 41 | +def normalize_potential_reference(raw_match: str) -> str: |
| 42 | + """ |
| 43 | + Cleans up and standardizes a raw path match found in the code line. |
| 44 | +
|
| 45 | + Args: |
| 46 | + raw_match: The raw string segment matched from a project file (e.g., "mesh/assets/A.scn"). |
| 47 | +
|
| 48 | + Returns: |
| 49 | + The cleaned, normalized path segment. Returns an empty string if the match is discarded. |
| 50 | + """ |
| 51 | + # 1. Normalize separators and strip leading/trailing slashes |
| 52 | + normalized = raw_match.replace('\\', '/').strip('/') |
| 53 | + |
| 54 | + # 2. Strip common 'mesh/' prefixes aggressively |
| 55 | + if normalized.lower().startswith('mesh/'): |
| 56 | + return normalized[len("mesh/"):] |
| 57 | + elif normalized.lower() == 'mesh': |
| 58 | + return "" # Discard if only the word "mesh" is found |
| 59 | + |
| 60 | + return normalized |
| 61 | + |
| 62 | + |
| 63 | +def find_mesh_references(project_dir: str, mesh_dir: str, delete_unused: bool) -> None: |
| 64 | + """ |
| 65 | + Main scanning function. Scans project files to identify usage of defined meshes, |
| 66 | + reports usage locations (deterministically), and calculates unused asset size. |
| 67 | +
|
| 68 | + Args: |
| 69 | + delete_unused: If True, attempts to delete identified unused assets after confirmation. |
| 70 | + """ |
| 71 | + global ALLOWED_MESHES |
| 72 | + |
| 73 | + # --- Phase 1: Collect Meshes --- |
| 74 | + collect_mesh_paths(mesh_dir) |
| 75 | + |
| 76 | + if not ALLOWED_MESHES: |
| 77 | + print("\n[FATAL] Cannot proceed with scanning because no valid mesh assets were collected.") |
| 78 | + return |
| 79 | + |
| 80 | + # Dictionary to store {canonical_mesh_path: list[(filepath, lineno)]} |
| 81 | + unique_references: Dict[str, List[Tuple[str, int]]] = defaultdict(list) |
| 82 | + total_files_scanned = 0 |
| 83 | + lines_processed = 0 |
| 84 | + |
| 85 | + def process_file(filepath: str): |
| 86 | + """Reads a single file, extracts potential references, and validates them against allowed meshes.""" |
| 87 | + nonlocal lines_processed |
| 88 | + try: |
| 89 | + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: |
| 90 | + for lineno, line in enumerate(f, 1): |
| 91 | + lines_processed += 1 |
| 92 | + |
| 93 | + # Regex captures potential paths starting after 'mesh' variations. |
| 94 | + matches = re.findall(r'(?:["\']mesh[\/\\])(.*?)(?:["\'])', line) |
| 95 | + |
| 96 | + for raw_path in matches: |
| 97 | + reference_key = normalize_potential_reference(raw_path) |
| 98 | + if not reference_key: continue |
| 99 | + |
| 100 | + # Check against all allowed canonical paths |
| 101 | + for canonical_mesh_path in ALLOWED_MESHES: |
| 102 | + found_match = False |
| 103 | + |
| 104 | + # Exact Match |
| 105 | + if reference_key == canonical_mesh_path: |
| 106 | + unique_references[canonical_mesh_path].append((os.path.abspath(filepath), lineno)) |
| 107 | + found_match = True |
| 108 | + break |
| 109 | + |
| 110 | + except Exception as e: |
| 111 | + print(f" [!] Warning: Error reading file {os.path.basename(filepath)}. Skipping. Reason: {e}") |
| 112 | + |
| 113 | + # Traverse the project directory |
| 114 | + for root, _, files in os.walk(project_dir): |
| 115 | + for filename in files: |
| 116 | + if filename.endswith(('.xml', '.scn', '.php', '.py', '.pyscn', '.cpp', '.h', '.inl')): |
| 117 | + full_path = os.path.join(root, filename) |
| 118 | + process_file(full_path) |
| 119 | + total_files_scanned += 1 |
| 120 | + |
| 121 | + # --- Phase 3: Summary Report Generation and Usage Check --- |
| 122 | + print("\n" + "=" * 70) |
| 123 | + print("--- Phase 3/3: Generating Report ---") |
| 124 | + print("-" * 30) |
| 125 | + print(f"Total project files scanned: {total_files_scanned}") |
| 126 | + print(f"Total lines processed (containing 'mesh'): {lines_processed}") |
| 127 | + print(f"\n[RESULT] Found unique, valid references matching defined meshes: {len(unique_references)}") |
| 128 | + print("=" * 70) |
| 129 | + |
| 130 | + # --- 3A. REPORTING USED ASSETS (Deterministic Order Ensured) --- |
| 131 | + sorted_keys = sorted(unique_references.keys()) |
| 132 | + for i, reference_key in enumerate(sorted_keys): |
| 133 | + locations = unique_references[reference_key] |
| 134 | + print("\n" + "*" * 5) |
| 135 | + print(f">>> USED REFERENCE {i + 1}: {reference_key} <<<") |
| 136 | + print("*" * 5) |
| 137 | + |
| 138 | + # Use a set to find only unique location tuples (filepath, lineno) |
| 139 | + unique_locations = list(set(locations)) |
| 140 | + |
| 141 | + # Sort the locations to ensure deterministic output order. |
| 142 | + unique_locations.sort() |
| 143 | + |
| 144 | + print(f" Found at {len(unique_locations)} unique locations:") |
| 145 | + for filepath, lineno in unique_locations: |
| 146 | + relative_file_path = os.path.relpath(filepath) |
| 147 | + print(f" - File: {relative_file_path}") |
| 148 | + print(f" Line Number: {lineno}") |
| 149 | + |
| 150 | + # --- 3B. USAGE CHECK & SIZE REPORTING (Finding Unused Meshes) --- |
| 151 | + print("\n\n" + "=" * 70) |
| 152 | + print("--- Phase 3B: USAGE CHECK: FINDING UNUSED MESH ASSETS ---") |
| 153 | + print("-" * 30) |
| 154 | + |
| 155 | + used_references = set(unique_references.keys()) |
| 156 | + initial_unused_references = ALLOWED_MESHES - used_references |
| 157 | + |
| 158 | + # Post-processing filter for MTL files (As per instructions) |
| 159 | + refined_unused_references: Set[str] = set() |
| 160 | + print("[INFO] Running companion asset analysis (MTL files)...") |
| 161 | + |
| 162 | + for mesh_path in initial_unused_references: |
| 163 | + is_mtl = mesh_path.lower().endswith('.mtl') |
| 164 | + |
| 165 | + if is_mtl: |
| 166 | + # If it's an MTL file, check if its presumed companion geometry exists and IS used. |
| 167 | + |
| 168 | + # 1. Get the directory path of the unused MTL file |
| 169 | + directory = os.path.dirname(mesh_path) |
| 170 | + base_name = os.path.basename(mesh_path).replace('.mtl', '') |
| 171 | + |
| 172 | + companion_found_and_used = False |
| 173 | + for allowed_asset in ALLOWED_MESHES: |
| 174 | + if os.path.dirname(allowed_asset) != directory and allowed_asset != mesh_path: |
| 175 | + continue # Must be in the same folder (or root if path is relative to nothing) |
| 176 | + |
| 177 | + # Check if this asset is a potential geometry holder AND is used |
| 178 | + is_geometry_holder = ('.obj' in allowed_asset.lower() or |
| 179 | + '.scn' in allowed_asset.lower() or |
| 180 | + '.dae' in allowed_asset.lower()) |
| 181 | + |
| 182 | + if is_geometry_holder and allowed_asset in used_references: |
| 183 | + # Found a geometry file (A.obj) that IS USED, so the MTL (B.mtl) might also be needed. |
| 184 | + companion_found_and_used = True |
| 185 | + break |
| 186 | + |
| 187 | + if companion_found_and_used: |
| 188 | + print( |
| 189 | + f" [INFO] Marked {mesh_path} as potentially used because its parent mesh was found in the project files.") |
| 190 | + continue # Do not add this MTL file to the unused list |
| 191 | + |
| 192 | + # If it wasn't an MTL, or if it was an MTL and no used companion was found, |
| 193 | + # then it remains genuinely unused. |
| 194 | + refined_unused_references.add(mesh_path) |
| 195 | + |
| 196 | + unused_references = refined_unused_references # Use the filtered set for reporting |
| 197 | + |
| 198 | + total_unused_size = 0 |
| 199 | + |
| 200 | + if not unused_references: |
| 201 | + print("[SUCCESS] All collected mesh assets appear to be actively referenced or implicitly linked!") |
| 202 | + else: |
| 203 | + print(f"[WARNING] Found {len(unused_references)} potential UNUSED mesh asset(s).") |
| 204 | + |
| 205 | + # --- Deletion Execution Block --- |
| 206 | + if delete_unused: |
| 207 | + print("\n!!! WARNING !!! DELETION MODE ACTIVATED.") |
| 208 | + delete_confirm = input("Are you SURE you want to DELETE these unused assets? (y/N): ").lower() |
| 209 | + if delete_confirm != 'y': |
| 210 | + print("[ABORTED] Deletion cancelled by user.") |
| 211 | + return # Exit early if deletion is refused |
| 212 | + |
| 213 | + # --- Reporting and Deleting Assets --- |
| 214 | + sorted_unused = sorted(list(unused_references)) # Ensure consistent order for unused assets |
| 215 | + |
| 216 | + for i, unused_key in enumerate(sorted_unused): |
| 217 | + full_path = os.path.join(os.path.abspath(mesh_dir), unused_key) |
| 218 | + file_size = 0 |
| 219 | + try: |
| 220 | + file_size = os.path.getsize(full_path) |
| 221 | + except OSError as e: |
| 222 | + print(f" -> {i + 1}. {unused_key} [Skipped: Could not read file size ({e})]") |
| 223 | + continue |
| 224 | + |
| 225 | + total_unused_size += file_size |
| 226 | + |
| 227 | + # Report the unused asset |
| 228 | + if os.path.exists(full_path): # Check existence before printing/deleting |
| 229 | + print(f" -> {i + 1}. {unused_key}") |
| 230 | + if unused_key.lower().endswith('.mtl'): |
| 231 | + print(" [Note]: This material file might be redundant if its companion mesh is also unused.") |
| 232 | + print(f" [Size]: {format_bytes(file_size)}") |
| 233 | + |
| 234 | + # Perform deletion if the flag is set and confirmed |
| 235 | + if delete_unused: |
| 236 | + try: |
| 237 | + os.remove(full_path) |
| 238 | + print(" [ACTION] DELETED successfully.") |
| 239 | + except OSError as e: |
| 240 | + print(f" [ERROR] Failed to delete {unused_key}: {e}") |
| 241 | + |
| 242 | + # Display Total Size (This sum should now also be deterministic) |
| 243 | + print("\n" + "=" * 70) |
| 244 | + if total_unused_size > 0: |
| 245 | + print("TOTAL UNUSED ASSET SIZE:") |
| 246 | + print(f"{format_bytes(total_unused_size)}") |
| 247 | + else: |
| 248 | + print("No measurable unused assets found.") |
| 249 | + print("=" * 70) |
| 250 | + |
| 251 | + |
| 252 | +def format_bytes(size: int) -> str: |
| 253 | + """Converts a size in bytes into a human-readable string (KB, MB, GB).""" |
| 254 | + units = ['B', 'KB', 'MB', 'GB'] |
| 255 | + s = float(size) |
| 256 | + i = 0 |
| 257 | + while s >= 1024 and i < len(units) - 1: |
| 258 | + s /= 1024.0 |
| 259 | + i += 1 |
| 260 | + return f"{s:.2f} {units[i]}" |
| 261 | + |
| 262 | + |
| 263 | +def main(): |
| 264 | + """ |
| 265 | + Handles command line arguments and initiates the mesh reference scanning process. |
| 266 | +
|
| 267 | + Usage: python script_name.py <project_dir> <mesh_definition_dir> [--delete-unused] |
| 268 | + """ |
| 269 | + parser = argparse.ArgumentParser( |
| 270 | + description="Scans project files (XML, SCN, PY, PHP) to identify which defined mesh assets are actually used in the codebase. Helps detect unused/redundant assets.", |
| 271 | + formatter_class=argparse.RawTextHelpFormatter |
| 272 | + ) |
| 273 | + parser.add_argument("project_directory", type=str, |
| 274 | + help="The root folder path containing project source files to scan (e.g., 'src').") |
| 275 | + parser.add_argument("mesh_definition_directory", type=str, |
| 276 | + help="The root folder path containing ALL mesh asset files used for validation (e.g., 'assets/meshes').") |
| 277 | + # New optional argument: --delete-unused or -d |
| 278 | + parser.add_argument("--delete-unused", "-d", action="store_true", |
| 279 | + help="If provided, the script will delete all identified unused assets after asking for explicit confirmation.") |
| 280 | + |
| 281 | + args = parser.parse_args() |
| 282 | + find_mesh_references(args.project_directory, args.mesh_definition_directory, args.delete_unused) |
| 283 | + |
| 284 | + |
| 285 | +if __name__ == "__main__": |
| 286 | + # Note: To test this script, ensure you run it with appropriate directory structures |
| 287 | + # (e.g., python your_script_name.py ./src ./assets/meshes --delete-unused) |
| 288 | + try: |
| 289 | + main() |
| 290 | + except Exception as e: |
| 291 | + print(f"\n[CRITICAL ERROR] An unexpected error occurred: {e}") |
| 292 | + |
0 commit comments