Skip to content

Commit 250bd26

Browse files
alxbilgerhugtalbot
andauthored
[scripts] Introduce script to identify unused resources in mesh folder (#6207)
* Introduce script to identify unused resources in mesh folder * Consider single quotes --------- Co-authored-by: Hugo <hugo.talbot@sofa-framework.org>
1 parent e20a1c9 commit 250bd26

1 file changed

Lines changed: 292 additions & 0 deletions

File tree

  • scripts/identify_unused_resources
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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

Comments
 (0)