diff --git a/dev/samples/01_largest_meshes.py b/dev/samples/01_largest_meshes.py new file mode 100644 index 0000000..a95bf27 --- /dev/null +++ b/dev/samples/01_largest_meshes.py @@ -0,0 +1,247 @@ +# ============================================================================= +# Largest Meshes Analyzer — ASYNC version for Kit Script Editor +# ----------------------------------------------------------------------------- +# Non-blocking: yields back to Kit's event loop every YIELD_EVERY meshes, +# so the UI stays responsive on large stages. +# +# Usage: +# 1) Paste into Window > Script Editor +# 2) Tweak the TUNING constants if needed +# 3) Run (Ctrl+Enter) → cell returns immediately; work happens in background +# 4) Watch the Script Editor output for progress and final report +# +# Re-running cancels the previous run automatically. +# +# Output: COUNT / DIMENSION only — no vertex data leaves the stage. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 20 +INCLUDE_INVISIBLE = False +INCLUDE_INSIDE_INSTANCES = True +TIME_CODE = None +YIELD_EVERY = 100 # meshes processed between UI yields +PROGRESS_EVERY = 500 # progress print interval +OUTPUT_CSV = "./largest_meshes.csv" +STAGE_PATH = None +# --------------------------------------------------------------------------- + +import asyncio +import csv +import statistics +import traceback +from pxr import Usd, UsdGeom + +import omni.usd +import omni.kit.app + +# ---- Cancel any previous run ----------------------------------------------- +_GLOBAL = globals() +_prev = _GLOBAL.get("_LARGEST_MESHES_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield_to_ui(): + """Yield one frame back to the Kit event loop.""" + await omni.kit.app.get_app().next_update_async() + + +async def _largest_meshes_main(): + # ---- 1) Resolve stage -------------------------------------------------- + if STAGE_PATH: + stage = Usd.Stage.Open(STAGE_PATH) + print(f"[opened] {STAGE_PATH}") + else: + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] No stage open. Open a USD file or set STAGE_PATH.") + return + print(f"[using current stage] {stage.GetRootLayer().identifier}") + + time_code = Usd.TimeCode.Default() if TIME_CODE is None else Usd.TimeCode(TIME_CODE) + + # ---- 2) BBoxCache ------------------------------------------------------ + purposes = [UsdGeom.Tokens.default_, UsdGeom.Tokens.render] + if INCLUDE_INVISIBLE: + purposes += [UsdGeom.Tokens.guide, UsdGeom.Tokens.proxy] + bbox_cache = UsdGeom.BBoxCache(time_code, includedPurposes=purposes, + useExtentsHint=True) + + # ---- 3) Traverse + collect -------------------------------------------- + if INCLUDE_INSIDE_INSTANCES: + prim_iter = Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) + else: + prim_iter = stage.Traverse() + + results, skipped_empty, skipped_invis, mesh_count = [], 0, 0, 0 + await _yield_to_ui() + + for prim in prim_iter: + if not prim.IsA(UsdGeom.Mesh): + continue + + mesh_count += 1 + + # Periodic yield to keep UI responsive + if mesh_count % YIELD_EVERY == 0: + await _yield_to_ui() + if mesh_count % PROGRESS_EVERY == 0: + print(f" ... {mesh_count} meshes scanned (kept {len(results)})") + + # Visibility filter + if not INCLUDE_INVISIBLE: + if UsdGeom.Imageable(prim).ComputeVisibility(time_code) == UsdGeom.Tokens.invisible: + skipped_invis += 1 + continue + + mesh = UsdGeom.Mesh(prim) + pts = mesh.GetPointsAttr().Get(time_code) + vert_count = len(pts) if pts else 0 + if vert_count == 0: + skipped_empty += 1 + continue + + fvc = mesh.GetFaceVertexCountsAttr().Get(time_code) + face_count = len(fvc) if fvc else 0 + + rng = bbox_cache.ComputeWorldBound(prim).ComputeAlignedRange() + if rng.IsEmpty(): + dx = dy = dz = diag = vol = 0.0 + else: + s = rng.GetSize() + dx, dy, dz = float(s[0]), float(s[1]), float(s[2]) + diag = (dx * dx + dy * dy + dz * dz) ** 0.5 + vol = abs(dx * dy * dz) + + results.append({ + "path": str(prim.GetPath()), + "instance_proxy": bool(prim.IsInstanceProxy()), + "verts": vert_count, "faces": face_count, + "dx": dx, "dy": dy, "dz": dz, + "diag": diag, "vol": vol, + }) + + print(f"\n[scan complete] {mesh_count} meshes seen | " + f"kept {len(results)} | empty {skipped_empty} | " + f"invisible {skipped_invis}\n") + await _yield_to_ui() + + if not results: + print("No meshes to analyze.") + return + + # ---- 4) Stage stats ---------------------------------------------------- + verts = [r["verts"] for r in results] + stage_rng = bbox_cache.ComputeWorldBound(stage.GetPseudoRoot()).ComputeAlignedRange() + if not stage_rng.IsEmpty(): + s = stage_rng.GetSize() + stage_diag = (float(s[0])**2 + float(s[1])**2 + float(s[2])**2) ** 0.5 + stage_vol = abs(float(s[0]) * float(s[1]) * float(s[2])) + else: + stage_diag = stage_vol = 0.0 + + print("=" * 78) + print("STAGE STATS") + print("=" * 78) + print(f" Total meshes (analyzed) : {len(results):,}") + print(f" Total vertices : {sum(verts):,}") + print(f" Median verts/mesh : {int(statistics.median(verts)):,}") + print(f" p95 verts/mesh : " + f"{int(sorted(verts)[int(0.95 * (len(verts)-1))]):,}") + print(f" Max verts/mesh : {max(verts):,}") + print(f" Stage bbox diag (world) : {stage_diag:.3f}") + print(f" Stage bbox volume : {stage_vol:.3f}\n") + await _yield_to_ui() + + # ---- 5) Ranking print helper ------------------------------------------ + def _print_top(title, sr): + print("=" * 78); print(title); print("=" * 78) + print(f" {'#':>3} {'verts':>10} {'faces':>10} " + f"{'dx':>10} {'dy':>10} {'dz':>10} {'diag':>10} " + f"{'%stg':>6} {'inst':>4} path") + for i, r in enumerate(sr[:TOP_N], 1): + pct = (r["diag"] / stage_diag * 100.0) if stage_diag > 0 else 0.0 + print(f" {i:>3} {r['verts']:>10,} {r['faces']:>10,} " + f"{r['dx']:>10.2f} {r['dy']:>10.2f} {r['dz']:>10.2f} " + f"{r['diag']:>10.2f} {pct:>5.1f}% " + f"{'I' if r['instance_proxy'] else '-':>4} {r['path']}") + print() + + by_verts = sorted(results, key=lambda r: r["verts"], reverse=True) + await _yield_to_ui() + by_diag = sorted(results, key=lambda r: r["diag"], reverse=True) + await _yield_to_ui() + by_vol = sorted(results, key=lambda r: r["vol"], reverse=True) + await _yield_to_ui() + + _print_top(f"TOP {TOP_N} BY VERTEX COUNT", by_verts) + _print_top(f"TOP {TOP_N} BY WORLD BBOX DIAGONAL (largest physical meshes)", by_diag) + _print_top(f"TOP {TOP_N} BY WORLD BBOX VOLUME", by_vol) + await _yield_to_ui() + + # ---- 6) Slab candidates ----------------------------------------------- + if stage_diag > 0: + slabs = sorted([r for r in results if r["diag"] / stage_diag > 0.5], + key=lambda r: r["diag"], reverse=True) + print("=" * 78) + print("SLAB CANDIDATES (single mesh diag > 50% of stage diag)") + print("=" * 78) + if not slabs: + print(" (none — no single mesh spans more than half the stage)") + else: + for r in slabs: + pct = r["diag"] / stage_diag * 100.0 + print(f" {pct:>6.1f}% stage | verts={r['verts']:>10,} | " + f"diag={r['diag']:>10.2f} | path={r['path']}") + print() + await _yield_to_ui() + + # ---- 7) CSV ------------------------------------------------------------ + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "instance_proxy", "verts", "faces", + "dx", "dy", "dz", "diag", "vol"]) + for r in by_verts: + w.writerow([r["path"], r["instance_proxy"], + r["verts"], r["faces"], + f"{r['dx']:.4f}", f"{r['dy']:.4f}", + f"{r['dz']:.4f}", f"{r['diag']:.4f}", + f"{r['vol']:.4f}"]) + print(f"[csv] wrote {OUTPUT_CSV} ({len(results)} rows)") + except Exception as e: + print(f"[csv] FAILED: {e}") + + print("\n[done]") + + +# ---- Wrap to surface exceptions (async tasks otherwise swallow them) ------ +async def _wrapped(): + try: + await _largest_meshes_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + print("[error] exception in async task:") + traceback.print_exc() + + +def _on_done(t: asyncio.Task): + try: + t.result() + except asyncio.CancelledError: + pass + except Exception: + # Already printed inside _wrapped, but keep this for safety + pass + + +# ---- Start -------------------------------------------------------------- +_LARGEST_MESHES_TASK = asyncio.ensure_future(_wrapped()) +_LARGEST_MESHES_TASK.add_done_callback(_on_done) +print("[started] async largest-meshes scan — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/02_instancing.py b/dev/samples/02_instancing.py new file mode 100644 index 0000000..118a32d --- /dev/null +++ b/dev/samples/02_instancing.py @@ -0,0 +1,246 @@ +# ============================================================================= +# Instancing Analyzer — ASYNC version for Kit Script Editor +# ----------------------------------------------------------------------------- +# Reports: +# - UsdGeomPointInstancer count + total drawn instances +# - Scene Graph Instances (prim.IsInstance / IsInstanceable) + prototype usage +# - Instancing ratio: drawn meshes / unique mesh prototypes +# - Unused prototypes (zero instances) +# - Top prototypes by instance count +# +# Non-blocking, COUNT-only output (no geometry data leaves the stage). +# Usage: paste, Ctrl+Enter. Re-run cancels previous. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 20 +YIELD_EVERY = 200 +OUTPUT_CSV = "./instancing.csv" +STAGE_PATH = None +# --------------------------------------------------------------------------- + +import asyncio +import csv +import traceback +from collections import defaultdict, Counter +from pxr import Usd, UsdGeom + +import omni.usd +import omni.kit.app + +_GLOBAL = globals() +_prev = _GLOBAL.get("_INSTANCING_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +async def _instancing_main(): + # ---- Stage ------------------------------------------------------------- + if STAGE_PATH: + stage = Usd.Stage.Open(STAGE_PATH) + print(f"[opened] {STAGE_PATH}") + else: + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] No stage open.") + return + print(f"[using current stage] {stage.GetRootLayer().identifier}") + + # ---- Pass 1: PointInstancer + Scene Graph Instance discovery ---------- + point_instancers = [] # (path, proto_count, total_indices) + sgi_instance_prims = [] # prims where IsInstance() == True + instanceable_prims = [] # prims with instanceable=true attr + total_meshes_authored = 0 # Mesh prim count in the stage (NOT counting instance proxies) + mesh_prims_total = 0 + n = 0 + + # NOTE: Default traversal does NOT descend into instance prototypes. + for prim in stage.Traverse(): + n += 1 + if n % YIELD_EVERY == 0: + await _yield() + if n % 2000 == 0: + print(f" ... pass1: {n} prims scanned") + + if prim.IsA(UsdGeom.PointInstancer): + pi = UsdGeom.PointInstancer(prim) + proto_paths = pi.GetPrototypesRel().GetTargets() + proto_count = len(proto_paths) + idx = pi.GetProtoIndicesAttr().Get() + n_idx = len(idx) if idx else 0 + point_instancers.append({ + "path": str(prim.GetPath()), + "proto_count": proto_count, + "instances": n_idx, + "protos": [str(p) for p in proto_paths], + "proto_index_dist": dict(Counter(idx)) if idx else {}, + }) + + if prim.IsInstance(): + # Scene-graph instance — points at a prototype root + proto_root = prim.GetPrototype() + sgi_instance_prims.append({ + "path": str(prim.GetPath()), + "prototype": str(proto_root.GetPath()) if proto_root else "", + }) + + if prim.IsInstanceable(): + instanceable_prims.append(str(prim.GetPath())) + + if prim.IsA(UsdGeom.Mesh): + mesh_prims_total += 1 + + await _yield() + + # ---- Pass 2: count meshes inside instance prototypes ------------------- + # Walk the stage's master prims (instance prototypes). + prototype_mesh_count = defaultdict(int) + prototype_paths = set() + for proto in stage.GetPrototypes(): + ppath = str(proto.GetPath()) + prototype_paths.add(ppath) + cnt = 0 + sub_n = 0 + for sub in Usd.PrimRange(proto): + sub_n += 1 + if sub_n % YIELD_EVERY == 0: + await _yield() + if sub.IsA(UsdGeom.Mesh): + cnt += 1 + prototype_mesh_count[ppath] = cnt + + await _yield() + + # ---- Pass 3: traverse INTO instance proxies to count drawn meshes ----- + drawn_mesh_total = 0 + n2 = 0 + for prim in Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()): + n2 += 1 + if n2 % YIELD_EVERY == 0: + await _yield() + if n2 % 5000 == 0: + print(f" ... pass3: {n2} prims scanned") + if prim.IsA(UsdGeom.Mesh): + drawn_mesh_total += 1 + + await _yield() + + # ---- Summary ----------------------------------------------------------- + # Sum PointInstancer instances + pi_total_instances = sum(p["instances"] for p in point_instancers) + + # Count SGI usage per prototype + proto_usage = Counter(p["prototype"] for p in sgi_instance_prims if p["prototype"]) + + unused_protos = [p for p in prototype_paths if proto_usage.get(p, 0) == 0] + instancing_ratio = (drawn_mesh_total / mesh_prims_total) if mesh_prims_total else 0.0 + + print("\n" + "=" * 78) + print("INSTANCING SUMMARY") + print("=" * 78) + print(f" Mesh prims (authored, no proxies) : {mesh_prims_total:,}") + print(f" Mesh prims (drawn, including proxies) : {drawn_mesh_total:,}") + if mesh_prims_total > 0: + print(f" Instancing multiplier (drawn / authored) : {instancing_ratio:.2f}x") + if instancing_ratio < 1.05: + print(f" -> very LITTLE benefit from instancing in this stage") + elif instancing_ratio < 2.0: + print(f" -> mild instancing") + else: + print(f" -> strong instancing in use") + print() + print(f" UsdGeomPointInstancer prims : {len(point_instancers):,}") + print(f" Total points (instances) across them : {pi_total_instances:,}") + print() + print(f" Scene Graph Instance prims : {len(sgi_instance_prims):,}") + print(f" Authored instanceable=true prims : {len(instanceable_prims):,}") + print(f" Unique stage prototypes : {len(prototype_paths):,}") + print(f" of which unused (0 instances pointing) : {len(unused_protos):,}") + print() + await _yield() + + # ---- Top PointInstancers ---------------------------------------------- + if point_instancers: + print("=" * 78) + print(f"TOP {TOP_N} POINT INSTANCERS BY INSTANCE COUNT") + print("=" * 78) + sorted_pi = sorted(point_instancers, key=lambda p: p["instances"], reverse=True) + for i, p in enumerate(sorted_pi[:TOP_N], 1): + print(f" {i:>3} instances={p['instances']:>10,} " + f"protos={p['proto_count']:>4} path={p['path']}") + print() + await _yield() + + # ---- Top prototypes by SGI usage -------------------------------------- + if proto_usage: + print("=" * 78) + print(f"TOP {TOP_N} SCENE GRAPH INSTANCE PROTOTYPES BY USAGE") + print("=" * 78) + for i, (proto, count) in enumerate(proto_usage.most_common(TOP_N), 1): + meshes_in_proto = prototype_mesh_count.get(proto, 0) + print(f" {i:>3} instances={count:>8,} proto_meshes={meshes_in_proto:>6,} " + f"prototype={proto}") + print() + await _yield() + + # ---- Unused prototypes ------------------------------------------------- + if unused_protos: + print("=" * 78) + print(f"UNUSED PROTOTYPES (no instances point here — dead memory)") + print("=" * 78) + for i, p in enumerate(sorted(unused_protos)[:TOP_N], 1): + meshes_in_proto = prototype_mesh_count.get(p, 0) + print(f" {i:>3} proto_meshes={meshes_in_proto:>6,} prototype={p}") + if len(unused_protos) > TOP_N: + print(f" ... and {len(unused_protos) - TOP_N} more") + print() + await _yield() + + # ---- CSV --------------------------------------------------------------- + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["category", "path", "instances_or_count", + "proto_count_or_meshes", "extra"]) + for p in point_instancers: + w.writerow(["PointInstancer", p["path"], p["instances"], + p["proto_count"], ""]) + for proto, count in proto_usage.most_common(): + w.writerow(["SGI_Prototype", proto, count, + prototype_mesh_count.get(proto, 0), ""]) + for p in unused_protos: + w.writerow(["UnusedPrototype", p, 0, + prototype_mesh_count.get(p, 0), ""]) + print(f"[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"[csv] FAILED: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _instancing_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + print("[error] exception in async task:") + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_INSTANCING_TASK = asyncio.ensure_future(_wrapped()) +_INSTANCING_TASK.add_done_callback(_on_done) +print("[started] async instancing scan — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/03_animation.py b/dev/samples/03_animation.py new file mode 100644 index 0000000..f287faf --- /dev/null +++ b/dev/samples/03_animation.py @@ -0,0 +1,315 @@ +# ============================================================================= +# Animation Trait Analyzer — ASYNC, uses Scene Optimizer's optimizeTimeSamples +# ----------------------------------------------------------------------------- +# Verified facts (read directly from omniverse-scene-optimizer C++ source): +# - Op name (Operation ctor): "optimizeTimeSamples" +# - Module import path: omni.scene.optimizer.core +# - Available op introspection: SceneOptimizerCore.getInstance().getOperations() +# - analysisMode: ctx.analysisMode = 1 +# - Analysis output (per source): { : [redundant_count, total_count] } +# (OptimizeTimeSamples.cpp lines 667-683: resultJson["analysis"] = analysisResult) +# +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 20 +YIELD_EVERY = 200 +INCLUDE_INSIDE_INSTANCES = True +OUTPUT_CSV = "./animation.csv" +STAGE_PATH = None +ENABLE_USD_FALLBACK = True +# --------------------------------------------------------------------------- + +import asyncio +import csv +import traceback +from collections import Counter +from pxr import Usd, UsdGeom, UsdSkel + +import omni.usd +import omni.kit.app + +_GLOBAL = globals() +_prev = _GLOBAL.get("_ANIM_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _get_so_core(): + """Return (core, available_op_names_set) or (None, None) if SO unavailable.""" + try: + from omni.scene.optimizer.core import SceneOptimizerCore + except ImportError as e: + print(f"[so] omni.scene.optimizer.core not loadable: {e}") + return None, None + try: + core = SceneOptimizerCore.getInstance() + ops = set() + try: + ops_obj = core.getOperations() + for o in ops_obj: + # ops may be op-objects with a name attr, or plain strings + name = getattr(o, "name", None) or getattr(o, "getName", lambda: None)() + if isinstance(name, str): + ops.add(name) + elif isinstance(o, str): + ops.add(o) + else: + ops.add(str(o)) + except Exception as e: + print(f"[so] getOperations() failed: {e}") + return core, ops + except Exception as e: + print(f"[so] failed to acquire SceneOptimizerCore: {e}") + return None, None + + +def _call_so(core, op_name, args, stage, available_ops): + """Call SO op in analysisMode=1. Returns analysis payload or None.""" + if available_ops and op_name not in available_ops: + print(f"[so] '{op_name}' NOT registered in this Kit ({len(available_ops)} ops available).") + print(f"[so] (likely SO extension version too old for this op — using USD fallback)") + return None + try: + from omni.scene.optimizer.core import ExecutionContext + ctx = ExecutionContext() + ctx.set_stage(stage) + ctx.analysisMode = 1 + success, error, output = core.executeOperation(op_name, ctx, args) + try: ctx.remove_stage() + except Exception: pass + if not success: + print(f"[so] {op_name} returned failure: {error!r}") + return None + if output is None: + print(f"[so] {op_name} returned no output.") + return None + if isinstance(output, dict): + return output.get("analysis", output) + return output + except Exception as e: + print(f"[so] direct call failed for {op_name}: {e}") + return None + + +def _classify_attr(prim, attr): + name = attr.GetName() + if name == "visibility": return "Visibility" + if name.startswith("xformOp:"): return "Xform" + if name == "points": return "Mesh.points (deformation)" + if name == "normals": return "Mesh.normals" + if name.startswith("primvars:displayColor") or name.startswith("primvars:displayOpacity"): + return "DisplayColor/Opacity" + if prim.IsA(UsdSkel.Animation): return "Skel.Animation" + if name.startswith("skel:") or name.startswith("primvars:skel:"): + return "Skel.attr" + if prim.GetTypeName() in ("Shader", "Material"): return "Material/Shader" + if prim.IsA(UsdGeom.Camera): return "Camera" + return f"Other ({name})" + + +async def _anim_main(): + if STAGE_PATH: + stage = Usd.Stage.Open(STAGE_PATH) + print(f"[opened] {STAGE_PATH}") + else: + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] No stage open.") + return + print(f"[using current stage] {stage.GetRootLayer().identifier}") + + print(f"[stage timecode] start={stage.GetStartTimeCode()} " + f"end={stage.GetEndTimeCode()} fps={stage.GetTimeCodesPerSecond()}\n") + + # ---- SO introspection -------------------------------------------------- + core, available_ops = _get_so_core() + if core is not None and available_ops is not None: + print(f"[so] SO available — {len(available_ops)} registered operations.") + + # ---- 1) Primary: SO optimizeTimeSamples (analysis mode) --------------- + print("=" * 78) + print("SO optimizeTimeSamples — analysis mode") + print("=" * 78) + analysis = None + if core is not None: + so_args = {"paths": [], "removeInterpolated": False, + "epsilonD": 1e-12, "epsilonF": 1e-6} + analysis = _call_so(core, "optimizeTimeSamples", so_args, stage, available_ops) + else: + print("[so] SO not loaded — USD fallback only.") + await _yield() + + if analysis is None: + pass + elif isinstance(analysis, dict): + # analysis = { attr_path: [redundant_count, total_count] } + so_attrs = len(analysis) + so_redundant_total = 0 + so_sample_total = 0 + per_attr_rows = [] + for ap, v in analysis.items(): + try: + r = int(v[0]); t = int(v[1]) + except Exception: + continue + so_redundant_total += r + so_sample_total += t + per_attr_rows.append((ap, r, t)) + print(f" Attributes with time samples : {so_attrs:,}") + print(f" Total time samples : {so_sample_total:,}") + print(f" Redundant samples (removable): {so_redundant_total:,}") + if so_sample_total > 0: + pct = so_redundant_total / so_sample_total * 100.0 + print(f" Redundancy ratio : {pct:.2f}%") + print() + per_attr_rows.sort(key=lambda r: r[1], reverse=True) + if per_attr_rows: + print(f" TOP {TOP_N} attributes by redundant-sample count:") + print(f" {'#':>3} {'redundant':>10} {'total':>10} attr-path") + for i, (ap, r, t) in enumerate(per_attr_rows[:TOP_N], 1): + print(f" {i:>3} {r:>10,} {t:>10,} {ap}") + print() + else: + print(f" (non-dict analysis: {type(analysis).__name__})") + await _yield() + + if not ENABLE_USD_FALLBACK: + print("[done]") + return + + # ---- 2) USD supplement: per-prim breakdown ---------------------------- + print("=" * 78) + print("USD supplement — per-prim animation breakdown") + print("=" * 78) + + prim_iter = (Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) + if INCLUDE_INSIDE_INSTANCES else stage.Traverse()) + + category_count = Counter() + category_samples = Counter() + animated_prims = set() + heaviest = [] + skel_anims = [] + n = 0 + n_anim_attrs = 0 + min_t = float("inf"); max_t = float("-inf") + + for prim in prim_iter: + n += 1 + if n % YIELD_EVERY == 0: await _yield() + if n % 5000 == 0: + print(f" ... {n} prims scanned (animated={len(animated_prims):,})") + + if prim.IsA(UsdSkel.Animation): + skel_anims.append(str(prim.GetPath())) + + for attr in prim.GetAuthoredAttributes(): + ts = attr.GetTimeSamples() + if not ts: continue + n_anim_attrs += 1 + animated_prims.add(str(prim.GetPath())) + cat = _classify_attr(prim, attr) + category_count[cat] += 1 + category_samples[cat] += len(ts) + t0, t1 = ts[0], ts[-1] + if t0 < min_t: min_t = t0 + if t1 > max_t: max_t = t1 + + verts = 0 + if attr.GetName() == "points" and prim.IsA(UsdGeom.Mesh): + try: + pts = attr.Get(Usd.TimeCode(t0)) + verts = len(pts) if pts else 0 + except Exception: + verts = 0 + heaviness = len(ts) * max(verts, 1) + heaviest.append({ + "heaviness": heaviness, "samples": len(ts), "verts": verts, + "path": str(prim.GetPath()), "attr": attr.GetName(), "category": cat, + }) + + await _yield() + + if not animated_prims: + print("[result] No time-sampled attributes found.") + return + + print(f"\n Prims scanned : {n:,}") + print(f" Animated prims (unique) : {len(animated_prims):,}") + print(f" Animated attributes (total) : {n_anim_attrs:,}") + print(f" Earliest sample time : {min_t}") + print(f" Latest sample time : {max_t}") + print(f" UsdSkel.Animation prims : {len(skel_anims):,}\n") + + print(f" {'category':<45} {'attrs':>10} {'samples':>14}") + for cat, count in category_count.most_common(): + print(f" {cat:<45} {count:>10,} {category_samples[cat]:>14,}") + print() + await _yield() + + pts_heavy = [h for h in heaviest if h["attr"] == "points"] + pts_heavy.sort(key=lambda h: h["heaviness"], reverse=True) + if pts_heavy: + print("=" * 78) + print(f"TOP {TOP_N} MESH DEFORMATION HOTSPOTS (animated points)") + print("=" * 78) + print(f" {'#':>3} {'samples':>10} {'verts':>10} {'heaviness':>14} path") + for i, h in enumerate(pts_heavy[:TOP_N], 1): + print(f" {i:>3} {h['samples']:>10,} {h['verts']:>10,} " + f"{h['heaviness']:>14,} {h['path']}") + print() + await _yield() + + by_samples = sorted(heaviest, key=lambda h: h["samples"], reverse=True) + print("=" * 78) + print(f"TOP {TOP_N} ANIMATED ATTRIBUTES BY SAMPLE COUNT") + print("=" * 78) + print(f" {'#':>3} {'samples':>10} {'category':<35} attr path") + for i, h in enumerate(by_samples[:TOP_N], 1): + print(f" {i:>3} {h['samples']:>10,} {h['category']:<35} " + f"{h['attr']} {h['path']}") + print() + await _yield() + + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "attr", "category", "samples", "verts", "heaviness"]) + for h in by_samples: + w.writerow([h["path"], h["attr"], h["category"], + h["samples"], h["verts"], h["heaviness"]]) + print(f"[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"[csv] FAILED: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _anim_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + print("[error] exception in async task:") + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_ANIM_TASK = asyncio.ensure_future(_wrapped()) +_ANIM_TASK.add_done_callback(_on_done) +print("[started] async animation scan — cell returns now.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/04_small_objects.py b/dev/samples/04_small_objects.py new file mode 100644 index 0000000..c1b0628 --- /dev/null +++ b/dev/samples/04_small_objects.py @@ -0,0 +1,268 @@ +# ============================================================================= +# Small Objects Analyzer — ASYNC, uses Scene Optimizer's removeSmallGeometry +# ----------------------------------------------------------------------------- +# Verified facts (read directly from omniverse-scene-optimizer C++ source): +# - Op name (Operation ctor): "removeSmallGeometry" +# - Module import path: omni.scene.optimizer.core +# - Args (RemoveSmallGeometry.cpp): paths, removeMethod, detectionMethod, threshold +# - Analysis output (lines 130-149): { "smallGeometry": [, ...] } +# optionally also "suggestedOperations" +# - analysisMode: ctx.analysisMode = 1 +# +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +THRESHOLDS_WORLD_UNITS = [0.01, 0.05, 0.1, 0.5, 1.0] +TOP_N = 20 +YIELD_EVERY = 200 +INCLUDE_INSIDE_INSTANCES = True +OUTPUT_CSV = "./small_objects.csv" +STAGE_PATH = None +ENABLE_USD_FALLBACK = True +# --------------------------------------------------------------------------- + +import asyncio +import csv +import traceback +from pxr import Usd, UsdGeom + +import omni.usd +import omni.kit.app + +_GLOBAL = globals() +_prev = _GLOBAL.get("_SMALL_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _get_so_core(): + """Return (core, available_op_names_set) or (None, None).""" + try: + from omni.scene.optimizer.core import SceneOptimizerCore + except ImportError as e: + print(f"[so] omni.scene.optimizer.core not loadable: {e}") + return None, None + try: + core = SceneOptimizerCore.getInstance() + ops = set() + try: + for o in core.getOperations(): + name = getattr(o, "name", None) or getattr(o, "getName", lambda: None)() + if isinstance(name, str): ops.add(name) + elif isinstance(o, str): ops.add(o) + else: ops.add(str(o)) + except Exception as e: + print(f"[so] getOperations() failed: {e}") + return core, ops + except Exception as e: + print(f"[so] failed to acquire SceneOptimizerCore: {e}") + return None, None + + +def _call_so(core, op_name, args, stage, available_ops): + """Call SO op in analysisMode=1. Returns analysis payload or None.""" + if available_ops and op_name not in available_ops: + print(f"[so] '{op_name}' NOT registered in this Kit — using USD fallback.") + return None + try: + from omni.scene.optimizer.core import ExecutionContext + ctx = ExecutionContext() + ctx.set_stage(stage) + ctx.analysisMode = 1 + success, error, output = core.executeOperation(op_name, ctx, args) + try: ctx.remove_stage() + except Exception: pass + if not success: + print(f"[so] {op_name} returned failure: {error!r}") + return None + if output is None: + print(f"[so] {op_name} returned no output.") + return None + if isinstance(output, dict): + return output.get("analysis", output) + return output + except Exception as e: + print(f"[so] direct call failed for {op_name}: {e}") + return None + + +async def _small_main(): + if STAGE_PATH: + stage = Usd.Stage.Open(STAGE_PATH) + print(f"[opened] {STAGE_PATH}") + else: + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] No stage open.") + return + print(f"[using current stage] {stage.GetRootLayer().identifier}\n") + + core, available_ops = _get_so_core() + if core is not None and available_ops is not None: + print(f"[so] SO available — {len(available_ops)} registered operations.") + + # ---- 1) Primary: SO removeSmallGeometry, multi-threshold preview ------ + print("=" * 78) + print("SO removeSmallGeometry — analysis preview at multiple thresholds") + print("=" * 78) + so_rows = [] + for t in THRESHOLDS_WORLD_UNITS: + args = {"paths": [], "removeMethod": 1, "detectionMethod": 1, + "threshold": float(t)} + analysis = None + if core is not None: + analysis = _call_so(core, "removeSmallGeometry", args, stage, available_ops) + await _yield() + if analysis is None: + print(f" threshold={t}: analysis not available") + continue + if isinstance(analysis, dict): + small = analysis.get("smallGeometry", []) + if not isinstance(small, (list, tuple)): + small = [] + print(f" threshold={t:<6} would_remove={len(small):,}") + so_rows.append({"threshold": t, "would_remove": len(small), + "paths": list(small)}) + else: + print(f" threshold={t}: non-dict result ({type(analysis).__name__})") + print() + await _yield() + + # Show some example paths from the smallest non-empty threshold + if so_rows: + for row in so_rows: + if row["would_remove"] > 0: + print(f" Sample paths at threshold={row['threshold']} " + f"(first {min(TOP_N, row['would_remove'])} of {row['would_remove']:,}):") + for p in row["paths"][:TOP_N]: + print(f" {p}") + if row["would_remove"] > TOP_N: + print(f" ... and {row['would_remove'] - TOP_N} more") + print() + break + + if core is None and not ENABLE_USD_FALLBACK: + print("[exit] SO unavailable and ENABLE_USD_FALLBACK=False, stopping.") + return + + # ---- 2) USD supplement: per-mesh diag distribution + Top-N smallest --- + print("=" * 78) + print("USD supplement — per-mesh bbox-diagonal distribution") + print("=" * 78) + bbox_cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), + includedPurposes=[UsdGeom.Tokens.default_, + UsdGeom.Tokens.render], + useExtentsHint=True) + prim_iter = (Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) + if INCLUDE_INSIDE_INSTANCES else stage.Traverse()) + + results = [] + n = 0 + for prim in prim_iter: + if not prim.IsA(UsdGeom.Mesh): continue + n += 1 + if n % YIELD_EVERY == 0: await _yield() + if n % 5000 == 0: + print(f" ... {n} meshes scanned") + if UsdGeom.Imageable(prim).ComputeVisibility() == UsdGeom.Tokens.invisible: + continue + mesh = UsdGeom.Mesh(prim) + pts = mesh.GetPointsAttr().Get() + if not pts: continue + rng = bbox_cache.ComputeWorldBound(prim).ComputeAlignedRange() + if rng.IsEmpty(): continue + s = rng.GetSize() + dx, dy, dz = float(s[0]), float(s[1]), float(s[2]) + diag = (dx*dx + dy*dy + dz*dz) ** 0.5 + results.append({ + "path": str(prim.GetPath()), "verts": len(pts), + "diag": diag, "dx": dx, "dy": dy, "dz": dz, + }) + + await _yield() + if not results: + print("[result] no meshes to analyze.") + return + + stage_rng = bbox_cache.ComputeWorldBound(stage.GetPseudoRoot()).ComputeAlignedRange() + if not stage_rng.IsEmpty(): + s = stage_rng.GetSize() + stage_diag = (float(s[0])**2 + float(s[1])**2 + float(s[2])**2) ** 0.5 + else: + stage_diag = 0.0 + + diags = sorted(r["diag"] for r in results) + n_total = len(diags) + print(f"\n Total meshes (visible) : {n_total:,}") + print(f" Stage bbox diag : {stage_diag:.3f}") + print(f" Min mesh diag : {diags[0]:.6f}") + print(f" Median mesh diag : {diags[n_total//2]:.6f}") + print(f" Max mesh diag : {diags[-1]:.6f}\n") + + print("=" * 78) + print("USD threshold preview — how many meshes would be removed at threshold X") + print("=" * 78) + print(f" {'threshold (world units)':<30} {'remove':>10} {'remove %':>10} " + f"{'remove % stage diag':>22}") + for t in THRESHOLDS_WORLD_UNITS: + kill = sum(1 for d in diags if d < t) + pct = (kill / n_total) * 100.0 + pct_stage = (t / stage_diag * 100.0) if stage_diag > 0 else 0.0 + print(f" {t:<30} {kill:>10,} {pct:>9.2f}% {pct_stage:>21.4f}%") + print() + await _yield() + + smallest = sorted(results, key=lambda r: r["diag"]) + print("=" * 78) + print(f"TOP {TOP_N} SMALLEST MESHES (by world bbox diagonal)") + print("=" * 78) + print(f" {'#':>3} {'diag':>12} {'verts':>10} " + f"{'dx':>10} {'dy':>10} {'dz':>10} path") + for i, r in enumerate(smallest[:TOP_N], 1): + print(f" {i:>3} {r['diag']:>12.6f} {r['verts']:>10,} " + f"{r['dx']:>10.4f} {r['dy']:>10.4f} {r['dz']:>10.4f} {r['path']}") + print() + await _yield() + + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "verts", "diag", "dx", "dy", "dz"]) + for r in smallest: + w.writerow([r["path"], r["verts"], + f"{r['diag']:.6f}", f"{r['dx']:.6f}", + f"{r['dy']:.6f}", f"{r['dz']:.6f}"]) + print(f"[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"[csv] FAILED: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _small_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + print("[error] exception in async task:") + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_SMALL_TASK = asyncio.ensure_future(_wrapped()) +_SMALL_TASK.add_done_callback(_on_done) +print("[started] async small-objects scan — cell returns now.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/05_slab.py b/dev/samples/05_slab.py new file mode 100644 index 0000000..0b5bafe --- /dev/null +++ b/dev/samples/05_slab.py @@ -0,0 +1,252 @@ +# ============================================================================= +# Slab Analyzer — ASYNC, USD-based (no direct SO op for slab detection) +# ----------------------------------------------------------------------------- +# Detects single-mesh "slabs" — flat plates that span most of the stage. +# Classifies: +# - Floor slab : huge XY footprint, very thin Z +# - Wall slab : huge XZ or YZ footprint, very thin in the remaining axis +# - Generic slab : aspect ratio max/min > THIN_ASPECT +# - Coverage : single mesh covers > COVERAGE_PCT of stage XY footprint +# +# Non-blocking. COUNT/DIMENSION only. +# Usage: paste, Ctrl+Enter. Re-running cancels previous. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 20 +YIELD_EVERY = 200 +THIN_ASPECT = 20.0 # max-dim / min-dim ratio to flag as plate +COVERAGE_PCT = 30.0 # % of stage XY footprint +INCLUDE_INSIDE_INSTANCES = True +OUTPUT_CSV = "./slabs.csv" +STAGE_PATH = None +# --------------------------------------------------------------------------- + +import asyncio +import csv +import traceback +from pxr import Usd, UsdGeom + +import omni.usd +import omni.kit.app + +_GLOBAL = globals() +_prev = _GLOBAL.get("_SLAB_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _classify_slab(dx, dy, dz): + dims = sorted([dx, dy, dz]) # ascending + min_d, mid_d, max_d = dims + if min_d <= 0: + return None + aspect = max_d / min_d + if aspect < THIN_ASPECT: + return None + # Find the "thin" axis + if dz == min_d: + return "Floor (thin Z)" + if dy == min_d: + return "Wall (thin Y)" + if dx == min_d: + return "Wall (thin X)" + return "Slab" + + +async def _slab_main(): + # ---- Stage ------------------------------------------------------------- + if STAGE_PATH: + stage = Usd.Stage.Open(STAGE_PATH) + print(f"[opened] {STAGE_PATH}") + else: + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] No stage open.") + return + print(f"[using current stage] {stage.GetRootLayer().identifier}\n") + + bbox_cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), + includedPurposes=[UsdGeom.Tokens.default_, + UsdGeom.Tokens.render], + useExtentsHint=True) + + # Stage bbox + stage_rng = bbox_cache.ComputeWorldBound(stage.GetPseudoRoot()).ComputeAlignedRange() + if stage_rng.IsEmpty(): + print("[error] Stage bbox is empty.") + return + s = stage_rng.GetSize() + stage_dx, stage_dy, stage_dz = float(s[0]), float(s[1]), float(s[2]) + stage_diag = (stage_dx**2 + stage_dy**2 + stage_dz**2) ** 0.5 + stage_xy_footprint = stage_dx * stage_dy + stage_xz_footprint = stage_dx * stage_dz + stage_yz_footprint = stage_dy * stage_dz + + print("=" * 78) + print("STAGE BBOX") + print("=" * 78) + print(f" dx={stage_dx:.3f} dy={stage_dy:.3f} dz={stage_dz:.3f}") + print(f" diag={stage_diag:.3f}") + print(f" XY footprint={stage_xy_footprint:.3f} " + f"XZ={stage_xz_footprint:.3f} YZ={stage_yz_footprint:.3f}\n") + + # ---- Traverse ---------------------------------------------------------- + prim_iter = (Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) + if INCLUDE_INSIDE_INSTANCES else stage.Traverse()) + + slabs = [] + n = 0 + for prim in prim_iter: + if not prim.IsA(UsdGeom.Mesh): continue + n += 1 + if n % YIELD_EVERY == 0: await _yield() + if n % 5000 == 0: + print(f" ... {n} meshes scanned (slabs so far={len(slabs):,})") + + if UsdGeom.Imageable(prim).ComputeVisibility() == UsdGeom.Tokens.invisible: + continue + + mesh = UsdGeom.Mesh(prim) + pts = mesh.GetPointsAttr().Get() + if not pts: continue + verts = len(pts) + + rng = bbox_cache.ComputeWorldBound(prim).ComputeAlignedRange() + if rng.IsEmpty(): continue + m = rng.GetSize() + dx, dy, dz = float(m[0]), float(m[1]), float(m[2]) + if dx <= 0 or dy <= 0 or dz <= 0: continue + + classification = _classify_slab(dx, dy, dz) + if classification is None: continue + + # Footprint coverage (depending on slab orientation) + if classification == "Floor (thin Z)": + coverage = (dx * dy / stage_xy_footprint * 100.0) if stage_xy_footprint > 0 else 0 + coverage_label = "%XY" + elif classification == "Wall (thin Y)": + coverage = (dx * dz / stage_xz_footprint * 100.0) if stage_xz_footprint > 0 else 0 + coverage_label = "%XZ" + elif classification == "Wall (thin X)": + coverage = (dy * dz / stage_yz_footprint * 100.0) if stage_yz_footprint > 0 else 0 + coverage_label = "%YZ" + else: + coverage = 0 + coverage_label = "n/a" + + dims = sorted([dx, dy, dz]) + aspect = dims[2] / dims[0] + + slabs.append({ + "path": str(prim.GetPath()), + "instance_proxy": bool(prim.IsInstanceProxy()), + "class": classification, + "verts": verts, + "dx": dx, "dy": dy, "dz": dz, + "aspect": aspect, + "coverage_pct": coverage, + "coverage_label": coverage_label, + }) + + await _yield() + print(f"\n Total meshes scanned : {n:,}") + print(f" Slab candidates (aspect>{THIN_ASPECT:.0f}x): {len(slabs):,}\n") + + if not slabs: + print("[result] No slab candidates found.") + return + + # ---- Strong coverage candidates --------------------------------------- + strong = sorted([r for r in slabs if r["coverage_pct"] > COVERAGE_PCT], + key=lambda r: r["coverage_pct"], reverse=True) + print("=" * 78) + print(f"STRONG SLAB CANDIDATES (footprint coverage > {COVERAGE_PCT:.0f}%)") + print("=" * 78) + if not strong: + print(f" (none — no slab covers more than {COVERAGE_PCT:.0f}% of its footprint)") + else: + for i, r in enumerate(strong[:TOP_N], 1): + print(f" {i:>3} coverage={r['coverage_pct']:>6.1f}{r['coverage_label']} " + f"class={r['class']:<20} verts={r['verts']:>10,} " + f"aspect={r['aspect']:>8.1f}x path={r['path']}") + if len(strong) > TOP_N: + print(f" ... and {len(strong) - TOP_N} more") + print() + await _yield() + + # ---- All slabs by aspect --------------------------------------------- + by_aspect = sorted(slabs, key=lambda r: r["aspect"], reverse=True) + print("=" * 78) + print(f"TOP {TOP_N} SLAB CANDIDATES BY ASPECT RATIO") + print("=" * 78) + print(f" {'#':>3} {'aspect':>10} {'class':<20} " + f"{'verts':>10} {'cover':>8} " + f"{'dx':>8} {'dy':>8} {'dz':>8} path") + for i, r in enumerate(by_aspect[:TOP_N], 1): + print(f" {i:>3} {r['aspect']:>9.1f}x {r['class']:<20} " + f"{r['verts']:>10,} {r['coverage_pct']:>6.1f}{r['coverage_label']} " + f"{r['dx']:>8.2f} {r['dy']:>8.2f} {r['dz']:>8.2f} {r['path']}") + print() + await _yield() + + # ---- Dead-memory flag (huge bbox + low verts) ------------------------- + dead = [r for r in slabs if r["verts"] < 100 and r["aspect"] > THIN_ASPECT] + dead.sort(key=lambda r: r["coverage_pct"], reverse=True) + if dead: + print("=" * 78) + print("DEAD-MEMORY CANDIDATES (huge slab but <100 verts — likely placeholder)") + print("=" * 78) + for r in dead[:TOP_N]: + print(f" verts={r['verts']:>6,} cover={r['coverage_pct']:>6.1f}{r['coverage_label']} " + f"class={r['class']:<20} path={r['path']}") + if len(dead) > TOP_N: + print(f" ... and {len(dead) - TOP_N} more") + print() + await _yield() + + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "instance_proxy", "class", "verts", + "dx", "dy", "dz", "aspect", + "coverage_pct", "coverage_label"]) + for r in by_aspect: + w.writerow([r["path"], r["instance_proxy"], r["class"], + r["verts"], + f"{r['dx']:.4f}", f"{r['dy']:.4f}", f"{r['dz']:.4f}", + f"{r['aspect']:.2f}", f"{r['coverage_pct']:.4f}", + r["coverage_label"]]) + print(f"[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"[csv] FAILED: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _slab_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + print("[error] exception in async task:") + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_SLAB_TASK = asyncio.ensure_future(_wrapped()) +_SLAB_TASK.add_done_callback(_on_done) +print("[started] async slab scan — cell returns now.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/06_instancing_opportunities.py b/dev/samples/06_instancing_opportunities.py new file mode 100644 index 0000000..b1cb6d6 --- /dev/null +++ b/dev/samples/06_instancing_opportunities.py @@ -0,0 +1,342 @@ +# ============================================================================= +# Instancing Opportunities Analyzer — ASYNC +# ----------------------------------------------------------------------------- +# Detects meshes that COULD be instanced but currently are NOT. +# (Inverse of 02_instancing.py which measures CURRENT instancing.) +# +# Verified facts (read directly from C++ source): +# - Op name (Operation ctor): "deduplicateGeometry" +# scene-optimizer-core/source/operations/deduplicateGeometry/DeduplicateGeometry.cpp +# omniverse-scene-optimizer Kit ext BUNDLES this op (works at runtime). +# - Analysis shape: [ [path, path, ...], [path, path, ...], ... ] +# (list of sets; each set is one duplicate group) +# - "deduplicateHierarchies" is in scene-optimizer-core but is NOT YET BUNDLED +# in the omniverse-scene-optimizer Kit extension (changelog confirms — no +# mentions of "Deduplicate Hierarchies", grep of omniverse repo returns 0). +# Runtime registry will reject it. We DO NOT call it. +# +# Strategy: +# 1) Call SO deduplicateGeometry (analysis mode) for mesh-level dups +# 2) USD pass: signature-hash grouping by (verts, faces, quantized bbox) +# Catches dups that SO might miss (different vertex order etc.) +# 3) Path-pattern detection for repeated reference paths +# +# Read-only. Re-running cancels previous. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 20 +YIELD_EVERY = 200 +MIN_DUP_GROUP_SIZE = 2 +MIN_VERTS_FOR_REPORT = 10 +BBOX_QUANTIZE = 0.001 +INCLUDE_INSIDE_INSTANCES = False +OUTPUT_CSV = "./instancing_opportunities.csv" +STAGE_PATH = None +ENABLE_USD_FALLBACK = True +# --------------------------------------------------------------------------- + +import asyncio +import csv +import re +import traceback +from collections import defaultdict +from pxr import Usd, UsdGeom + +import omni.usd +import omni.kit.app + +_GLOBAL = globals() +_prev = _GLOBAL.get("_OPPORTUNITIES_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _get_so_core(): + try: + from omni.scene.optimizer.core import SceneOptimizerCore + except ImportError as e: + print(f"[so] omni.scene.optimizer.core not loadable: {e}") + return None, None + try: + core = SceneOptimizerCore.getInstance() + ops = set() + try: + for o in core.getOperations(): + name = getattr(o, "name", None) or getattr(o, "getName", lambda: None)() + if isinstance(name, str): ops.add(name) + elif isinstance(o, str): ops.add(o) + else: ops.add(str(o)) + except Exception as e: + print(f"[so] getOperations() failed: {e}") + return core, ops + except Exception as e: + print(f"[so] failed to acquire SceneOptimizerCore: {e}") + return None, None + + +def _call_so(core, op_name, args, stage, available_ops): + if available_ops and op_name not in available_ops: + print(f"[so] '{op_name}' NOT registered in this Kit — using USD fallback.") + return None + try: + from omni.scene.optimizer.core import ExecutionContext + ctx = ExecutionContext() + ctx.set_stage(stage) + ctx.analysisMode = 1 + success, error, output = core.executeOperation(op_name, ctx, args) + try: ctx.remove_stage() + except Exception: pass + if not success: + print(f"[so] {op_name} returned failure: {error!r}") + return None + if output is None: + print(f"[so] {op_name} returned no output.") + return None + if isinstance(output, dict): + return output.get("analysis", output) + return output + except Exception as e: + print(f"[so] direct call failed for {op_name}: {e}") + return None + + +_INDEX_RE = re.compile(r"(_\d+|\.\d+|\[\d+\])$") + + +def _normalize_path(path_str): + parts = path_str.split("/") + return "/".join(_INDEX_RE.sub("", p) for p in parts) + + +def _summarize_dedup_geometry(analysis): + print("=" * 78) + print("SO deduplicateGeometry — analysis mode (mesh-level)") + print("=" * 78) + if analysis is None: + print(" (analysis not available — see above for reason)") + return + if not isinstance(analysis, (list, tuple)): + print(f" (non-list result: {type(analysis).__name__})") + return + groups = [list(g) for g in analysis if isinstance(g, (list, tuple))] + groups.sort(key=len, reverse=True) + total_meshes = sum(len(g) for g in groups) + potential_saving = sum(len(g) - 1 for g in groups) + print(f" Duplicate groups : {len(groups):,}") + print(f" Total meshes inside groups : {total_meshes:,}") + print(f" Potential mesh reductions : {potential_saving:,}") + print(f" (if every group → 1 prototype + N instances)\n") + if groups: + print(f" TOP {TOP_N} groups by copy count:") + print(f" {'#':>3} {'copies':>7} sample-path") + for i, g in enumerate(groups[:TOP_N], 1): + print(f" {i:>3} {len(g):>7} {g[0]}") + for p in g[1:4]: + print(f" └─ {p}") + if len(g) > 4: + print(f" └─ ... and {len(g) - 4} more") + print() + + +async def _main(): + if STAGE_PATH: + stage = Usd.Stage.Open(STAGE_PATH) + print(f"[opened] {STAGE_PATH}") + else: + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] No stage open.") + return + print(f"[using current stage] {stage.GetRootLayer().identifier}\n") + + core, available_ops = _get_so_core() + if core is not None and available_ops is not None: + print(f"[so] SO available — {len(available_ops)} registered operations.") + # If user wants to know what's available, set ENV var or look at this print. + + # ---- 1) SO: deduplicateGeometry only (Hierarchies not bundled) -------- + # Default args from test_operation_deduplicate_geometry.py: + # {"meshPrimPaths":[], "considerDeepTransforms":True, "tolerance":0.05, + # "duplicateMethod":0 (instanceableReference), "fuzzy":False, + # "useGpu":False, "allowScaling":False} + geom_analysis = None + if core is not None: + geom_args = {"meshPrimPaths": [], "considerDeepTransforms": True, + "tolerance": 0.05, "duplicateMethod": 0, + "fuzzy": False, "useGpu": False, "allowScaling": False} + geom_analysis = _call_so(core, "deduplicateGeometry", geom_args, stage, available_ops) + await _yield() + _summarize_dedup_geometry(geom_analysis) + await _yield() + + if core is None and not ENABLE_USD_FALLBACK: + print("[exit] SO unavailable and ENABLE_USD_FALLBACK=False, stopping.") + return + + # ---- 2) USD supplement: mesh signature grouping ----------------------- + print("=" * 78) + print("USD supplement — mesh signature grouping") + print("(group meshes by (verts, faces, bbox dx/dy/dz) → if multiple have") + print(" the same signature, they are CANDIDATES for instancing)") + print("=" * 78) + + bbox_cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), + includedPurposes=[UsdGeom.Tokens.default_, + UsdGeom.Tokens.render], + useExtentsHint=True) + + prim_iter = (Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) + if INCLUDE_INSIDE_INSTANCES else stage.Traverse()) + + sig_groups = defaultdict(list) + n_meshes = 0 + n_skipped_instanced = 0 + n_skipped_small = 0 + + for prim in prim_iter: + if not prim.IsA(UsdGeom.Mesh): continue + n_meshes += 1 + if n_meshes % YIELD_EVERY == 0: await _yield() + if n_meshes % 5000 == 0: + print(f" ... {n_meshes} meshes scanned " + f"(unique signatures so far={len(sig_groups):,})") + + if prim.IsInstanceProxy() or prim.IsInstance(): + n_skipped_instanced += 1 + continue + if UsdGeom.Imageable(prim).ComputeVisibility() == UsdGeom.Tokens.invisible: + continue + + mesh = UsdGeom.Mesh(prim) + pts = mesh.GetPointsAttr().Get() + if not pts: continue + verts = len(pts) + if verts < MIN_VERTS_FOR_REPORT: + n_skipped_small += 1 + continue + + fvc = mesh.GetFaceVertexCountsAttr().Get() + faces = len(fvc) if fvc else 0 + + rng = bbox_cache.ComputeWorldBound(prim).ComputeAlignedRange() + if rng.IsEmpty(): continue + s = rng.GetSize() + dx, dy, dz = float(s[0]), float(s[1]), float(s[2]) + + q = BBOX_QUANTIZE + sig = (verts, faces, + round(dx / q) * q, round(dy / q) * q, round(dz / q) * q) + sig_groups[sig].append({ + "path": str(prim.GetPath()), "verts": verts, "faces": faces, + "dx": dx, "dy": dy, "dz": dz, + }) + + await _yield() + print(f"\n Total meshes scanned : {n_meshes:,}") + print(f" Skipped (already instanced) : {n_skipped_instanced:,}") + print(f" Skipped (verts < {MIN_VERTS_FOR_REPORT}) : {n_skipped_small:,}") + print(f" Unique mesh signatures : {len(sig_groups):,}\n") + + dup_groups = [(sig, members) for sig, members in sig_groups.items() + if len(members) >= MIN_DUP_GROUP_SIZE] + dup_groups.sort(key=lambda x: (len(x[1]), x[0][0]), reverse=True) + + total_dup_meshes = sum(len(m) for _, m in dup_groups) + total_savings = sum(len(m) - 1 for _, m in dup_groups) + print(f" Duplicate groups (>= {MIN_DUP_GROUP_SIZE} copies): {len(dup_groups):,}") + print(f" Total meshes inside groups : {total_dup_meshes:,}") + print(f" Potential mesh reductions : {total_savings:,}") + print(f" (if every group → 1 prototype + N instances)\n") + await _yield() + + if dup_groups: + print("=" * 78) + print(f"TOP {TOP_N} DUPLICATE GROUPS (by copy count)") + print("=" * 78) + print(f" {'#':>3} {'copies':>7} {'verts':>10} {'faces':>10} " + f"{'dx':>8} {'dy':>8} {'dz':>8} sample-path") + for i, (sig, members) in enumerate(dup_groups[:TOP_N], 1): + verts, faces, dx, dy, dz = sig + sample = members[0]["path"] + print(f" {i:>3} {len(members):>7} {verts:>10,} {faces:>10,} " + f"{dx:>8.3f} {dy:>8.3f} {dz:>8.3f} {sample}") + for m in members[1:4]: + print(f" └─ {m['path']}") + if len(members) > 4: + print(f" └─ ... and {len(members) - 4} more") + print() + await _yield() + + # ---- 3) Path-pattern detection ---------------------------------------- + print("=" * 78) + print("PATH-PATTERN DETECTION (paths differing only by trailing _N / .N)") + print("=" * 78) + path_groups = defaultdict(list) + for sig, members in sig_groups.items(): + for m in members: + norm = _normalize_path(m["path"]) + path_groups[norm].append(m["path"]) + + path_dup = [(norm, paths) for norm, paths in path_groups.items() + if len(paths) >= MIN_DUP_GROUP_SIZE] + path_dup.sort(key=lambda x: len(x[1]), reverse=True) + + if not path_dup: + print(" (none — no path-pattern duplicates detected)\n") + else: + print(f" Path-pattern groups: {len(path_dup):,}") + print(f" {'#':>3} {'copies':>7} normalized-path") + for i, (norm, paths) in enumerate(path_dup[:TOP_N], 1): + print(f" {i:>3} {len(paths):>7} {norm}") + for p in paths[:3]: + print(f" └─ {p}") + if len(paths) > 3: + print(f" └─ ... and {len(paths) - 3} more") + print() + await _yield() + + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["group_id", "copies", "verts", "faces", + "dx", "dy", "dz", "path"]) + for gid, (sig, members) in enumerate(dup_groups, 1): + verts, faces, dx, dy, dz = sig + for m in members: + w.writerow([gid, len(members), verts, faces, + f"{dx:.4f}", f"{dy:.4f}", f"{dz:.4f}", + m["path"]]) + print(f"[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"[csv] FAILED: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + print("[error] exception in async task:") + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_OPPORTUNITIES_TASK = asyncio.ensure_future(_wrapped()) +_OPPORTUNITIES_TASK.add_done_callback(_on_done) +print("[started] async instancing-opportunities scan — cell returns now.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/07_so_printstats.py b/dev/samples/07_so_printstats.py new file mode 100644 index 0000000..ae6eade --- /dev/null +++ b/dev/samples/07_so_printstats.py @@ -0,0 +1,192 @@ +# ============================================================================= +# SO printStats Analyzer — ASYNC, uses Scene Optimizer's printStats op +# ----------------------------------------------------------------------------- +# Verified facts (read directly from omniverse-scene-optimizer C++ source): +# - Op name (Operation ctor): "printStats" (in stats/PrintStats.cpp) +# Operation("printStats", "Stats", ...) +# - Module import path: omni.scene.optimizer.core +# - Args (PrintStats.cpp): countPrimvars, splitCollocatedPoints, time +# - Analysis output: dict (nested) with full stats payload +# (lines 385-386: analysis["analysis"] = payload) +# - analysisMode: ctx.analysisMode = 1 (int, not bool) +# +# Comparison with dev/samples/export_stage_stats.py: +# - That script reads CACHED stats via omni.stats.get_stats_interface() +# filtered by scope "Scene Optimizer". Requires the op to have run earlier. +# - This script INVOKES the printStats op directly (analysis mode) and prints +# its raw analysis payload. Self-contained, no scope-cache dependency. +# +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +COUNT_PRIMVARS = True +SPLIT_COLLOCATED_POINTS = False +TIME = 0.0 +OUTPUT_JSON = "./so_printstats.json" + +# --------------------------------------------------------------------------- +import asyncio +import json +import traceback +import omni.kit.app +import omni.usd + +# ---- Cancel any previous run ----------------------------------------------- +_prev = globals().get("_SO_PRINTSTATS_TASK") +if _prev is not None and not _prev.done(): + _prev.cancel() + + +def _get_so_core(): + """Return (core, available_op_names_set) or (None, None).""" + try: + from omni.scene.optimizer.core import SceneOptimizerCore + except ImportError as e: + print(f"[so] omni.scene.optimizer.core not loadable: {e}") + return None, None + try: + core = SceneOptimizerCore.getInstance() + ops = set() + try: + for o in core.getOperations(): + name = getattr(o, "name", None) or getattr(o, "getName", lambda: None)() + if isinstance(name, str): + ops.add(name) + elif isinstance(o, str): + ops.add(o) + else: + ops.add(str(o)) + except Exception as e: + print(f"[so] getOperations() failed: {e}") + return core, ops + except Exception as e: + print(f"[so] failed to acquire SceneOptimizerCore: {e}") + return None, None + + +def _call_so(core, op_name, args, stage, available_ops): + if available_ops and op_name not in available_ops: + print(f"[so] '{op_name}' NOT registered in this Kit — skipping.") + return None + try: + from omni.scene.optimizer.core import ExecutionContext + ctx = ExecutionContext() + ctx.set_stage(stage) + ctx.analysisMode = 1 + success, error, output = core.executeOperation(op_name, ctx, args) + try: + ctx.remove_stage() + except Exception: + pass + if not success: + print(f"[so] {op_name} returned failure: {error!r}") + return None + if isinstance(output, dict): + return output.get("analysis", output) + return output + except Exception as e: + print(f"[so] direct call failed for {op_name}: {e}") + return None + + +def _walk_print(d, indent=0): + """Pretty-print nested dict / list result.""" + pad = " " * indent + if isinstance(d, dict): + for k, v in d.items(): + if isinstance(v, (dict, list)) and v: + print(f"{pad}{k}:") + _walk_print(v, indent + 1) + else: + print(f"{pad}{k}: {v}") + elif isinstance(d, list): + if len(d) > 10: + for x in d[:10]: + _walk_print(x, indent) + print(f"{pad}... and {len(d) - 10} more") + else: + for x in d: + _walk_print(x, indent) + else: + print(f"{pad}{d}") + + +async def _printstats_main(): + stage = omni.usd.get_context().get_stage() + if not stage: + print("[error] no stage open") + return + root = stage.GetRootLayer().identifier if stage.GetRootLayer() else "?" + print(f"[stage] {root}") + + core, available_ops = _get_so_core() + if core is None: + print("[error] SO core not available — cannot run printStats") + return + + print(f"[so] {len(available_ops)} ops registered in this Kit") + if "printStats" not in available_ops: + print("[so] 'printStats' NOT registered — likely missing extension.") + print("[so] Available ops sample:") + for op in sorted(list(available_ops))[:30]: + print(f" {op}") + return + + args = { + "countPrimvars": bool(COUNT_PRIMVARS), + "splitCollocatedPoints": bool(SPLIT_COLLOCATED_POINTS), + "time": float(TIME), + } + + print("=" * 78) + print("SO printStats — analysis mode") + print("=" * 78) + print(f" args: {args}") + print() + + await omni.kit.app.get_app().next_update_async() + + analysis = _call_so(core, "printStats", args, stage, available_ops) + if analysis is None: + print(" (no analysis returned — see [so] log above)") + return + + print("Analysis payload (full):") + print("-" * 78) + _walk_print(analysis) + print("-" * 78) + + # Also dump JSON to file + try: + with open(OUTPUT_JSON, "w") as f: + json.dump(analysis, f, indent=2, default=str) + print(f"\n[json] wrote {OUTPUT_JSON}") + except Exception as e: + print(f"\n[json] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _printstats_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(task): + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + print(f"[task error] {exc!r}") + + +_SO_PRINTSTATS_TASK = asyncio.ensure_future(_wrapped()) +_SO_PRINTSTATS_TASK.add_done_callback(_on_done) +print("[started] async SO printStats — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/08_validators.py b/dev/samples/08_validators.py new file mode 100644 index 0000000..90fa28b --- /dev/null +++ b/dev/samples/08_validators.py @@ -0,0 +1,233 @@ +# ============================================================================= +# SO Validators Analyzer — ASYNC, runs Find* ops in analysis mode +# ----------------------------------------------------------------------------- +# Verified facts (read directly from omniverse-scene-optimizer C++ source): +# +# findCoincidingGeometry (FindCoincidingGeometry.cpp): +# - Operation ctor: "findCoincidingGeometry" +# - Args: primPaths, tolerance, offset, fuzzy +# - Analysis output: {"coincidingGeometry": []} +# +# findOccludedMeshes (FindOccludedMeshes.cpp): +# - Operation ctor: "findOccludedMeshes" +# - Args: paths, checkTransparency, action, useGpu +# - Analysis output: {"occludedMeshes": []} +# - WARNING: slow op. Large stages may take minutes. +# +# findOverlappingMeshes (FindOverlappingMeshes / FindOverlappingMeshesOperation.cpp): +# - Operation ctor: "findOverlappingMeshes" (directory: findMeshOverlaps) +# - Args: paths, useGpu (most other args commented out in src) +# - Analysis output: {"suppressedOverlaps": uint, "overlappingMeshes": []} +# +# All called with ctx.analysisMode = 1 (read-only). +# Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +RUN_COINCIDING = True +RUN_OCCLUDED = False # set True if you have time — this op is SLOW +RUN_OVERLAPPING = True + +COINCIDING_TOLERANCE = 0.001 +COINCIDING_OFFSET = 0.0 +COINCIDING_FUZZY = False + +OCCLUDED_CHECK_TRANSPARENCY = False +OCCLUDED_USE_GPU = True + +OVERLAPPING_USE_GPU = True + +TOP_N = 20 +OUTPUT_CSV = "./validators.csv" + +# --------------------------------------------------------------------------- +import asyncio +import csv +import traceback +import omni.kit.app +import omni.usd + +# ---- Cancel any previous run ----------------------------------------------- +_prev = globals().get("_VALIDATORS_TASK") +if _prev is not None and not _prev.done(): + _prev.cancel() + + +def _get_so_core(): + try: + from omni.scene.optimizer.core import SceneOptimizerCore + except ImportError as e: + print(f"[so] omni.scene.optimizer.core not loadable: {e}") + return None, None + try: + core = SceneOptimizerCore.getInstance() + ops = set() + try: + for o in core.getOperations(): + name = getattr(o, "name", None) or getattr(o, "getName", lambda: None)() + if isinstance(name, str): + ops.add(name) + elif isinstance(o, str): + ops.add(o) + else: + ops.add(str(o)) + except Exception as e: + print(f"[so] getOperations() failed: {e}") + return core, ops + except Exception as e: + print(f"[so] failed to acquire SceneOptimizerCore: {e}") + return None, None + + +def _call_so(core, op_name, args, stage, available_ops): + if available_ops and op_name not in available_ops: + print(f"[so] '{op_name}' NOT registered in this Kit — skipping.") + return None + try: + from omni.scene.optimizer.core import ExecutionContext + ctx = ExecutionContext() + ctx.set_stage(stage) + ctx.analysisMode = 1 + success, error, output = core.executeOperation(op_name, ctx, args) + try: + ctx.remove_stage() + except Exception: + pass + if not success: + print(f"[so] {op_name} returned failure: {error!r}") + return None + if isinstance(output, dict): + return output.get("analysis", output) + return output + except Exception as e: + print(f"[so] direct call failed for {op_name}: {e}") + return None + + +def _print_paths(label, paths, limit=TOP_N): + if not paths: + print(f" {label}: 0 entries") + return + print(f" {label}: {len(paths):,} entries") + for i, p in enumerate(paths[:limit], 1): + print(f" {i:>3} {p}") + if len(paths) > limit: + print(f" ... and {len(paths) - limit:,} more") + + +async def _validators_main(): + stage = omni.usd.get_context().get_stage() + if not stage: + print("[error] no stage open") + return + root = stage.GetRootLayer().identifier if stage.GetRootLayer() else "?" + print(f"[stage] {root}") + + core, available_ops = _get_so_core() + if core is None: + print("[error] SO core not available") + return + + print(f"[so] {len(available_ops)} ops registered") + + csv_rows = [] + + # -------- findCoincidingGeometry -------- + if RUN_COINCIDING: + print() + print("=" * 78) + print("SO findCoincidingGeometry — analysis mode") + print("=" * 78) + args = { + "primPaths": [], + "tolerance": float(COINCIDING_TOLERANCE), + "offset": float(COINCIDING_OFFSET), + "fuzzy": bool(COINCIDING_FUZZY), + } + print(f" args: {args}") + await omni.kit.app.get_app().next_update_async() + analysis = _call_so(core, "findCoincidingGeometry", args, stage, available_ops) + if isinstance(analysis, dict): + coinc = analysis.get("coincidingGeometry", []) + _print_paths("coincidingGeometry", list(coinc)) + for p in coinc: + csv_rows.append({"validator": "coinciding", "path": str(p)}) + + # -------- findOccludedMeshes -------- + if RUN_OCCLUDED: + print() + print("=" * 78) + print("SO findOccludedMeshes — analysis mode (WARNING: slow)") + print("=" * 78) + args = { + "paths": [], + "checkTransparency": bool(OCCLUDED_CHECK_TRANSPARENCY), + "action": 0, # default action; analysis mode ignores anyway + "useGpu": bool(OCCLUDED_USE_GPU), + } + print(f" args: {args}") + await omni.kit.app.get_app().next_update_async() + analysis = _call_so(core, "findOccludedMeshes", args, stage, available_ops) + if isinstance(analysis, dict): + occl = analysis.get("occludedMeshes", []) + _print_paths("occludedMeshes", list(occl)) + for p in occl: + csv_rows.append({"validator": "occluded", "path": str(p)}) + + # -------- findOverlappingMeshes -------- + if RUN_OVERLAPPING: + print() + print("=" * 78) + print("SO findOverlappingMeshes — analysis mode") + print("=" * 78) + args = { + "paths": [], + "useGpu": bool(OVERLAPPING_USE_GPU), + } + print(f" args: {args}") + await omni.kit.app.get_app().next_update_async() + analysis = _call_so(core, "findOverlappingMeshes", args, stage, available_ops) + if isinstance(analysis, dict): + ovr = analysis.get("overlappingMeshes", []) + suppressed = analysis.get("suppressedOverlaps", 0) + print(f" suppressedOverlaps: {suppressed}") + _print_paths("overlappingMeshes", list(ovr)) + for p in ovr: + csv_rows.append({"validator": "overlapping", "path": str(p)}) + + # -------- CSV -------- + if csv_rows: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=["validator", "path"]) + w.writeheader() + w.writerows(csv_rows) + print(f"\n[csv] wrote {OUTPUT_CSV} ({len(csv_rows):,} rows)") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _validators_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(task): + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + print(f"[task error] {exc!r}") + + +_VALIDATORS_TASK = asyncio.ensure_future(_wrapped()) +_VALIDATORS_TASK.add_done_callback(_on_done) +print("[started] async validators — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/09_hierarchy_depth.py b/dev/samples/09_hierarchy_depth.py new file mode 100644 index 0000000..03a5895 --- /dev/null +++ b/dev/samples/09_hierarchy_depth.py @@ -0,0 +1,200 @@ +# ============================================================================= +# Hierarchy Depth Analyzer — ASYNC, USD-only +# ----------------------------------------------------------------------------- +# Reports: +# - Max / mean / median / p95 / p99 depth across all prims +# - Depth histogram (count of prims at each depth level) +# - Branch-factor distribution (children per non-leaf prim) +# - Top-N deepest prim paths +# - Top-N widest prims (most direct children) +# +# IP-safe: COUNT only — paths are needed but no geometry data. +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 20 +YIELD_EVERY = 5000 # yield to Kit UI every N prims +OUTPUT_CSV = "./hierarchy_depth.csv" + +# --------------------------------------------------------------------------- +import asyncio +import csv +import math +import statistics +import traceback +from collections import Counter, defaultdict +import omni.kit.app +import omni.usd + +# ---- Cancel any previous run ----------------------------------------------- +_prev = globals().get("_HIERARCHY_DEPTH_TASK") +if _prev is not None and not _prev.done(): + _prev.cancel() + + +def _pct(values, p): + if not values: + return 0 + s = sorted(values) + k = max(0, min(len(s) - 1, int(math.ceil(p * len(s))) - 1)) + return s[k] + + +async def _hier_main(): + stage = omni.usd.get_context().get_stage() + if not stage: + print("[error] no stage open") + return + root = stage.GetRootLayer().identifier if stage.GetRootLayer() else "?" + print(f"[stage] {root}") + + depth_counter = Counter() + branch_counter = Counter() + deepest = [] # (depth, path_str) + widest = [] # (n_children, path_str) + + n = 0 + n_yield_chunk = 0 + + # Use TraverseAll so we don't skip instance proxies / disabled prims + for prim in stage.TraverseAll(): + path = prim.GetPath() + depth = path.pathElementCount # absolute depth from root + depth_counter[depth] += 1 + + kids = prim.GetChildren() + nkids = len(kids) + if nkids > 0: + branch_counter[nkids] += 1 + widest.append((nkids, str(path))) + + deepest.append((depth, str(path))) + + n += 1 + n_yield_chunk += 1 + if n_yield_chunk >= YIELD_EVERY: + n_yield_chunk = 0 + await omni.kit.app.get_app().next_update_async() + if (n % (YIELD_EVERY * 5)) == 0: + print(f" ... {n:,} prims scanned") + + if n == 0: + print("[error] stage has 0 prims") + return + + # ---- Stats ---- + depths = [d for d, _ in deepest] + max_d = max(depths) + mean_d = statistics.mean(depths) + med_d = statistics.median(depths) + p95 = _pct(depths, 0.95) + p99 = _pct(depths, 0.99) + + print() + print("=" * 78) + print("HIERARCHY DEPTH SUMMARY") + print("=" * 78) + print(f" Total prims : {n:,}") + print(f" Max depth : {max_d}") + print(f" Mean depth : {mean_d:.2f}") + print(f" Median depth : {med_d}") + print(f" p95 depth : {p95}") + print(f" p99 depth : {p99}") + + print() + print("=" * 78) + print("DEPTH HISTOGRAM (prim count by depth)") + print("=" * 78) + max_depth_seen = max(depth_counter.keys()) + max_count = max(depth_counter.values()) + bar_max = 50 + for d in range(0, max_depth_seen + 1): + c = depth_counter.get(d, 0) + bar = "#" * max(0, int(bar_max * c / max_count)) if max_count else "" + print(f" depth {d:>3} {c:>10,} {bar}") + + print() + print("=" * 78) + print("BRANCH FACTOR DISTRIBUTION (children per non-leaf prim)") + print("=" * 78) + if not branch_counter: + print(" (no non-leaf prims)") + else: + # bucket: 1, 2, 3-5, 6-10, 11-50, 51-200, 201-1k, >1k + buckets = [(1, 1), (2, 2), (3, 5), (6, 10), (11, 50), (51, 200), + (201, 1000), (1001, float("inf"))] + bucket_counts = [0] * len(buckets) + for nkids, count in branch_counter.items(): + for i, (lo, hi) in enumerate(buckets): + if lo <= nkids <= hi: + bucket_counts[i] += count + break + for (lo, hi), c in zip(buckets, bucket_counts): + label = f"{lo}" if lo == hi else (f"{lo}-{hi}" if hi != float("inf") else f">{lo - 1}") + print(f" children={label:<12} prims={c:>10,}") + + # ---- Top deepest ---- + deepest.sort(key=lambda x: x[0], reverse=True) + print() + print("=" * 78) + print(f"TOP {TOP_N} DEEPEST PRIMS") + print("=" * 78) + for i, (d, p) in enumerate(deepest[:TOP_N], 1): + print(f" {i:>3} depth={d:>3} {p}") + + # ---- Top widest ---- + widest.sort(key=lambda x: x[0], reverse=True) + print() + print("=" * 78) + print(f"TOP {TOP_N} WIDEST PRIMS (most direct children)") + print("=" * 78) + for i, (k, p) in enumerate(widest[:TOP_N], 1): + print(f" {i:>3} children={k:>7,} {p}") + + # ---- CSV ---- + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["section", "key", "value"]) + w.writerow(["summary", "total_prims", n]) + w.writerow(["summary", "max_depth", max_d]) + w.writerow(["summary", "mean_depth", f"{mean_d:.4f}"]) + w.writerow(["summary", "median_depth", med_d]) + w.writerow(["summary", "p95_depth", p95]) + w.writerow(["summary", "p99_depth", p99]) + for d in sorted(depth_counter.keys()): + w.writerow(["depth_histogram", d, depth_counter[d]]) + for d, p in deepest[:TOP_N]: + w.writerow(["deepest_top", d, p]) + for k, p in widest[:TOP_N]: + w.writerow(["widest_top", k, p]) + print(f"\n[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _hier_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(task): + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + print(f"[task error] {exc!r}") + + +_HIERARCHY_DEPTH_TASK = asyncio.ensure_future(_wrapped()) +_HIERARCHY_DEPTH_TASK.add_done_callback(_on_done) +print("[started] async hierarchy-depth — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/10_pipe_density.py b/dev/samples/10_pipe_density.py new file mode 100644 index 0000000..ab0e8fc --- /dev/null +++ b/dev/samples/10_pipe_density.py @@ -0,0 +1,246 @@ +# ============================================================================= +# Pipe/Cylinder Density Analyzer — ASYNC, USD-only, HEURISTIC +# ----------------------------------------------------------------------------- +# WARNING — this is a HEURISTIC, not a semantic "pipe" detector. +# USD has no concept of "pipe". We detect cylinder-like geometry by bbox shape: +# +# 1) "Elongated" : longest-axis / second-longest-axis > ELONGATION_RATIO +# 2) "Thin-section" : ratio of two short axes >= 0.5 (roughly circular cross-section) +# 3) "Reasonable verts": vertex count between MIN_VERTS and MAX_VERTS +# (cylinders/tubes typically have a moderate vert count) +# +# Outputs: +# - Cylinder-like mesh count +# - Density per stage volume (count / stage_bbox_volume), normalized +# - Length distribution (histogram of long-axis lengths) +# - Axis-alignment ratio: how many are aligned to world X/Y/Z axes +# (rough proxy for "straight run" vs "diagonal/bent") +# - Top-N longest cylinder-like meshes +# +# Limits / caveats (read carefully before quoting numbers): +# - Cannot detect bent pipes — they would be classified as bent only if their +# bbox is no longer elongated. A long bent pipe with large bbox dx ≈ dy will +# NOT be elongated and will be MISSED. +# - Cannot tell pipes from beams, columns, cables, tubes, rails. All look +# "cylinder-like" by bbox. +# - "Straight vs curved" full split would need polyline/centerline analysis, +# out of scope for a Script-Editor one-shot. +# +# IP-safe: COUNT/DIMENSION only. +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +ELONGATION_RATIO = 5.0 # long_axis / second_longest > this => elongated +MIN_VERTS = 6 # below this, too trivial +MAX_VERTS = 5000 # above this, probably not a simple cylinder +AXIS_ALIGN_TOLERANCE = 0.10 # short-axis / long-axis below this => "axis-aligned" +TOP_N = 20 +YIELD_EVERY = 200 # yield to Kit UI every N meshes +OUTPUT_CSV = "./pipe_density.csv" + +# --------------------------------------------------------------------------- +import asyncio +import csv +import traceback +import omni.kit.app +import omni.usd +from pxr import Usd, UsdGeom + +# ---- Cancel any previous run ----------------------------------------------- +_prev = globals().get("_PIPE_DENSITY_TASK") +if _prev is not None and not _prev.done(): + _prev.cancel() + + +def _bbox_dims(prim, bbox_cache): + try: + bbox = bbox_cache.ComputeWorldBound(prim).ComputeAlignedBox() + except Exception: + return None + mn, mx = bbox.GetMin(), bbox.GetMax() + dx = float(mx[0] - mn[0]) + dy = float(mx[1] - mn[1]) + dz = float(mx[2] - mn[2]) + return dx, dy, dz + + +def _classify_cylinder(dx, dy, dz): + """Return (is_cylinder_like, long_axis_index, long_len, second_len, short_len).""" + dims = [(dx, 0), (dy, 1), (dz, 2)] + dims.sort(key=lambda x: x[0], reverse=True) + long_len, long_ax = dims[0] + second_len, _ = dims[1] + short_len, _ = dims[2] + if long_len <= 0 or second_len <= 0: + return False, long_ax, long_len, second_len, short_len + elong = long_len / max(second_len, 1e-9) + if elong < ELONGATION_RATIO: + return False, long_ax, long_len, second_len, short_len + # Cross-section roughly circular: shortest and second-shortest similar + if second_len > 0 and short_len / second_len < 0.5: + return False, long_ax, long_len, second_len, short_len + return True, long_ax, long_len, second_len, short_len + + +async def _pipe_main(): + stage = omni.usd.get_context().get_stage() + if not stage: + print("[error] no stage open") + return + root = stage.GetRootLayer().identifier if stage.GetRootLayer() else "?" + print(f"[stage] {root}") + + bbox_cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), + [UsdGeom.Tokens.default_, UsdGeom.Tokens.render]) + + # Stage bbox for density normalization + try: + stage_bb = bbox_cache.ComputeWorldBound(stage.GetPseudoRoot()).ComputeAlignedBox() + sdx = float(stage_bb.GetMax()[0] - stage_bb.GetMin()[0]) + sdy = float(stage_bb.GetMax()[1] - stage_bb.GetMin()[1]) + sdz = float(stage_bb.GetMax()[2] - stage_bb.GetMin()[2]) + stage_vol = max(sdx * sdy * sdz, 1.0) + except Exception: + stage_vol = 1.0 + sdx = sdy = sdz = 0.0 + + print(f"[stage bbox] dx={sdx:.2f} dy={sdy:.2f} dz={sdz:.2f} vol={stage_vol:.2e}") + + total_meshes = 0 + cyl_meshes = [] # list of (long_len, second_len, short_len, long_ax, path) + axis_aligned = 0 # cylinders where 2 short axes << long axis (i.e. axis-aligned) + n_yield_chunk = 0 + + for prim in stage.TraverseAll(): + if not prim.IsA(UsdGeom.Mesh): + continue + total_meshes += 1 + + # Skip extremely complex meshes — not cylinders + try: + mesh = UsdGeom.Mesh(prim) + points_attr = mesh.GetPointsAttr() + n_verts = len(points_attr.Get()) if points_attr.Get() else 0 + except Exception: + n_verts = 0 + if n_verts < MIN_VERTS or n_verts > MAX_VERTS: + n_yield_chunk += 1 + if n_yield_chunk >= YIELD_EVERY: + n_yield_chunk = 0 + await omni.kit.app.get_app().next_update_async() + continue + + dims = _bbox_dims(prim, bbox_cache) + if dims is None: + continue + dx, dy, dz = dims + is_cyl, long_ax, long_len, second_len, short_len = _classify_cylinder(dx, dy, dz) + if is_cyl: + cyl_meshes.append((long_len, second_len, short_len, long_ax, str(prim.GetPath()))) + # Axis-alignment: both shorter axes are small relative to long axis + if (second_len / long_len) < AXIS_ALIGN_TOLERANCE * 5: + axis_aligned += 1 + + n_yield_chunk += 1 + if n_yield_chunk >= YIELD_EVERY: + n_yield_chunk = 0 + await omni.kit.app.get_app().next_update_async() + if (total_meshes % (YIELD_EVERY * 25)) == 0: + print(f" ... {total_meshes:,} meshes scanned (cyl so far={len(cyl_meshes):,})") + + n_cyl = len(cyl_meshes) + pct = (100.0 * n_cyl / total_meshes) if total_meshes else 0.0 + density = (n_cyl / stage_vol) if stage_vol > 0 else 0.0 + + print() + print("=" * 78) + print("CYLINDER-LIKE MESH SUMMARY (heuristic)") + print("=" * 78) + print(f" Total meshes scanned : {total_meshes:,}") + print(f" Cylinder-like meshes : {n_cyl:,} ({pct:.1f}%)") + print(f" Axis-aligned cylinders : {axis_aligned:,}") + print(f" Density (count / stage vol) : {density:.4e}") + print(f" Criteria: elong>{ELONGATION_RATIO}, verts {MIN_VERTS}-{MAX_VERTS}, " + f"cross-section roughly round (short/second >= 0.5)") + + # Length histogram + if n_cyl > 0: + lens = sorted([c[0] for c in cyl_meshes]) + lo, hi = lens[0], lens[-1] + print() + print("=" * 78) + print("LENGTH DISTRIBUTION (long-axis length)") + print("=" * 78) + if hi > 0 and lo >= 0: + # Logarithmic bins + buckets_def = [(0, 0.01), (0.01, 0.1), (0.1, 1.0), (1.0, 10.0), + (10.0, 100.0), (100.0, 1000.0), (1000.0, float("inf"))] + buckets = [0] * len(buckets_def) + for L in lens: + for i, (lo_b, hi_b) in enumerate(buckets_def): + if lo_b <= L < hi_b: + buckets[i] += 1 + break + max_b = max(buckets) if buckets else 1 + bar_max = 40 + for (lo_b, hi_b), c in zip(buckets_def, buckets): + lab = f"[{lo_b}, {hi_b})" if hi_b != float("inf") else f"[{lo_b}, inf)" + bar = "#" * int(bar_max * c / max_b) if max_b else "" + print(f" {lab:<20} {c:>8,} {bar}") + # Longest + cyl_meshes.sort(key=lambda x: x[0], reverse=True) + print() + print("=" * 78) + print(f"TOP {TOP_N} LONGEST CYLINDER-LIKE MESHES") + print("=" * 78) + axis_lbl = {0: "X", 1: "Y", 2: "Z"} + for i, (L, S2, S3, ax, p) in enumerate(cyl_meshes[:TOP_N], 1): + print(f" {i:>3} long={L:>10.3f} cross=({S2:.3f}, {S3:.3f}) axis={axis_lbl.get(ax,'?')} {p}") + + # ---- CSV ---- + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["section", "key", "value"]) + w.writerow(["summary", "total_meshes", total_meshes]) + w.writerow(["summary", "cylinder_like", n_cyl]) + w.writerow(["summary", "cylinder_pct", f"{pct:.4f}"]) + w.writerow(["summary", "axis_aligned", axis_aligned]) + w.writerow(["summary", "density_per_vol", f"{density:.6e}"]) + w.writerow(["summary", "stage_vol", f"{stage_vol:.6e}"]) + for i, (L, S2, S3, ax, p) in enumerate(cyl_meshes[:TOP_N], 1): + w.writerow([f"top_{i}", "long_len", L]) + w.writerow([f"top_{i}", "short1", S2]) + w.writerow([f"top_{i}", "short2", S3]) + w.writerow([f"top_{i}", "axis", ax]) + w.writerow([f"top_{i}", "path", p]) + print(f"\n[csv] wrote {OUTPUT_CSV}") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _pipe_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(task): + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + print(f"[task error] {exc!r}") + + +_PIPE_DENSITY_TASK = asyncio.ensure_future(_wrapped()) +_PIPE_DENSITY_TASK.add_done_callback(_on_done) +print("[started] async pipe-density (heuristic) — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/11_usd_files_inventory.py b/dev/samples/11_usd_files_inventory.py new file mode 100644 index 0000000..6c018f0 --- /dev/null +++ b/dev/samples/11_usd_files_inventory.py @@ -0,0 +1,201 @@ +# ============================================================================= +# USD Files Inventory — ASYNC version for Kit Script Editor +# ----------------------------------------------------------------------------- +# Answers: +# - How many separate USD files compose the current stage? +# - How big is each individual USD file on disk? +# +# Method: +# 1) Use Sdf.Layer.GetLoadedLayers() to enumerate every loaded layer +# (root layer + sublayers + reference/payload layers + session, etc.) +# 2) For each layer, resolve its real path via Layer.realPath +# 3) If the resolved path is a local file, os.path.getsize() it. +# If it's a Nucleus/HTTP URL, mark size as "n/a (remote)". +# 4) Print summary + top-N largest files + extension breakdown +# 5) Write CSV to ./usd_files_inventory.csv +# +# IP-safe: file paths, sizes only. No geometry data leaves the stage. +# Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +TOP_N = 30 +OUTPUT_CSV = "./usd_files_inventory.csv" +YIELD_EVERY = 100 +# --------------------------------------------------------------------------- + +import asyncio +import csv +import os +import traceback +from collections import Counter + +import omni.kit.app +import omni.usd +from pxr import Sdf + +_GLOBAL = globals() +_prev = _GLOBAL.get("_USD_INVENTORY_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _looks_remote(path): + if not path: + return False + p = path.lower() + return p.startswith(("omniverse://", "http://", "https://")) + + +async def _inventory_main(): + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] no stage open") + return + + root_id = stage.GetRootLayer().identifier if stage.GetRootLayer() else "?" + print(f"[stage] {root_id}") + + # All loaded layers (process-wide). Filter to those used by *this* stage + # by intersecting with stage.GetUsedLayers(). + try: + used = set(L.identifier for L in stage.GetUsedLayers()) + except Exception: + used = None # fall back to all loaded layers + + all_layers = Sdf.Layer.GetLoadedLayers() + if used is not None: + layers = [L for L in all_layers if L.identifier in used] + else: + layers = list(all_layers) + + print(f"[discovered] {len(layers)} loaded layers for this stage") + await _yield() + + rows = [] # list of dict + total_bytes = 0 + remote_count = 0 + missing_count = 0 + anon_count = 0 + ext_counter = Counter() + + for i, L in enumerate(layers): + if i % YIELD_EVERY == 0 and i > 0: + await _yield() + + ident = L.identifier + real = L.realPath or "" + is_anon = L.anonymous + size = -1 + kind = "local" + + if is_anon: + kind = "anonymous" + anon_count += 1 + elif _looks_remote(real or ident): + kind = "remote" + remote_count += 1 + else: + try: + size = os.path.getsize(real) if real and os.path.exists(real) else -1 + except Exception: + size = -1 + if size < 0: + missing_count += 1 + kind = "missing_or_unresolved" + else: + total_bytes += size + + # File extension + ext = os.path.splitext(real or ident)[1].lower() + if ext.startswith("."): + ext = ext[1:] + ext_counter[ext or "(none)"] += 1 + + rows.append({ + "identifier": ident, + "real_path": real, + "kind": kind, + "ext": ext or "", + "size_bytes": size, + "anonymous": is_anon, + }) + + # ---- Summary ---------------------------------------------------------- + n_local = sum(1 for r in rows if r["kind"] == "local") + print() + print("=" * 78) + print("USD FILES INVENTORY") + print("=" * 78) + print(f" Total layers used by stage : {len(rows):,}") + print(f" Local files (size measurable) : {n_local:,}") + print(f" Remote (Nucleus/HTTP) : {remote_count:,}") + print(f" Anonymous (in-memory only) : {anon_count:,}") + print(f" Missing / unresolved : {missing_count:,}") + print(f" Total size on disk (local only) : {total_bytes:,} bytes " + f"({total_bytes / (1024 * 1024):.2f} MiB)") + + # ---- Extension breakdown --------------------------------------------- + if ext_counter: + print() + print("EXTENSION BREAKDOWN") + for ext, n in ext_counter.most_common(): + print(f" .{ext:<8} {n:>6,}") + + await _yield() + + # ---- Top-N largest local files -------------------------------------- + local_sorted = sorted([r for r in rows if r["size_bytes"] >= 0], + key=lambda r: r["size_bytes"], reverse=True) + if local_sorted: + print() + print("=" * 78) + print(f"TOP {TOP_N} LARGEST LOCAL USD FILES") + print("=" * 78) + print(f" {'#':>3} {'size (MiB)':>12} {'ext':>6} path") + for i, r in enumerate(local_sorted[:TOP_N], 1): + mib = r["size_bytes"] / (1024 * 1024) + shown = r["real_path"] or r["identifier"] + print(f" {i:>3} {mib:>12.2f} {r['ext']:>6} {shown}") + + # ---- CSV -------------------------------------------------------------- + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["identifier", "real_path", "kind", "ext", + "size_bytes", "anonymous"]) + for r in rows: + w.writerow([r["identifier"], r["real_path"], r["kind"], + r["ext"], r["size_bytes"], r["anonymous"]]) + print(f"\n[csv] wrote {OUTPUT_CSV} ({len(rows)} rows)") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _inventory_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_USD_INVENTORY_TASK = asyncio.ensure_future(_wrapped()) +_USD_INVENTORY_TASK.add_done_callback(_on_done) +print("[started] async USD inventory scan — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/12_pipe_geometry_detail.py b/dev/samples/12_pipe_geometry_detail.py new file mode 100644 index 0000000..62dc018 --- /dev/null +++ b/dev/samples/12_pipe_geometry_detail.py @@ -0,0 +1,304 @@ +# ============================================================================= +# Pipe Geometry Detail — ASYNC, USD-only, HEURISTIC +# ----------------------------------------------------------------------------- +# Builds on 10_pipe_density.py — answers the follow-up questions: +# +# (A) How many pipes are there? +# (B) How many triangles are in the pipes (total + per-pipe avg/median)? +# (C) Are the pipes instanced? +# - count pipe meshes that are inside instance prototypes +# - count pipe meshes that have IsInstanceable() / IsInstance() +# (D) Are they named in a way that makes them easy to identify as pipes? +# - scan prim names + their ancestors for naming patterns: +# "pipe", "tube", "conduit", "duct", "cylinder", "pipework" +# - case-insensitive +# - reports % of pipe-like meshes that have an identifiable name +# +# IMPORTANT — same caveat as 10_pipe_density.py: +# USD has no semantic "pipe". This uses the same bbox/elongation heuristic +# as script 10 to *classify* a mesh as cylinder-like, then computes the +# additional details on that subset. +# +# IP-safe: count / dimensions / names only. Read-only. +# Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning (kept identical to 10_pipe_density.py for consistency) --------- +ELONGATION_RATIO = 5.0 +MIN_VERTS = 6 +MAX_VERTS = 5000 +TOP_N = 20 +YIELD_EVERY = 200 +OUTPUT_CSV = "./pipe_geometry_detail.csv" + +# Naming heuristic — case-insensitive substring match against prim name + +# ancestor names. Add/remove tokens as needed for the customer's data. +NAME_TOKENS = ("pipe", "tube", "conduit", "duct", "pipework", "piping", + "cylinder", "tubing") +# --------------------------------------------------------------------------- + +import asyncio +import csv +import statistics +import traceback + +import omni.kit.app +import omni.usd +from pxr import Usd, UsdGeom + +_GLOBAL = globals() +_prev = _GLOBAL.get("_PIPE_DETAIL_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _classify(dx, dy, dz): + dims = sorted([dx, dy, dz], reverse=True) + long_len, second_len, short_len = dims + if long_len <= 0 or second_len <= 0: + return False, long_len, second_len, short_len + if (long_len / max(second_len, 1e-9)) < ELONGATION_RATIO: + return False, long_len, second_len, short_len + if second_len > 0 and short_len / second_len < 0.5: + return False, long_len, second_len, short_len + return True, long_len, second_len, short_len + + +def _triangles_from_facevertex_counts(fvc): + """Approximate triangle count from face-vertex-counts: + each face with N verts -> (N-2) triangles.""" + if not fvc: + return 0 + tri = 0 + for n in fvc: + if n >= 3: + tri += (n - 2) + return tri + + +def _name_match(prim): + """Return matched-token or None. Checks prim name and ancestors.""" + cur = prim + while cur and cur.GetPath() != cur.GetPath().GetParentPath(): + name = cur.GetName().lower() + for tok in NAME_TOKENS: + if tok in name: + return tok + cur = cur.GetParent() + return None + + +async def _pipe_detail_main(): + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] no stage open") + return + print(f"[stage] {stage.GetRootLayer().identifier}") + + bbox_cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), + [UsdGeom.Tokens.default_, UsdGeom.Tokens.render]) + + # Collected per pipe-like mesh + pipes = [] # list of dict (path, verts, tris, long, short1, short2, + # instanced, instance_proxy, name_match) + + total_meshes = 0 + n_yield = 0 + + # IMPORTANT: descend into instance proxies so we count instanced pipes too. + prim_iter = Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()) + + for prim in prim_iter: + if not prim.IsA(UsdGeom.Mesh): + continue + total_meshes += 1 + + try: + mesh = UsdGeom.Mesh(prim) + pts = mesh.GetPointsAttr().Get() + n_verts = len(pts) if pts else 0 + except Exception: + n_verts = 0 + if n_verts < MIN_VERTS or n_verts > MAX_VERTS: + n_yield += 1 + if n_yield >= YIELD_EVERY: + n_yield = 0 + await _yield() + continue + + try: + bbox = bbox_cache.ComputeWorldBound(prim).ComputeAlignedBox() + mn, mx = bbox.GetMin(), bbox.GetMax() + dx = float(mx[0] - mn[0]); dy = float(mx[1] - mn[1]); dz = float(mx[2] - mn[2]) + except Exception: + continue + + is_pipe, L, S2, S3 = _classify(dx, dy, dz) + if not is_pipe: + n_yield += 1 + if n_yield >= YIELD_EVERY: + n_yield = 0 + await _yield() + continue + + # Triangle count + try: + fvc = mesh.GetFaceVertexCountsAttr().Get() + tris = _triangles_from_facevertex_counts(fvc) + except Exception: + tris = 0 + + # Instancing + is_proxy = bool(prim.IsInstanceProxy()) + is_inst = bool(prim.IsInstance()) + is_instable = bool(prim.IsInstanceable()) + # Any of these → effectively benefits from instancing + instanced = is_proxy or is_inst or is_instable + + # Naming + name_tok = _name_match(prim) + + pipes.append({ + "path": str(prim.GetPath()), + "verts": n_verts, + "tris": tris, + "long": L, + "short1": S2, + "short2": S3, + "instanced": instanced, + "is_proxy": is_proxy, + "is_inst": is_inst, + "is_instable": is_instable, + "name_token": name_tok or "", + }) + + n_yield += 1 + if n_yield >= YIELD_EVERY: + n_yield = 0 + await _yield() + if (total_meshes % (YIELD_EVERY * 25)) == 0: + print(f" ... {total_meshes:,} meshes scanned (pipes so far={len(pipes):,})") + + n_pipes = len(pipes) + print() + print("=" * 78) + print("PIPE GEOMETRY DETAIL (heuristic)") + print("=" * 78) + print(f" Total meshes scanned : {total_meshes:,}") + print(f" Pipe-like meshes (heuristic) : {n_pipes:,} " + f"({100.0 * n_pipes / max(total_meshes, 1):.1f}%)") + if n_pipes == 0: + print(" (no pipe-like meshes — nothing else to report)") + return + + # ---- (B) Triangle counts --------------------------------------------- + tris_list = [p["tris"] for p in pipes if p["tris"] > 0] + total_tris = sum(tris_list) + print() + print("TRIANGLE COUNTS IN PIPES") + print(f" Total triangles in pipes : {total_tris:,}") + if tris_list: + print(f" Mean tris/pipe : {total_tris / len(tris_list):.1f}") + print(f" Median tris/pipe : {int(statistics.median(tris_list)):,}") + print(f" Min / Max tris per pipe : {min(tris_list):,} / {max(tris_list):,}") + if len(tris_list) >= 20: + sl = sorted(tris_list) + p95 = sl[int(0.95 * (len(sl) - 1))] + print(f" p95 tris/pipe : {p95:,}") + + # ---- (C) Instancing on pipes ----------------------------------------- + n_inst = sum(1 for p in pipes if p["instanced"]) + n_proxy = sum(1 for p in pipes if p["is_proxy"]) + n_isinst = sum(1 for p in pipes if p["is_inst"]) + n_instable = sum(1 for p in pipes if p["is_instable"]) + print() + print("INSTANCING ON PIPES") + print(f" Pipes that are instanced (any form) : {n_inst:,} " + f"({100.0 * n_inst / n_pipes:.1f}%)") + print(f" of which IsInstanceProxy() : {n_proxy:,}") + print(f" of which IsInstance() : {n_isinst:,}") + print(f" of which IsInstanceable() : {n_instable:,}") + if n_inst == 0: + print(" -> pipes are NOT instanced — deduplicateGeometry " + "(method=Instanceable Reference) is a strong candidate") + elif n_inst / n_pipes < 0.5: + print(" -> only some pipes are instanced — partial benefit, " + "dedup may still help") + + # ---- (D) Naming identifiability -------------------------------------- + n_named = sum(1 for p in pipes if p["name_token"]) + print() + print("PIPE NAMING IDENTIFIABILITY") + print(f" Pipes whose name/ancestor matches : {n_named:,} " + f"({100.0 * n_named / n_pipes:.1f}%)") + if n_named > 0: + tok_counter = {} + for p in pipes: + t = p["name_token"] + if t: + tok_counter[t] = tok_counter.get(t, 0) + 1 + print(" Token breakdown:") + for t, c in sorted(tok_counter.items(), key=lambda x: x[1], reverse=True): + print(f" {t:<10} {c:>6,}") + else: + print(" -> pipe prims do NOT have identifying names — selection " + "for SO ops needs path-based or heuristic filtering") + print(f" Tokens checked: {', '.join(NAME_TOKENS)}") + + # ---- Top-N ----------------------------------------------------------- + print() + print("=" * 78) + print(f"TOP {TOP_N} PIPE-LIKE MESHES BY TRIANGLE COUNT") + print("=" * 78) + pipes_by_tris = sorted(pipes, key=lambda p: p["tris"], reverse=True) + for i, p in enumerate(pipes_by_tris[:TOP_N], 1): + inst = "I" if p["instanced"] else "-" + nm = p["name_token"] if p["name_token"] else "?" + print(f" {i:>3} tris={p['tris']:>8,} verts={p['verts']:>6,} " + f"long={p['long']:>8.2f} inst={inst} name={nm:<8} {p['path']}") + + # ---- CSV ------------------------------------------------------------- + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "verts", "tris", "long", "short1", "short2", + "instanced", "is_proxy", "is_inst", "is_instable", + "name_token"]) + for p in pipes_by_tris: + w.writerow([p["path"], p["verts"], p["tris"], + f"{p['long']:.4f}", f"{p['short1']:.4f}", + f"{p['short2']:.4f}", + p["instanced"], p["is_proxy"], p["is_inst"], + p["is_instable"], p["name_token"]]) + print(f"\n[csv] wrote {OUTPUT_CSV} ({len(pipes)} rows)") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _pipe_detail_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_PIPE_DETAIL_TASK = asyncio.ensure_future(_wrapped()) +_PIPE_DETAIL_TASK.add_done_callback(_on_done) +print("[started] async pipe-geometry-detail scan — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/13_triangle_aspect_ratio.py b/dev/samples/13_triangle_aspect_ratio.py new file mode 100644 index 0000000..f992490 --- /dev/null +++ b/dev/samples/13_triangle_aspect_ratio.py @@ -0,0 +1,281 @@ +# ============================================================================= +# Triangle Aspect Ratio Analyzer — ASYNC version for Kit Script Editor +# ----------------------------------------------------------------------------- +# Answers: are the triangles "long and skinny" or more "equilateral"? +# +# For every UsdGeomMesh, triangulate each face by fanning from vertex 0 +# (this is an approximation — fine for stats, NOT for geometry output), +# then compute an aspect ratio per triangle. +# +# Two metrics reported per triangle: +# ar1 = longest_edge / shortest_edge (1.0 = equilateral, >> 1.0 = sliver) +# ar2 = longest_edge / (2 * inradius) (radius ratio; 2.0 = equilateral, +# larger = more sliver-like) +# We use ar1 (longest/shortest) as the primary metric — simpler to interpret. +# +# Output: +# - Total triangles scanned +# - Mean / median / p95 of ar1 across the whole stage +# - Histogram of ar1 buckets +# - Optional pipe-subset stats (re-uses 10_pipe_density.py heuristic) +# +# Tuning: SAMPLE_EVERY_NTH_TRI=1 means analyze every triangle. For very +# heavy stages, set higher (e.g. 5) to subsample. +# +# IP-safe: aggregate stats only; no vertex positions leave the stage. +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +SAMPLE_EVERY_NTH_TRI = 1 # 1 = every triangle, 5 = 1-in-5, 10 = 1-in-10 +MAX_VERTS_PER_MESH = 200000 # skip extremely dense meshes (perf) +INCLUDE_PIPE_SUBSET = True +ELONGATION_RATIO = 5.0 # same as 10_pipe_density.py +MIN_VERTS_PIPE = 6 +MAX_VERTS_PIPE = 5000 +YIELD_EVERY = 50 # meshes between UI yields (this script is heavier) +OUTPUT_CSV = "./triangle_aspect_ratio.csv" +TOP_N = 20 +# --------------------------------------------------------------------------- + +import asyncio +import csv +import math +import statistics +import traceback + +import omni.kit.app +import omni.usd +from pxr import Usd, UsdGeom + +_GLOBAL = globals() +_prev = _GLOBAL.get("_TRI_AR_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _is_pipe_like(prim, bbox_cache): + try: + mesh = UsdGeom.Mesh(prim) + pts = mesh.GetPointsAttr().Get() + n_verts = len(pts) if pts else 0 + except Exception: + return False + if n_verts < MIN_VERTS_PIPE or n_verts > MAX_VERTS_PIPE: + return False + try: + bb = bbox_cache.ComputeWorldBound(prim).ComputeAlignedBox() + mn, mx = bb.GetMin(), bb.GetMax() + dx = float(mx[0] - mn[0]); dy = float(mx[1] - mn[1]); dz = float(mx[2] - mn[2]) + except Exception: + return False + dims = sorted([dx, dy, dz], reverse=True) + long_, second_, short_ = dims + if long_ <= 0 or second_ <= 0: + return False + if long_ / max(second_, 1e-9) < ELONGATION_RATIO: + return False + if second_ > 0 and short_ / second_ < 0.5: + return False + return True + + +def _tri_ar(p0, p1, p2): + """Aspect ratio = longest_edge / shortest_edge of a triangle.""" + e0 = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]) + e1 = (p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]) + e2 = (p0[0] - p2[0], p0[1] - p2[1], p0[2] - p2[2]) + L0 = math.sqrt(e0[0]**2 + e0[1]**2 + e0[2]**2) + L1 = math.sqrt(e1[0]**2 + e1[1]**2 + e1[2]**2) + L2 = math.sqrt(e2[0]**2 + e2[1]**2 + e2[2]**2) + mx = max(L0, L1, L2) + mn = min(L0, L1, L2) + if mn <= 0.0: + return None + return mx / mn + + +def _accumulate_mesh(mesh, sampler_state): + """Return (count, sum_ar, ar_list) for one mesh.""" + pts = mesh.GetPointsAttr().Get() + fvc = mesh.GetFaceVertexCountsAttr().Get() + fvi = mesh.GetFaceVertexIndicesAttr().Get() + if not pts or not fvc or not fvi: + return 0, 0.0, [] + + n_verts = len(pts) + if n_verts > MAX_VERTS_PER_MESH: + return 0, 0.0, [] + + ar_local = [] + idx = 0 + total = 0 + sum_ar = 0.0 + skip = SAMPLE_EVERY_NTH_TRI + counter = sampler_state[0] + for n in fvc: + if n < 3: + idx += n + continue + # Fan triangulation from vertex 0 of this face + i0 = fvi[idx] + for k in range(1, n - 1): + counter += 1 + if (counter % skip) != 0: + continue + i1 = fvi[idx + k] + i2 = fvi[idx + k + 1] + if i0 >= n_verts or i1 >= n_verts or i2 >= n_verts: + continue + ar = _tri_ar(pts[i0], pts[i1], pts[i2]) + if ar is None: + continue + sum_ar += ar + total += 1 + ar_local.append(ar) + idx += n + sampler_state[0] = counter + return total, sum_ar, ar_local + + +async def _ar_main(): + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] no stage open") + return + print(f"[stage] {stage.GetRootLayer().identifier}") + + bbox_cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(), + [UsdGeom.Tokens.default_, UsdGeom.Tokens.render]) + + sampler_state = [0] # counter shared across meshes + all_ar = [] + all_pipe_ar = [] + n_meshes = 0 + n_meshes_processed = 0 + per_mesh_stats = [] # list of (path, mean_ar, count, is_pipe) + + for prim in Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()): + if not prim.IsA(UsdGeom.Mesh): + continue + n_meshes += 1 + + if n_meshes % YIELD_EVERY == 0: + await _yield() + if n_meshes % (YIELD_EVERY * 20) == 0: + print(f" ... {n_meshes:,} meshes scanned " + f"(triangles so far={len(all_ar):,})") + + try: + mesh = UsdGeom.Mesh(prim) + except Exception: + continue + + cnt, sum_ar, ar_list = _accumulate_mesh(mesh, sampler_state) + if cnt == 0: + continue + n_meshes_processed += 1 + + mean_ar = sum_ar / cnt + is_pipe = INCLUDE_PIPE_SUBSET and _is_pipe_like(prim, bbox_cache) + per_mesh_stats.append((str(prim.GetPath()), mean_ar, cnt, is_pipe)) + all_ar.extend(ar_list) + if is_pipe: + all_pipe_ar.extend(ar_list) + + print() + print("=" * 78) + print("TRIANGLE ASPECT RATIO (longest_edge / shortest_edge)") + print("=" * 78) + print(f" Total meshes seen : {n_meshes:,}") + print(f" Meshes with measurable triangles : {n_meshes_processed:,}") + print(f" Triangles analyzed (sampled 1/{SAMPLE_EVERY_NTH_TRI}) : {len(all_ar):,}") + + def _print_dist(label, lst): + if not lst: + print(f" [{label}] (no triangles)") + return + m = statistics.mean(lst) + med = statistics.median(lst) + sl = sorted(lst) + p95 = sl[int(0.95 * (len(sl) - 1))] + mx = sl[-1] + print(f" [{label}]") + print(f" triangles : {len(lst):,}") + print(f" mean ar : {m:.3f} (1.0 = equilateral, > 4 = sliver)") + print(f" median ar : {med:.3f}") + print(f" p95 ar : {p95:.3f}") + print(f" max ar : {mx:.3f}") + # Buckets + bins = [(1.0, 1.5), (1.5, 2.0), (2.0, 3.0), (3.0, 5.0), + (5.0, 10.0), (10.0, 50.0), (50.0, float("inf"))] + bucket_counts = [0] * len(bins) + for v in lst: + for i, (lo, hi) in enumerate(bins): + if lo <= v < hi: + bucket_counts[i] += 1 + break + mx_b = max(bucket_counts) or 1 + for (lo, hi), c in zip(bins, bucket_counts): + lab = f"[{lo}, {hi})" if hi != float("inf") else f"[{lo}, inf)" + bar = "#" * int(40 * c / mx_b) + pct = 100.0 * c / len(lst) + print(f" {lab:<14} {c:>10,} ({pct:>5.1f}%) {bar}") + + print() + _print_dist("ALL TRIANGLES", all_ar) + + if INCLUDE_PIPE_SUBSET: + print() + _print_dist("PIPE-LIKE MESHES SUBSET", all_pipe_ar) + + # Top mean-AR meshes (worst-quality meshes) + if per_mesh_stats: + worst = sorted(per_mesh_stats, key=lambda x: x[1], reverse=True) + print() + print("=" * 78) + print(f"TOP {TOP_N} MESHES BY MEAN ASPECT RATIO (worst sliver-ness)") + print("=" * 78) + for i, (p, m, c, is_pipe) in enumerate(worst[:TOP_N], 1): + tag = "PIPE" if is_pipe else " " + print(f" {i:>3} mean_ar={m:>8.3f} tris={c:>8,} {tag} {p}") + + # CSV + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "mean_ar", "tri_count", "is_pipe_like"]) + for p, m, c, is_pipe in per_mesh_stats: + w.writerow([p, f"{m:.4f}", c, is_pipe]) + print(f"\n[csv] wrote {OUTPUT_CSV} ({len(per_mesh_stats)} rows)") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _ar_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_TRI_AR_TASK = asyncio.ensure_future(_wrapped()) +_TRI_AR_TASK.add_done_callback(_on_done) +print("[started] async triangle-aspect-ratio scan — cell returns now, work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/14_hoops_category_pipes.py b/dev/samples/14_hoops_category_pipes.py new file mode 100644 index 0000000..cee38ab --- /dev/null +++ b/dev/samples/14_hoops_category_pipes.py @@ -0,0 +1,235 @@ +# ============================================================================= +# HOOPS Category "Pipe" / "Pipes" Counter — ASYNC version for Kit Script Editor +# ----------------------------------------------------------------------------- +# Answers: how many prims in the stage are tagged with HOOPS metadata category +# "Pipe" or "Pipes"? +# +# Background: +# When CAD data is imported via HOOPS Exchange (Revit / NX / Creo / etc.), +# the converter often attaches an attribute named +# omni:hoops:metadata:Other:Category +# to each prim, carrying the original CAD category string (e.g. "Pipe", +# "Wall", "Duct", "Door"). This script walks the stage and counts those +# tags so we can quickly answer "how much of this scene is pipes?". +# +# What is reported: +# - Exact-case counts for "Pipe" and "Pipes" +# - Case-insensitive count for any value matching pipe/pipes +# - Top-N most common Category values across the whole stage +# - Mesh vs non-mesh split for Pipe/Pipes prims +# - Total triangle count for matching mesh prims (fan triangulation) +# +# IP-safe: aggregate counts + prim paths only. No vertex positions exfiltrated. +# Read-only. Re-running cancels previous run. +# ============================================================================= + +# ---- Tuning ---------------------------------------------------------------- +ATTR_NAME = "omni:hoops:metadata:Other:Category" +EXACT_TARGETS = ("Pipe", "Pipes") # case-sensitive matches +CASE_INSENSITIVE = True # also count case-insensitive +TOP_N_CATEGORIES = 30 # top categories to print +TOP_N_PIPE_PATHS = 30 # top pipe-prim paths to print +YIELD_EVERY = 500 # prims between UI yields +OUTPUT_CSV = "./hoops_category_pipes.csv" +OUTPUT_DISTRIBUTION_CSV = "./hoops_category_distribution.csv" +# --------------------------------------------------------------------------- + +import asyncio +import csv +import traceback +from collections import Counter + +import omni.kit.app +import omni.usd +from pxr import Usd, UsdGeom + +_GLOBAL = globals() +_prev = _GLOBAL.get("_HOOPS_CAT_TASK") +if _prev is not None and not _prev.done(): + print("[cancel] previous run is still active — cancelling it") + _prev.cancel() + + +async def _yield(): + await omni.kit.app.get_app().next_update_async() + + +def _count_triangles(mesh): + """Fan-triangulation triangle count for a UsdGeomMesh (approximate).""" + try: + fvc = mesh.GetFaceVertexCountsAttr().Get() + except Exception: + return 0 + if not fvc: + return 0 + total = 0 + for n in fvc: + if n >= 3: + total += (n - 2) + return total + + +async def _hoops_main(): + stage = omni.usd.get_context().get_stage() + if stage is None: + print("[error] no stage open") + return + print(f"[stage] {stage.GetRootLayer().identifier}") + print(f"[attr] {ATTR_NAME}") + + # Counters + exact_counts = {t: 0 for t in EXACT_TARGETS} + ci_pipe_count = 0 + category_counter = Counter() + n_prims = 0 + n_with_attr = 0 + + # Per-prim records for matching Pipe/Pipes + pipe_records = [] # list of (path, category_value, is_mesh, tri_count) + pipe_tri_total = 0 + pipe_mesh_count = 0 + pipe_nonmesh_count = 0 + + for prim in Usd.PrimRange.Stage(stage, Usd.TraverseInstanceProxies()): + n_prims += 1 + if n_prims % YIELD_EVERY == 0: + await _yield() + if n_prims % (YIELD_EVERY * 50) == 0: + print(f" ... {n_prims:,} prims scanned " + f"(with Category attr so far={n_with_attr:,})") + + attr = prim.GetAttribute(ATTR_NAME) + if not attr or not attr.IsValid() or not attr.HasAuthoredValue(): + continue + try: + value = attr.Get() + except Exception: + continue + if value is None: + continue + # Normalize to string + sval = str(value) + n_with_attr += 1 + category_counter[sval] += 1 + + # Exact-case match + exact_hit = sval in exact_counts + if exact_hit: + exact_counts[sval] += 1 + + # Case-insensitive match for pipe/pipes + ci_hit = CASE_INSENSITIVE and (sval.lower() in ("pipe", "pipes")) + if ci_hit: + ci_pipe_count += 1 + + if exact_hit or ci_hit: + is_mesh = prim.IsA(UsdGeom.Mesh) + tris = 0 + if is_mesh: + try: + tris = _count_triangles(UsdGeom.Mesh(prim)) + except Exception: + tris = 0 + pipe_mesh_count += 1 + pipe_tri_total += tris + else: + pipe_nonmesh_count += 1 + pipe_records.append((str(prim.GetPath()), sval, is_mesh, tris)) + + # ---------------- Report ---------------- + print() + print("=" * 78) + print(f"HOOPS CATEGORY SUMMARY — attr: {ATTR_NAME}") + print("=" * 78) + print(f" Total prims scanned : {n_prims:,}") + print(f" Prims with authored Category : {n_with_attr:,}") + print(f" Distinct Category values : {len(category_counter):,}") + + print() + print("EXACT MATCHES (case-sensitive)") + for t in EXACT_TARGETS: + print(f" Category == \"{t}\" : {exact_counts[t]:,}") + print(f" Sum of exact Pipe/Pipes : {sum(exact_counts.values()):,}") + if CASE_INSENSITIVE: + print(f" Case-insensitive 'pipe'/'pipes' : {ci_pipe_count:,}") + + print() + print(f"PIPE/PIPES PRIM TYPE BREAKDOWN ({len(pipe_records):,} prims)") + print(f" Mesh prims : {pipe_mesh_count:,}") + print(f" Non-mesh prims : {pipe_nonmesh_count:,}") + print(f" Triangle total : {pipe_tri_total:,} (fan-triangulation, " + f"approx)") + if pipe_mesh_count > 0: + avg = pipe_tri_total / pipe_mesh_count + print(f" Avg tris / mesh : {avg:,.1f}") + + # Top categories overall + if category_counter: + print() + print("=" * 78) + print(f"TOP {TOP_N_CATEGORIES} CATEGORY VALUES (entire stage)") + print("=" * 78) + print(f" {'#':>3} {'count':>10} category") + for i, (cat, c) in enumerate(category_counter.most_common(TOP_N_CATEGORIES), + 1): + print(f" {i:>3} {c:>10,} {cat!r}") + + # Top pipe prim paths (largest triangle count first) + if pipe_records: + worst = sorted(pipe_records, key=lambda r: r[3], reverse=True) + print() + print("=" * 78) + print(f"TOP {TOP_N_PIPE_PATHS} PIPE/PIPES PRIMS BY TRIANGLE COUNT") + print("=" * 78) + for i, (path, cat, is_mesh, tris) in enumerate(worst[:TOP_N_PIPE_PATHS], 1): + tag = "MESH" if is_mesh else "----" + print(f" {i:>3} tris={tris:>10,} {tag} cat={cat!r} {path}") + + # CSV: per-prim Pipe/Pipes + if OUTPUT_CSV: + try: + with open(OUTPUT_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "category", "is_mesh", "tri_count"]) + for path, cat, is_mesh, tris in pipe_records: + w.writerow([path, cat, is_mesh, tris]) + print(f"\n[csv] wrote {OUTPUT_CSV} ({len(pipe_records)} rows)") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + # CSV: full category distribution + if OUTPUT_DISTRIBUTION_CSV: + try: + with open(OUTPUT_DISTRIBUTION_CSV, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["category", "count"]) + for cat, c in category_counter.most_common(): + w.writerow([cat, c]) + print(f"[csv] wrote {OUTPUT_DISTRIBUTION_CSV} " + f"({len(category_counter)} rows)") + except Exception as e: + print(f"[csv] distribution write failed: {e}") + + print("\n[done]") + + +async def _wrapped(): + try: + await _hoops_main() + except asyncio.CancelledError: + print("[cancelled]") + raise + except Exception: + traceback.print_exc() + + +def _on_done(t): + try: t.result() + except Exception: pass + + +_HOOPS_CAT_TASK = asyncio.ensure_future(_wrapped()) +_HOOPS_CAT_TASK.add_done_callback(_on_done) +print("[started] async HOOPS Category Pipe/Pipes scan — cell returns now, " + "work runs in background.") +print(" re-run this cell to cancel and restart.") diff --git a/dev/samples/export_carb_settings.py b/dev/samples/export_carb_settings.py new file mode 100644 index 0000000..f14a9ff --- /dev/null +++ b/dev/samples/export_carb_settings.py @@ -0,0 +1,77 @@ +import os +import json +from datetime import datetime, timezone + +import carb +import carb.settings +import omni.kit.app + +OUT = os.path.abspath(os.path.expanduser("./carb_settings.json")) + +EXCEPTION_LIST = { + "/crashreporter/data/lastCommands", + "/crashreporter/data/lastCommand", +} +EXCLUDE_TOKENS = ( + "/Path", + "/app/tokens", + "/privacy", + "/crashreporter", + "/persistent/app/viewport/outline/shadeColor/", + "/persistent/app/viewport/outline/color/", + "/app/exts/foldersCore/", + "/app/exts/folders/", + "/app/python/sysPaths/", + "/app/python/scriptFolders/", + "/0/0/GL", + "2.amazonaws", +) + + +def _skip(path): + if path in EXCEPTION_LIST: + return True + if len(path) >= 2 and path[1].isupper(): + return True + return any(path.startswith(t) for t in EXCLUDE_TOKENS) + + +def _flatten(node, prefix=""): + out = {} + if isinstance(node, dict): + for k, v in node.items(): + out.update(_flatten(v, prefix + "/" + k)) + else: + if not _skip(prefix): + out[prefix] = node + return out + + +settings = carb.settings.get_settings() +flat = _flatten(settings.get("/")) + +for var in ("PXR_WORK_THREAD_LIMIT", "OPENBLAS_NUM_THREADS", + "GOTO_NUM_THREADS", "OMP_NUM_THREADS"): + flat[var] = os.environ.get(var, -1) + +app = omni.kit.app.get_app() +mgr = app.get_extension_manager() +exts = {} +for mod in mgr.get_enabled_extension_module_names(): + ext_id = mgr.get_extension_id_by_module(mod) + exts.setdefault(ext_id, []).append(mod) + +payload = { + "utc_datetime": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"), + "app_name": app.get_app_name(), + "app_version": app.get_app_version(), + "kit_version": app.get_kit_version(), + "settings": dict(sorted(flat.items())), + "extensions": exts, +} + +os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True) +with open(OUT, "w") as f: + json.dump(payload, f, indent=4, default=str) + +print("[dump_carb_settings] wrote {} ({} keys)".format(OUT, len(flat))) diff --git a/dev/samples/export_stage_stats.py b/dev/samples/export_stage_stats.py new file mode 100644 index 0000000..3031a50 --- /dev/null +++ b/dev/samples/export_stage_stats.py @@ -0,0 +1,122 @@ +import asyncio +import json +import logging +import os + +import omni +import omni.kit.app +import omni.ui +from pxr import UsdUtils + +logger = logging.getLogger(__name__) + +OUTPUT_JSON = "./stage_stats.json" + + +async def wait_for_frames(count: int): + for _ in range(count): + await omni.kit.app.get_app().next_update_async() + + +async def get_stage_stats() -> dict[str, dict]: + # 1) Try to get Statistics window handle + stats_window = omni.ui.Workspace.get_window("Statistics") + if not stats_window: + # If not available, enable omni.kit.window.stats extension immediately + try: + manager = omni.kit.app.get_app().get_extension_manager() + manager.set_extension_enabled_immediate("omni.kit.window.stats", True) + stats_window = omni.ui.Workspace.get_window("Statistics") + except Exception as e: + logger.warning(f"Failed to enable omni.kit.window.stats: {e}") + + # 2) Show the Statistics window and wait 200 frames so stats accumulate + if stats_window: + omni.ui.Workspace.show_window("Statistics", True) + # Wait for frames to ensure stats are populated + await wait_for_frames(200) + + # 3) Get the active stage + stage = omni.usd.get_context().get_stage() + if not stage: + logger.warning("get_stage_stats: No stage found, won't return any data") + return {} + + # 4) USD stats + usd_stats = UsdUtils.ComputeUsdStageStats(stage) + stage_stat = {"usd_stat": {}, "rtx_stat": {}, "so_stat": {}} + if usd_stats: + stage_stat["usd_stat"] = usd_stats + + # 5) Stats interface + scopes + _stats_if = omni.stats.get_stats_interface() + _scopes = _stats_if.get_scopes() + + # 6) Collect RTX Scene stats (flat) + rtx_stat = {} + stats = [] + _scopes_rtx = [x for x in _scopes if x["name"] in "RTX Scene"] + for scope in _scopes_rtx: + stats = _stats_if.get_stats(scope["scopeId"]) + + for x in stats: + name = x["name"].replace(" - ", "_") + name = name.replace(" ", "_") + rtx_stat[name] = x["value"] + stage_stat["rtx_stat"] = rtx_stat + + # 7) Collect Scene Optimizer stats with nested hierarchy + def build_hierarchy_by_level(stats_list): + """Build hierarchical stat names based on level field""" + result = {} + stack = [(result, -1, "")] # (current_dict, level, prefix) + + for stat in stats_list: + name = stat["name"].replace(" - ", "_").replace(" ", "_") + level = stat.get("level", 0) + value = stat.get("value", 0) + + # Pop stack until we find the parent level + while len(stack) > 1 and stack[-1][1] >= level: + stack.pop() + + parent_dict, parent_level, parent_prefix = stack[-1] + full_name = f"{parent_prefix}_{name}" if parent_prefix else name + + parent_dict[full_name] = value + + # Push this level onto stack for potential children + stack.append((parent_dict, level, full_name)) + + return result + + so_stat = {} + _scopes_so = [x for x in _scopes if "Scene Optimizer" in x["name"]] + for scope in _scopes_so: + so_stats = _stats_if.get_stats_nested(scope["scopeId"]) + if so_stats: + scope_stats = build_hierarchy_by_level(so_stats) + so_stat.update(scope_stats) + + stage_stat["so_stat"] = so_stat + + # 8) Hide Statistics window + if stats_window: + omni.ui.Workspace.show_window("Statistics", False) + await wait_for_frames(2) + + return stage_stat + + +async def main(): + stats = await get_stage_stats() + print(stats) + try: + out_path = os.path.abspath(os.path.expanduser(OUTPUT_JSON)) + with open(out_path, "w") as f: + json.dump(stats, f, indent=4, default=str) + print(f"[stage_stats] wrote {out_path}") + except Exception as e: + print(f"[stage_stats] write failed: {e}") + +asyncio.ensure_future(main()) diff --git a/dev/samples/standalone_usd_disk_scan.py b/dev/samples/standalone_usd_disk_scan.py new file mode 100644 index 0000000..a4bb94a --- /dev/null +++ b/dev/samples/standalone_usd_disk_scan.py @@ -0,0 +1,122 @@ +# ============================================================================= +# Standalone USD Disk Scanner — runs OUTSIDE Kit +# ----------------------------------------------------------------------------- +# *** This is the ONLY script in this folder that does NOT run in Kit. *** +# Run it from a regular Python shell (Windows: cmd / PowerShell ; Linux: bash). +# +# Why a standalone tool: +# Before opening a heavy scene in Kit, customers often want to know how many +# USD files are sitting in a folder and how big they are. This walks a +# directory tree with the OS only — no Kit, no USD libraries needed. +# +# What it answers: +# - How many separate USD files (by extension: .usd / .usda / .usdc / .usdz) +# - Total / per-file sizes on disk +# - Top-N largest files +# - Extension breakdown +# +# Dependencies: +# Python 3.8+. No pip install needed. +# +# Usage: +# python standalone_usd_disk_scan.py [--csv out.csv] [--top 30] +# +# Example: +# python standalone_usd_disk_scan.py D:\customer\skt_factory +# python standalone_usd_disk_scan.py /mnt/data/skt --csv ./skt_files.csv +# ============================================================================= + +import argparse +import csv +import os +import sys +from collections import Counter + + +USD_EXTS = (".usd", ".usda", ".usdc", ".usdz") + + +def main(argv=None): + ap = argparse.ArgumentParser(description="Standalone USD file inventory (no Kit needed).") + ap.add_argument("root", help="Root folder to scan (recursively).") + ap.add_argument("--csv", default="./standalone_usd_disk_scan.csv", + help="Output CSV path (default: ./standalone_usd_disk_scan.csv).") + ap.add_argument("--top", type=int, default=30, + help="Print Top-N largest files (default: 30).") + args = ap.parse_args(argv) + + root = args.root + if not os.path.isdir(root): + print(f"[error] not a directory: {root}", file=sys.stderr) + return 2 + + print(f"[scanning] {os.path.abspath(root)}") + + rows = [] + ext_counter = Counter() + total_bytes = 0 + errors = 0 + + for dirpath, _dirnames, filenames in os.walk(root): + for fn in filenames: + ext = os.path.splitext(fn)[1].lower() + if ext not in USD_EXTS: + continue + full = os.path.join(dirpath, fn) + try: + size = os.path.getsize(full) + except OSError: + errors += 1 + size = -1 + ext_counter[ext] += 1 + if size >= 0: + total_bytes += size + rows.append((full, ext, size)) + + n = len(rows) + print() + print("=" * 78) + print("USD FILES ON DISK") + print("=" * 78) + print(f" Root : {os.path.abspath(root)}") + print(f" Total USD files : {n:,}") + print(f" Total size on disk : {total_bytes:,} bytes " + f"({total_bytes / (1024 * 1024):.2f} MiB ; {total_bytes / (1024 ** 3):.3f} GiB)") + print(f" Read errors : {errors:,}") + + if ext_counter: + print() + print("EXTENSION BREAKDOWN") + for ext, c in ext_counter.most_common(): + print(f" {ext:<8} {c:>6,}") + + # Top-N largest + rows_sorted = sorted([r for r in rows if r[2] >= 0], + key=lambda r: r[2], reverse=True) + if rows_sorted: + print() + print("=" * 78) + print(f"TOP {args.top} LARGEST USD FILES") + print("=" * 78) + print(f" {'#':>3} {'size (MiB)':>12} {'ext':>6} path") + for i, (path, ext, size) in enumerate(rows_sorted[: args.top], 1): + mib = size / (1024 * 1024) + print(f" {i:>3} {mib:>12.2f} {ext:>6} {path}") + + # CSV + try: + with open(args.csv, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["path", "ext", "size_bytes"]) + for path, ext, size in rows: + w.writerow([path, ext, size]) + print(f"\n[csv] wrote {os.path.abspath(args.csv)} ({n} rows)") + except Exception as e: + print(f"\n[csv] write failed: {e}") + + print("\n[done]") + return 0 + + +if __name__ == "__main__": + sys.exit(main())