"""Door robot arm: geometry audit (writes build/geo_report.json through dra_build.py --stage geo_audit). Per mesh: game triangles, quads, triangle faces, n-gons, open edges (on markings and elsewhere), zero-area faces, duplicate faces (the same corners twice). Crossings in the rest pose: every pair of parts inside one link (a part may sit in its declared seats), every pair of objects of one showroom arm, each arm against the floor rail, and in the ship scene the rail kits, chains and door fixtures against each other and the ship context. A crossing is a pair of triangles that each reach more than EPS through the other's plane and really meet (touching is not crossing). """ import math import bpy import bmesh from mathutils import Vector from mathutils.bvhtree import BVHTree from mathutils.geometry import intersect_tri_tri_2d EPS = 0.0002 def mesh_stats(me): bm = bmesh.new() bm.from_mesh(me) lay = bm.faces.layers.int.get('part') decals = list(me.get("decals", [])) q = sum(1 for f in bm.faces if len(f.verts) == 4) t = sum(1 for f in bm.faces if len(f.verts) == 3) ng = sum(1 for f in bm.faces if len(f.verts) > 4) tris = sum(len(f.verts) - 2 for f in bm.faces) open_e = 0 open_dec = 0 for e in bm.edges: if len(e.link_faces) == 1: f = e.link_faces[0] if lay is not None and decals and f[lay] < len(decals) and decals[f[lay]]: open_dec += 1 else: open_e += 1 zero = sum(1 for f in bm.faces if f.calc_area() < 1e-10) seen = {} dup = 0 for f in bm.faces: k = tuple(sorted((round(v.co.x, 6), round(v.co.y, 6), round(v.co.z, 6)) for v in f.verts)) if k in seen: dup += 1 else: seen[k] = 1 nonman = sum(1 for e in bm.edges if len(e.link_faces) > 2) bm.free() return dict(game_tris=tris, quads=q, tri_faces=t, ngons=ng, open_edges=open_e, open_edges_on_markings=open_dec, zero_area_faces=zero, duplicate_faces=dup, non_manifold_edges=nonman, uv_layers=[u.name for u in me.uv_layers], materials=[m.name for m in me.materials if m]) DG = {"dg": None} def _tris_world(ob, part_filter=None): """world triangles of an object, optionally only the faces of some parts -> (verts, tris)""" me = ob.data M = ob.evaluated_get(DG["dg"]).matrix_world if DG["dg"] is not None else ob.matrix_world verts = [M @ v.co for v in me.vertices] lay = me.attributes.get('part') tris = [] tparts = [] ids = list(me.get("parts", [])) me.calc_loop_triangles() for lt in me.loop_triangles: pv = lay.data[lt.polygon_index].value if lay is not None else -1 if part_filter is not None: if lay is None or pv not in part_filter: continue tris.append(tuple(lt.vertices)) tparts.append(ids[pv] if 0 <= pv < len(ids) else ob.name) PARTS_OF[ob.name] = tparts return verts, tris def _tree(verts, tris): if not tris: return None return BVHTree.FromPolygons(verts, tris, all_triangles=True) def _straddles(p, q): n = (q[1] - q[0]).cross(q[2] - q[0]) if n.length < 1e-14: return False n.normalize() d = [(x - q[0]).dot(n) for x in p] return max(d) > EPS and min(d) < -EPS PARTS_OF = {} def crossing(ga, gb, pairs_out=None): (va, ta, tra), (vb, tb, trb) = ga, gb if tra is None or trb is None: return 0 k = 0 for i, j in tra.overlap(trb): p = [va[v] for v in ta[i]] q = [vb[v] for v in tb[j]] if _straddles(p, q) and _straddles(q, p): k += 1 if pairs_out is not None: pairs_out.append((i, j)) return k def _geo(ob, cache, part_filter=None): key = (ob.name, tuple(sorted(part_filter)) if part_filter else None) if key not in cache: v, t = _tris_world(ob, part_filter) cache[key] = (v, t, _tree(v, t)) return cache[key] def _seat_ok(a, b): """part a may sit in part b (or b in a)""" def base(x): return x[:-2] if x[-2:] in ('.L', '.R') else x for x, y in ((a, b), (b, a)): for s in x['seats']: if s == y['id'] or s == base(y['id']): return True return False def within_link(ob): """crossings between the parts of one link mesh (rest), skipping seats and markings""" me = ob.data ids = list(me.get("parts", [])) seats = list(me.get("seats", [])) decals = list(me.get("decals", [])) parts = [dict(id=ids[i], seats=set(s for s in seats[i].split(";") if s), decal=bool(decals[i])) for i in range(len(ids))] lay = me.attributes.get('part') if lay is None: return [] verts = [v.co.copy() for v in me.vertices] me.calc_loop_triangles() per = {} for lt in me.loop_triangles: per.setdefault(lay.data[lt.polygon_index].value, []).append(tuple(lt.vertices)) trees = {} boxes = {} for k, tris in per.items(): if k >= len(parts) or parts[k]['decal']: continue trees[k] = (verts, tris, _tree(verts, tris)) pts = [verts[i] for t in tris for i in t] boxes[k] = (Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))), Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))) out = [] keys = sorted(trees) for ii, a in enumerate(keys): for b in keys[ii + 1:]: if parts[a]['id'] == parts[b]['id']: continue if _seat_ok(parts[a], parts[b]): continue (la, ha), (lb, hb) = boxes[a], boxes[b] if any(la[i] > hb[i] + 1e-6 or lb[i] > ha[i] + 1e-6 for i in range(3)): continue n = crossing(trees[a], trees[b]) if n: out.append([parts[a]['id'], parts[b]['id'], n]) return out def _nondecal_parts(me): dec = list(me.get("decals", [])) return set(i for i in range(len(dec)) if not dec[i]) if dec else None def pairs(obs, others, cache, skip=None): out = [] for i, a in enumerate(obs): for b in obs[i + 1:] + others: if a is b or (skip and skip(a, b)): continue ga = _geo(a, cache, _nondecal_parts(a.data)) gb = _geo(b, cache, _nondecal_parts(b.data) if b.get("parts") is not None or b.data.get("parts") else None) pl = [] n = crossing(ga, gb, pl) if n: pa, pb = PARTS_OF.get(a.name, []), PARTS_OF.get(b.name, []) det = sorted(set("%s x %s" % (pa[i] if i < len(pa) else "?", pb[j] if j < len(pb) else "?") for i, j in pl)) out.append([a.name, b.name, n, det[:12]]) return out def audit(): rep = {"meshes": {}, "within_link": {}, "between_objects": {}, "ship_fit": [], "notes": []} done = set() for ob in bpy.data.objects: if ob.type != 'MESH' or ob.data.name in done: continue if ob.get("reference_only") or ob.name.startswith("Showroom | cyclorama"): continue done.add(ob.data.name) rep["meshes"][ob.data.name] = mesh_stats(ob.data) # within links (one object per mesh is enough) seen = set() for ob in bpy.data.objects: if ob.type == 'MESH' and ob.data.get("parts") is not None and ob.data.name not in seen: seen.add(ob.data.name) w = within_link(ob) if w: rep["within_link"][ob.data.name] = w # showroom arms: every pair of objects of an arm, and against the floor rail sc = bpy.data.scenes.get("DoorArm | Showroom") cache = {} if sc: pass sc.frame_set(1) DG["dg"] = sc.view_layers[0].depsgraph DG["dg"].update() rail = bpy.data.objects.get("Showroom | floor rail") for v in ("A", "B"): obs = [o for o in sc.objects if o.type == 'MESH' and o.name.startswith("Showroom | arm %s |" % v)] res = pairs(obs, [rail] if rail else [], cache) rep["between_objects"]["showroom arm " + v] = res rep.setdefault("curves", {})["showroom arm " + v] = curve_crossings(sc, v, obs) # ship fit: kits, chain links, fixtures against each other and the context, at frame 1. The rigged arms are the rig # check's (every frame); a hidden version is not evaluated (its objects wait where they were), so each version's # fixtures are checked with that version switched on sf = bpy.data.scenes.get("DoorArm | Ship fit") if sf: wm = bpy.context.window_manager if wm.windows: wm.windows[0].scene = sf ctl = bpy.data.objects.get("DoorArm | CONTROLS") arms = tuple("DoorArm_%s_%s |" % (k, v) for k in ("AFT", "FORE") for v in ("A", "B")) rep["ship_fit"] = [] for ver in ("A", "B"): if ctl is not None: ctl["version"] = 0 if ver == "A" else 1 ctl.update_tag() sf.frame_set(1) DG["dg"] = sf.view_layers[0].depsgraph DG["dg"].update() mine = [o for o in sf.objects if o.type == 'MESH' and o.name.startswith("DoorArm_") and not o.name.startswith(arms) and o.get("version") in (None, ver)] ctx = [o for o in sf.objects if o.type == 'MESH' and o.get("reference_only") and not o.hide_render] for x in pairs(mine, ctx, {}): if x not in rep["ship_fit"]: rep["ship_fit"].append(x) if ctl is not None: ctl["version"] = 0 ctl.update_tag() sf.frame_set(1) tot_w = sum(len(v) for v in rep["within_link"].values()) tot_b = sum(len(v) for v in rep["between_objects"].values()) rep["summary"] = dict(meshes=len(rep["meshes"]), game_tris_arm_A=sum(rep["meshes"].get("DoorArm | " + n, {}).get("game_tris", 0) for n in _arm_links("A")), game_tris_arm_B=sum(rep["meshes"].get("DoorArm | " + n, {}).get("game_tris", 0) for n in _arm_links("B")), ngons=sum(m["ngons"] for m in rep["meshes"].values()), open_edges=sum(m["open_edges"] for m in rep["meshes"].values()), zero_area=sum(m["zero_area_faces"] for m in rep["meshes"].values()), duplicates=sum(m["duplicate_faces"] for m in rep["meshes"].values()), non_manifold_edges=sum(m["non_manifold_edges"] for m in rep["meshes"].values()), crossing_pairs_within_links=tot_w, crossing_pairs_between_objects=tot_b, crossing_pairs_ship_fit=len(rep["ship_fit"]), curve_crossings=sum(len(x) for x in rep.get("curves", {}).values())) return rep def _arm_links(v): import dra_rigdata as rd out = [] for s in rd.LINKS: if s["tool"] and s["tool"] != v: continue out.append(s["name"]) return out CURVE_SEATS = {"U08", "U08g", "L17", "R04p", "R01", "L03.L", "L03.R", "L04b.L", "L04b.R"} def curve_crossings(sc, v, obs): """the cables over the elbow and the cylinder hoses (curves) against every object of the arm; their ends sit in their glands / ports / the channel wall (CURVE_SEATS)""" dg = sc.view_layers[0].depsgraph out = [] curves = [o for o in sc.objects if o.type == 'CURVE' and o.name.startswith("Showroom | arm %s |" % v)] for cu in curves: ev = cu.evaluated_get(dg) me = ev.to_mesh() M = ev.matrix_world verts = [M @ vv.co for vv in me.vertices] me.calc_loop_triangles() tris = [tuple(lt.vertices) for lt in me.loop_triangles] ev.to_mesh_clear() gc = (verts, tris, _tree(verts, tris)) for ob in obs: gb = _geo(ob, {}, None) pl = [] n = crossing(gc, gb, pl) if n: parts = PARTS_OF.get(ob.name, []) hit = sorted(set(parts[j] if j < len(parts) else "?" for i, j in pl)) bad = [h for h in hit if h not in CURVE_SEATS] if bad: out.append([cu.name, ob.name, n, bad[:12]]) return out