# Critique round 2 (read-only, never saves): see-through holes and inside faces seen from outside. # Rays from 400 camera positions on a sphere (r 9 m round the hull's centre) at a grid over the model; a ray whose first hit # (glass skipped: it is transparent) is the BACK of a hull, door or hatch face shows the inside of the shell from outside. # blender -b Tender_Grouper.blend --python critique/round2/cr2_holes.py import bpy, bmesh, json, math, os, collections 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") dg = bpy.context.evaluated_depsgraph_get() NAMES = ["SM_Grouper_Hull", "SM_Grouper_Door_L", "SM_Grouper_Door_R", "SM_Grouper_Hatch", "SM_Grouper_Rotor", "SM_Grouper_Antenna", "SM_Grouper_Cabin", "SM_Grouper_Decals"] verts, polys, owner = [], [], [] for nm in NAMES: o = bpy.data.objects[nm] me = o.evaluated_get(dg).to_mesh() M = o.matrix_world base = len(verts) verts += [M @ v.co for v in me.vertices] for p in me.polygons: polys.append([base + i for i in p.vertices]); owner.append(nm) o.evaluated_get(dg).to_mesh_clear() bvh = BVHTree.FromPolygons(verts, polys, all_triangles=False, epsilon=0.0) # face normals in world space (from the polygon winding) def fnormal(fi): p = polys[fi] n = Vector() for k in range(len(p)): a, b = verts[p[k]], verts[p[(k + 1) % len(p)]] n.x += (a.y - b.y) * (a.z + b.z); n.y += (a.z - b.z) * (a.x + b.x); n.z += (a.x - b.x) * (a.y + b.y) return n.normalized() C = Vector((0.0, 0.0, -0.1)) hits = collections.Counter(); where = collections.defaultdict(list); total = 0 golden = math.pi * (3 - math.sqrt(5)) N = 400 for i in range(N): zz = 1 - 2 * (i + 0.5) / N r = math.sqrt(1 - zz * zz) th = golden * i eye = C + Vector((r * math.cos(th), r * math.sin(th), zz)) * 9.0 fwd = (C - eye).normalized() right = fwd.cross(Vector((0, 0, 1))) if right.length < 1e-6: right = Vector((1, 0, 0)) right.normalize(); up = right.cross(fwd) for a in range(-40, 41): for b in range(-40, 41): d = (fwd + right * (a * 0.0105) + up * (b * 0.0105)).normalized() loc, n, fi, dist = bvh.ray_cast(eye, d) if loc is None: continue total += 1 fn = n if fn.dot(d) > 0.05: # we see the back of this face key = (owner[fi], round(loc.x, 1), round(loc.y, 1), round(loc.z, 1)) hits[key] += 1 if len(where[key]) < 1: where[key].append([round(loc.x, 3), round(loc.y, 3), round(loc.z, 3), i]) res = {"rays_hitting": total, "back_face_hits": sum(hits.values()), "clusters": [[k[0], k[1:], v, where[k][0]] for k, v in hits.most_common(60)]} json.dump(res, open(os.path.join(OUT, "holes.json"), "w"), indent=1) print("CR2 HOLES", total, sum(hits.values()), len(hits)) for k, v in hits.most_common(30): print("BACK", k, v, where[k][0]) # ---- second pass: which faces are seen from behind, and is their normal flipped (pointing at the part's own inside)? faces = collections.Counter(); samp = {} for i in range(N): zz = 1 - 2 * (i + 0.5) / N r = math.sqrt(1 - zz * zz) th = golden * i eye = C + Vector((r * math.cos(th), r * math.sin(th), zz)) * 9.0 fwd = (C - eye).normalized() right = fwd.cross(Vector((0, 0, 1))) if right.length < 1e-6: right = Vector((1, 0, 0)) right.normalize(); up = right.cross(fwd) for a in range(-40, 41): for b in range(-40, 41): d = (fwd + right * (a * 0.0105) + up * (b * 0.0105)).normalized() loc, n, fi, dist = bvh.ray_cast(eye, d) if loc is not None and n.dot(d) > 0.05: faces[fi] += 1 samp.setdefault(fi, (eye.copy(), d.copy(), loc.copy())) # for each such face: cast from just in front of its FRONT side outward along its normal: if the ray escapes (no hit # within 3 m), the front side faces open space -> the face is a correctly wound outer face seen through a gap (a hole); # if it hits our own shell, the face's front looks INTO the part (its normal is flipped: it is an outer face wound inward) rows = [] for fi, k in faces.most_common(): p = polys[fi] c = sum((verts[j] for j in p), Vector()) / len(p) n = fnormal(fi) h_front = bvh.ray_cast(c + n * 0.002, n, 3.0) h_back = bvh.ray_cast(c - n * 0.002, -n, 3.0) rows.append(dict(face=fi, owner=owner[fi], hits=k, c=[round(v, 3) for v in c], n=[round(v, 2) for v in n], front_open=h_front[0] is None, back_open=h_back[0] is None)) summ = collections.Counter((r["owner"], r["front_open"], r["back_open"]) for r in rows) res["faces_seen_from_behind"] = len(rows) res["by_owner_frontopen_backopen"] = [[list(k), v] for k, v in summ.items()] res["faces"] = rows[:200] # regions: drum (y < -2.85, z < -0.25), window reveals, pods def region(c): x, y, z = c if y < -2.85 and z < -0.2: return "drum" if -2.9 < y < -1.4 and -0.45 < z < 0.75 and abs(x) < 1.05: return "windows" if z < -0.95: return "pods/belly" return "other" reg = collections.Counter() regf = collections.Counter() for r in rows: reg[region(r["c"])] += r["hits"]; regf[region(r["c"])] += 1 res["regions_rays"] = dict(reg); res["regions_faces"] = dict(regf) json.dump(res, open(os.path.join(OUT, "holes.json"), "w"), indent=1) print("CR2 FACES", len(rows), dict(summ), dict(reg), dict(regf))