"""Critique 2: what the user meets when opening DoorRobotArm.blend, and the R5 / R7 rules on the real data. Read-only: the file is opened headless and never saved. blender -b --factory-startup "" --python previews/critique2/crit2_inspect.py -- """ import json import math import os import sys import bmesh import bpy from mathutils import Matrix, Vector OUT = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "crit2_inspect.json" R = {} def fmt(v, n=4): return [round(x, n) for x in v] # ---------------------------------------------------------------- 1. the file as it opens wm = bpy.context.window_manager R["open"] = { "context_scene": bpy.context.scene.name, "windows": [[w.scene.name, w.view_layer.name, w.workspace.name if w.workspace else None] for w in wm.windows], "scenes": [s.name for s in bpy.data.scenes], "filepath": bpy.data.filepath, } views = [] for scr in bpy.data.screens: for area in scr.areas: if area.type == 'VIEW_3D': sp = area.spaces.active views.append([scr.name, sp.shading.type, sp.region_3d.view_perspective if sp.region_3d else None, fmt(sp.region_3d.view_location, 2) if sp.region_3d else None, round(sp.region_3d.view_distance, 2) if sp.region_3d else None, sp.clip_end]) R["open"]["view3d"] = views def walk_layer(lc, out, depth=0): out.append([" " * depth + lc.name, "exclude" if lc.exclude else "", "eye_off" if lc.hide_viewport else "", "coll_disabled" if lc.collection.hide_viewport else "", "coll_norender" if lc.collection.hide_render else "", len(lc.collection.objects)]) for c in lc.children: walk_layer(c, out, depth + 1) R["collections"] = {} for sc in bpy.data.scenes: out = [] for vl in sc.view_layers: walk_layer(vl.layer_collection, out) R["collections"][sc.name] = out # objects not in any scene, junk inscene = set() for sc in bpy.data.scenes: for o in sc.objects: inscene.add(o.name) R["objects_total"] = len(bpy.data.objects) R["objects_in_no_scene"] = [o.name for o in bpy.data.objects if o.name not in inscene][:40] R["cameras"] = [o.name for o in bpy.data.objects if o.type == 'CAMERA'] R["lights"] = {sc.name: [o.name for o in sc.objects if o.type == 'LIGHT'] for sc in bpy.data.scenes} R["worlds"] = {sc.name: (sc.world.name if sc.world else None) for sc in bpy.data.scenes} # ---------------------------------------------------------------- 2. images: relative and resolving imgs = [] for im in bpy.data.images: if im.source not in ('FILE', 'SEQUENCE', 'MOVIE', 'TILED'): imgs.append([im.name, im.source, "", None, None, im.users]) continue fp = im.filepath ab = bpy.path.abspath(fp) imgs.append([im.name, im.source, fp, fp.startswith("//"), os.path.exists(ab) or bool(im.packed_file), im.users]) R["images"] = imgs R["images_missing"] = [i for i in imgs if i[4] is False] R["images_absolute"] = [i for i in imgs if i[3] is False] # materials that use a missing image miss = set(i[0] for i in R["images_missing"]) mm = [] for m in bpy.data.materials: if m.use_nodes and m.node_tree: for n in m.node_tree.nodes: if n.type == 'TEX_IMAGE' and n.image and n.image.name in miss: mm.append([m.name, n.image.name, m.users]) R["materials_with_missing_image"] = mm R["libraries"] = [[l.name, l.filepath] for l in bpy.data.libraries] # ---------------------------------------------------------------- 3. every driver in every datablock coll_names = ["objects", "meshes", "curves", "materials", "node_groups", "scenes", "worlds", "lights", "cameras", "shape_keys", "armatures", "textures", "lattices", "collections", "images", "actions", "grease_pencils", "particles", "linestyles", "speakers", "volumes", "pointclouds", "hair_curves"] drv = {"count": 0, "by_owner_type": {}, "not_simple": [], "invalid": [], "use_self": 0, "var_types": {}, "targets": {}, "frame_vars": 0, "python_funcs": []} for cn in coll_names: col = getattr(bpy.data, cn, None) if col is None: continue for idb in col: ad = getattr(idb, "animation_data", None) if ad is None: continue for fc in ad.drivers: d = fc.driver drv["count"] += 1 drv["by_owner_type"][cn] = drv["by_owner_type"].get(cn, 0) + 1 if d.type != 'SCRIPTED' or not d.is_simple_expression: drv["not_simple"].append([idb.name, fc.data_path, fc.array_index, d.type, d.expression]) if not d.is_valid: drv["invalid"].append([idb.name, fc.data_path, fc.array_index, d.expression]) if d.use_self: drv["use_self"] += 1 if "frame" in d.expression: drv["frame_vars"] += 1 for v in d.variables: drv["var_types"][v.type] = drv["var_types"].get(v.type, 0) + 1 for t in v.targets: if t.id is not None: key = t.id.name if key.startswith("DoorArm_") and " | " in key: key = "DoorArm_* | " + key.split(" | ", 1)[1].split(" ")[0] + "..." drv["targets"][key] = drv["targets"].get(key, 0) + 1 drv["not_simple"] = drv["not_simple"][:20] drv["invalid"] = drv["invalid"][:20] R["drivers"] = drv # ---------------------------------------------------------------- 4. keys: who has an action (only the doors may) acts = [] for o in bpy.data.objects: ad = o.animation_data if ad and (ad.action or len(ad.nla_tracks)): acts.append([o.name, ad.action.name if ad.action else None, len(ad.nla_tracks)]) R["objects_with_actions"] = acts for cn in ("meshes", "curves", "materials", "scenes"): for idb in getattr(bpy.data, cn): ad = getattr(idb, "animation_data", None) if ad and ad.action: R.setdefault("other_actions", []).append([cn, idb.name, ad.action.name]) # ---------------------------------------------------------------- 5. the controls ctl = bpy.data.objects.get("DoorArm | CONTROLS") if ctl: ui = {} for k in ctl.keys(): try: ui[k] = [repr(ctl[k]), type(ctl[k]).__name__, ctl.id_properties_ui(k).as_dict()] except Exception as e: ui[k] = [repr(ctl[k]), type(ctl[k]).__name__, str(e)] R["controls"] = {"scenes": [s.name for s in ctl.users_scene], "collections": [c.name for c in ctl.users_collection], "location": fmt(ctl.location, 3), "props": ui, "hide_viewport": ctl.hide_viewport} # ---------------------------------------------------------------- 6. the showroom: both versions side by side sc = bpy.data.scenes["DoorArm | Showroom"] dg = bpy.context.evaluated_depsgraph_get() def bbox_world(obs): lo = Vector((1e9, 1e9, 1e9)) hi = -lo for o in obs: ev = o.evaluated_get(dg) for c in ev.bound_box: p = ev.matrix_world @ Vector(c) lo = Vector((min(lo[i], p[i]) for i in range(3))) hi = Vector((max(hi[i], p[i]) for i in range(3))) return fmt(lo, 3), fmt(hi, 3) show = {} for v in ("A", "B"): obs = [o for o in sc.objects if o.name.startswith("Showroom | arm %s |" % v) and o.type in ('MESH', 'CURVE')] root = bpy.data.objects.get("Showroom | arm %s" % v) show[v] = {"objects": len(obs), "bbox": bbox_world(obs), "hidden_viewport": [o.name for o in obs if o.hide_viewport or o.hide_get()], "hidden_render": [o.name for o in obs if o.hide_render], "root": fmt(root.matrix_world.translation, 4) if root else None, "has_fingers": any("finger" in o.name for o in obs), "has_tool_B": any(o.name.endswith("| tool B") for o in obs)} # origins on joints, rotation 0, scale 1 bad = [] for o in obs: if o.type != 'MESH': continue if any(abs(a) > 1e-6 for a in o.rotation_euler) or any(abs(s - 1) > 1e-6 for s in o.scale): bad.append([o.name, "rot/scale", fmt(o.rotation_euler, 5), fmt(o.scale, 5)]) ja = o.data.get("joint_A") if ja is not None and root is not None: loc = root.matrix_world.inverted() @ o.matrix_world.translation d = (loc - Vector(ja)).length if d > 1e-4: bad.append([o.name, "origin off joint_A (m)", round(d, 5), fmt(loc), fmt(ja)]) show[v]["origin_rot_scale_issues"] = bad show[v]["modifiers"] = sorted(set(m.type for o in obs for m in o.modifiers)) R["showroom"] = show if show["A"]["bbox"] and show["B"]["bbox"]: R["showroom"]["gap_x_between_A_and_B_m"] = round(show["B"]["bbox"][0][0] - show["A"]["bbox"][1][0], 3) # ---------------------------------------------------------------- 7. modifiers anywhere (booleans?) mods = {} for o in bpy.data.objects: for m in o.modifiers: mods.setdefault(m.type, []).append(o.name) R["modifiers"] = {k: [len(v), v[:6]] for k, v in mods.items()} # ---------------------------------------------------------------- 8. topology of the asset meshes (pole fans vs stray triangles) def topo(me): bm = bmesh.new() bm.from_mesh(me) bm.verts.ensure_lookup_table() q = t = n = 0 stray = 0 stray_pos = [] pole_verts = set() for v in bm.verts: lf = v.link_faces if len(lf) >= 3 and all(len(f.verts) == 3 for f in lf): pole_verts.add(v.index) for f in bm.faces: k = len(f.verts) if k == 4: q += 1 elif k == 3: t += 1 if not any(v.index in pole_verts for v in f.verts): stray += 1 if len(stray_pos) < 5: stray_pos.append(fmt(f.calc_center_median(), 3)) else: n += 1 bm.free() return {"quads": q, "tris": t, "ngons": n, "tri_share": round(t / max(1, q + t + n), 3), "tris_not_in_pole_fans": stray, "stray_share": round(stray / max(1, q + t + n), 3), "stray_examples": stray_pos} topo_rep = {} for me in bpy.data.meshes: if me.users == 0: continue if me.name.startswith("DoorArm") or me.name.startswith("UCX_"): if me.name.startswith("UCX_"): continue topo_rep[me.name] = topo(me) topo_rep[me.name]["uv_layers"] = [u.name for u in me.uv_layers] R["topology"] = topo_rep R["topology_total"] = { "faces": sum(v["quads"] + v["tris"] + v["ngons"] for v in topo_rep.values()), "tris": sum(v["tris"] for v in topo_rep.values()), "tris_not_in_pole_fans": sum(v["tris_not_in_pole_fans"] for v in topo_rep.values()), "ngons": sum(v["ngons"] for v in topo_rep.values()), "meshes_without_2_uv": [k for k, v in topo_rep.items() if len(v["uv_layers"]) < 2], } # ---------------------------------------------------------------- 9. UCX hulls ucx = [o for o in bpy.data.objects if o.name.startswith("UCX_")] u = {"count": len(ucx), "collections": sorted(set(c.name for o in ucx for c in o.users_collection)), "visible_viewport": [o.name for o in ucx if not (o.hide_viewport or o.hide_get())], "render_on": [o.name for o in ucx if not o.hide_render], "parents": sorted(set(o.parent.name if o.parent else "-" for o in ucx))[:30], "nonconvex": [], "materials": []} for o in ucx: me = o.data if len(me.materials): u["materials"].append(o.name) vs = [v.co for v in me.vertices] worst = 0.0 for p in me.polygons: nrm = p.normal c = p.center for v in vs: d = (v - c).dot(nrm) worst = max(worst, d) if worst > 1e-4: u["nonconvex"].append([o.name, round(worst, 5)]) # eye state of the collision collection in each scene eye = {} for s in bpy.data.scenes: def find(lc): if lc.name == "DoorArm | collision": return lc for c in lc.children: r = find(c) if r: return r return None for vl in s.view_layers: lc = find(vl.layer_collection) if lc: eye[s.name] = {"exclude": lc.exclude, "eye_off": lc.hide_viewport, "coll_disabled": lc.collection.hide_viewport, "coll_norender": lc.collection.hide_render} u["collection_state"] = eye R["ucx"] = u # ---------------------------------------------------------------- 10. FBX files next to the blend fdir = os.path.join(os.path.dirname(bpy.data.filepath), "fbx") R["fbx"] = sorted([[f, os.path.getsize(os.path.join(fdir, f))] for f in os.listdir(fdir)]) if os.path.isdir(fdir) else None with open(OUT, "w") as f: json.dump(R, f, indent=1) print("[crit2] inspect written", OUT, flush=True)