# Critique round 2 (read-only, never saves): objects, n-gons, materials, twist audit, decal coverage, drum-mount pieces # blender -b Tender_Grouper.blend --python critique/round2/cr2_audit.py import bpy, bmesh, json, math, os, collections import numpy as np from mathutils import Vector from mathutils.bvhtree import BVHTree ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) OUT = os.path.join(ROOT, "critique", "round2") out = {"objects": {}, "materials": {}} dg = bpy.context.evaluated_depsgraph_get() for o in bpy.data.objects: if o.type != "MESH": continue me = o.data out["objects"][o.name] = dict(tris=sum(len(p.vertices) - 2 for p in me.polygons), faces=len(me.polygons), ngons=sum(1 for p in me.polygons if len(p.vertices) > 4), tri_faces=sum(1 for p in me.polygons if len(p.vertices) == 3), mats=[s.material.name if s.material else None for s in o.material_slots], uv=[u.name for u in me.uv_layers], hide_render=o.hide_render, coll=[c.name for c in o.users_collection]) for m in bpy.data.materials: if not m.use_nodes: continue info = {} for n in m.node_tree.nodes: if n.type == "BSDF_PRINCIPLED": for k in ("Metallic", "Roughness", "Coat Weight", "Coat Roughness", "Emission Strength", "Transmission Weight"): i = n.inputs.get(k) if i is None: continue info[k] = ("linked:" + i.links[0].from_node.name) if i.is_linked else round(i.default_value, 3) if n.type == "TEX_IMAGE" and n.image: info.setdefault("images", []).append(os.path.basename(n.image.filepath)) out["materials"][m.name] = info # ---- twist audit on the hull, binned by place o = bpy.data.objects["SM_Grouper_Hull"] me = o.data bm = bmesh.new(); bm.from_mesh(me) tw = [] for f in bm.faces: if len(f.verts) != 4: continue a, b, c, d = [v.co for v in f.verts] n1 = (b - a).cross(c - a); n2 = (c - a).cross(d - a) n3 = (b - a).cross(d - a); n4 = (c - b).cross(d - b) if min(n1.length, n2.length, n3.length, n4.length) < 1e-12: continue ang = max(math.degrees(n1.angle(n2)), math.degrees(n3.angle(n4))) tw.append((ang, tuple(round(v, 3) for v in f.calc_center_median()), me.materials[f.material_index].name if me.materials else "", f.calc_area())) angs = np.array([t[0] for t in tw]) out["twist"] = {"quads": len(tw), "over_20": int((angs > 20).sum()), "over_25": int((angs > 25).sum()), "over_45": int((angs > 45).sum()), "over_90": int((angs > 90).sum())} big = sorted([t for t in tw if t[0] > 45], reverse=True) out["twist_over45"] = [[round(t[0]), t[1], t[2], round(t[3] * 1e4, 1)] for t in big[:40]] cnt = collections.Counter((t[2], round(t[1][1] * 2) / 2) for t in tw if t[0] > 25) out["twist25_by_mat_y"] = sorted([[k[0], k[1], v] for k, v in cnt.items()], key=lambda r: -r[2])[:25] # loose pieces in the hull bm.verts.ensure_lookup_table() seen = set(); pieces = [] for f in bm.faces: if f.index in seen: continue stack = [f]; seen.add(f.index); fs = [] while stack: g = stack.pop(); fs.append(g) for e in g.edges: for h in e.link_faces: if h.index not in seen: seen.add(h.index); stack.append(h) co = np.array([v.co[:] for g in fs for v in g.verts]) pieces.append(dict(n=len(fs), lo=co.min(0).round(3).tolist(), hi=co.max(0).round(3).tolist(), mat=me.materials[fs[0].material_index].name if me.materials else "")) out["hull_pieces"] = len(pieces) # pieces in the drum mount region (forward of the chin, under the nose): y < -2.6, z < 0 out["mount_pieces"] = [p for p in pieces if p["hi"][1] < -2.55 and p["lo"][2] < -0.2 and p["n"] < 3000] out["mount_pieces"].sort(key=lambda p: p["lo"][1]) bm.free() # ---- decal coverage: rasterise the decal mesh's UV faces and compare with the decal sheet's alpha dec = bpy.data.objects["SM_Grouper_Decals"] dme = dec.data R = 1024 cover = np.zeros((R, R), bool) uvl = dme.uv_layers["UVMap"].data for p in dme.polygons: pts = [uvl[li].uv.copy() for li in range(p.loop_start, p.loop_start + p.loop_total)] for k in range(1, len(pts) - 1): tri = np.array([pts[0], pts[k], pts[k + 1]]) * R x0, y0 = np.floor(tri.min(0)).astype(int); x1, y1 = np.ceil(tri.max(0)).astype(int) x0, y0 = max(x0, 0), max(y0, 0); x1, y1 = min(x1, R - 1), min(y1, R - 1) if x1 < x0 or y1 < y0: continue xs, ys = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5) (ax, ay), (bx, by), (cx, cy) = tri den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) if abs(den) < 1e-12: continue l1 = ((by - cy) * (xs - cx) + (cx - bx) * (ys - cy)) / den l2 = ((cy - ay) * (xs - cx) + (ax - cx) * (ys - cy)) / den l3 = 1 - l1 - l2 ins = (l1 >= -1e-6) & (l2 >= -1e-6) & (l3 >= -1e-6) cover[y0:y1 + 1, x0:x1 + 1] |= ins img = None for im in bpy.data.images: if "Decals_BaseColor" in im.name: img = im alpha = None try: w, h = img.size px = np.array(img.pixels[:], dtype=np.float32).reshape(h, w, 4) a = px[:, :, 3] # downsample to R by max pooling f = w // R alpha = a.reshape(R, f, R, f).max(axis=(1, 3)) > 0.5 except Exception as e: out["decal_err"] = repr(e) dj = json.load(open(os.path.join(ROOT, "build", "decals.json"))) cov = {} if alpha is not None: for name, t in dj["tiles"].items(): u0, v0, u1, v1 = t["uv"] sl = (slice(int(v0 * R), int(v1 * R)), slice(int(u0 * R), int(u1 * R))) A = alpha[sl]; C = cover[sl] miss = A & ~C cov[name] = dict(alpha_px=int(A.sum()), uncovered_px=int(miss.sum()), uncovered_pct=round(100 * miss.sum() / max(1, A.sum()), 2)) if miss.sum(): ys, xs = np.where(miss) cov[name]["uncovered_bbox_in_tile_uv01"] = [round(xs.min() / A.shape[1], 3), round(ys.min() / A.shape[0], 3), round(xs.max() / A.shape[1], 3), round(ys.max() / A.shape[0], 3)] vis = np.zeros((R, R, 3), np.uint8) vis[alpha] = (120, 120, 120); vis[cover] = (40, 90, 160); vis[alpha & cover] = (220, 220, 220); vis[alpha & ~cover] = (255, 0, 0) try: im2 = bpy.data.images.new("cr2_cov", R, R) rgba = np.concatenate([vis.astype(np.float32) / 255.0, np.ones((R, R, 1), np.float32)], 2) im2.pixels[:] = rgba.ravel() im2.filepath_raw = os.path.join(OUT, "decal_coverage.png"); im2.file_format = "PNG"; im2.save() except Exception as e: out["cov_img_err"] = repr(e) out["decal_coverage"] = cov json.dump(out, open(os.path.join(OUT, "audit.json"), "w"), indent=1) print("CR2 DONE", json.dumps(out["twist"]), json.dumps(cov))