""" Is the station closed from outside? A census of what a viewer outside the station sees first. blender -b --factory-startup Anchorage_Station_exterior.blend --python build/check_exterior.py [-- --step 0.25] [--report out.json] The kit's rooms are one-sided: seen from behind, a wall is not drawn and you look into the room. So rays are shot at the station from all round (8 compass directions level, the same 8 from 35 degrees up, and straight down), and the first surface each ray meets is classified: shell a face of the outer shell, seen from its front fine kit, allowed kit geometry that IS the outside: window frames and glass of the galleries, Relay Control's window drum and skylight, the three outer doors fine HOLE a one-sided face seen from behind: the viewer looks into a room EXPOSED kit geometry of an enclosed room seen from its front: it sticks out of the shell, or a wall is missing in front of it SHELL_BACK a shell face seen from behind: it is wound the wrong way, or the viewer is inside the shell Rays stop at the level of the ice (what is below it cannot be seen). The ice, the rocks, the sea and the vessel are left out; a stand-in for the hull closes the end of the boarding bridge. The run ends with the HOLE and EXPOSED spots grouped into places (2 m cells), worst first. Then the other way round: from eye points inside the rooms (1.5 m grid, 150 directions each) nothing of the shell may be seen and no ray may get out. --quick fewer directions (4 level, 4 from above, top), for a fast look while working --no-inside skip the view from inside """ import json import math import sys import bpy from mathutils import Vector ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] STEP = float(ARGS[ARGS.index("--step") + 1]) if "--step" in ARGS else 0.25 REPORT = ARGS[ARGS.index("--report") + 1] if "--report" in ARGS else None GROUND_Z = float(ARGS[ARGS.index("--ground") + 1]) if "--ground" in ARGS else -0.60 STATION = "ANCHORAGE STATION" # kit meshes that are meant to be seen from outside, by module (substring of anchorage_module) and mesh name prefix ALLOWED = ( ("gallery", ("IN_SM_Win_Frame", "IN_SM_Win_Glass", "IN_SM_Window")), ("quay unit", ("IN_SM_Win_Frame", "IN_SM_Win_Glass", "IN_SM_Window", "IN_SM_Doorway_L", "IN_SM_Door_L", "IN_SM_Hall_Doorway_Panel", "IN_SM_Pipes_Doorway")), ("arrivals", ("IN_SM_Win_Frame", "IN_SM_Win_Glass", "IN_SM_Window", "IN_SM_Doorway_L", "IN_SM_Door_L", "IN_SM_Hall_Doorway_Panel", "IN_SM_Pipes_Doorway")), ("relay control", ("CC_SM_Windows", "CC_SM_Wall_Corner_B", "CC_SM_Ceiling_Window", "CC_SM_Ceiling_OuterRing_C")), ("exterior: skylight frame", ("CC_SM_Ceiling_Window_A",)), ("exterior: relay dish", ("LITE_",)), ("outpost:", ("LITE_", "PH_")), # the pack's ducts on the roofs, and anything else the outpost places ("cargo hall", ("HW_SM_Hall_Door", "HW_SM_Hall_Doorway", "HW_SM_Door")), ("boarding bridge", ("HW_SM_Hall_FloorBuffer", "HW_SM_Hall_WallBuffer", "HW_SM_Ceiling_A_Buffer")), ) QUICK = "--quick" in ARGS sc = bpy.context.scene station = bpy.data.collections[STATION] keep = set(station.all_objects) info = {} for ob in sc.objects: hide = (ob not in keep or ob.type != "MESH" or ob.get("ue_decal_material") is not None or bool(ob.get("anchorage_surroundings"))) ob.hide_viewport = hide if not hide: info[ob.name] = (bool(ob.get("anchorage_custom_mesh")), ob.get("anchorage_module", ""), ob.data.name) # the hull stands at the end of the boarding bridge: a plane takes its place, so nobody looks down the main axis mk = bpy.data.objects.get("MARKER | Boarding point (end of the bridge)") if mk is not None: y = mk.matrix_world.translation.y - 0.25 hull = bpy.data.meshes.new("CHECK_hull") hull.from_pydata([(-7.0, y, -2.0), (7.0, y, -2.0), (7.0, y, 7.0), (-7.0, y, 7.0)], [], [(0, 1, 2, 3)]) hob = bpy.data.objects.new("CHECK_hull", hull) sc.collection.objects.link(hob) info[hob.name] = (True, "stand-in for the hull", "CHECK_hull") bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() lo = Vector((1e9, 1e9, 1e9)) hi = Vector((-1e9, -1e9, -1e9)) for ob in sc.objects: if ob.hide_viewport or ob.type != "MESH": continue for c in ob.bound_box: p = ob.matrix_world @ Vector(c) lo = Vector((min(lo.x, p.x), min(lo.y, p.y), min(lo.z, p.z))) hi = Vector((max(hi.x, p.x), max(hi.y, p.y), max(hi.z, p.z))) lo.z = GROUND_Z centre = (lo + hi) / 2 radius = (hi - lo).length / 2 + 5.0 _cull = {} def culled(ob, idx): key = (ob.name, ob.data.polygons[idx].material_index) if key not in _cull: mi = key[1] slot = ob.material_slots[mi] if mi < len(ob.material_slots) else None mat = slot.material if slot else None _cull[key] = True if mat is None else bool(mat.use_backface_culling) return _cull[key] def allowed(module, mesh): for frag, prefixes in ALLOWED: if frag in module and mesh.startswith(prefixes): return True return False dirs = [] for k in range(8): if QUICK and k % 2: continue a = math.radians(45.0 * k) dirs.append(("level %3d" % (45 * k), Vector((-math.cos(a), -math.sin(a), 0.0)))) for k in range(8): if QUICK and k % 2: continue a = math.radians(45.0 * k + 22.5) e = math.radians(35.0) dirs.append(("down35 %5.1f" % (45 * k + 22.5), Vector((-math.cos(a) * math.cos(e), -math.sin(a) * math.cos(e), -math.sin(e))))) dirs.append(("top", Vector((0.0, 0.0, -1.0)))) totals = {"shell": 0, "kit_allowed": 0, "HOLE": 0, "EXPOSED": 0, "SHELL_BACK": 0, "miss": 0} spots = {} tally = {} for label, d in dirs: d = d.normalized() up = Vector((0, 0, 1)) if abs(d.z) < 0.99 else Vector((0, 1, 0)) u = d.cross(up).normalized() v = u.cross(d).normalized() corners = [Vector((x, y, z)) for x in (lo.x, hi.x) for y in (lo.y, hi.y) for z in (lo.z, hi.z)] us = [(c - centre).dot(u) for c in corners] vs = [(c - centre).dot(v) for c in corners] nu = int((max(us) - min(us)) / STEP) + 1 nv = int((max(vs) - min(vs)) / STEP) + 1 part = {"shell": 0, "kit_allowed": 0, "HOLE": 0, "EXPOSED": 0, "SHELL_BACK": 0, "miss": 0} for iu in range(nu): for iv in range(nv): o = centre + u * (min(us) + iu * STEP) + v * (min(vs) + iv * STEP) - d * radius dist = 2.0 * radius if d.z < -1e-6: dist = min(dist, (o.z - GROUND_Z) / -d.z) # stop at the ice elif o.z < GROUND_Z: continue hit, loc, nrm, idx, ob, mtx = sc.ray_cast(dg, o, d, distance=dist) if not hit: part["miss"] += 1 continue eob = ob.original custom, module, mesh = info.get(eob.name, (False, "", eob.data.name)) front = nrm.dot(d) < 0.0 or not culled(eob, idx) if mesh == "CHECK_hull": kind = "shell" elif custom: kind = "shell" if front else "SHELL_BACK" elif not front: kind = "HOLE" elif allowed(module, mesh): kind = "kit_allowed" else: kind = "EXPOSED" part[kind] += 1 if kind in ("HOLE", "EXPOSED", "SHELL_BACK"): cell = (kind, int(math.floor(loc.x / 2.0)), int(math.floor(loc.y / 2.0))) s = spots.setdefault(cell, {"n": 0, "z": [1e9, -1e9], "what": {}, "dirs": {}, "p": [loc.x, loc.y, loc.z]}) s["n"] += 1 s["z"][0] = min(s["z"][0], loc.z) s["z"][1] = max(s["z"][1], loc.z) key = "%s | %s" % (mesh, module) s["what"][key] = s["what"].get(key, 0) + 1 s["dirs"][label] = s["dirs"].get(label, 0) + 1 if kind in ("EXPOSED", "kit_allowed"): key = (kind, mesh, module) tally[key] = tally.get(key, 0) + 1 for k in part: totals[k] += part[k] print("[check] %-14s rays %7d: shell %6d kit ok %6d HOLE %5d EXPOSED %5d SHELL_BACK %5d" % ( label, nu * nv, part["shell"], part["kit_allowed"], part["HOLE"], part["EXPOSED"], part["SHELL_BACK"])) seen = totals["shell"] + totals["kit_allowed"] + totals["HOLE"] + totals["EXPOSED"] + totals["SHELL_BACK"] print("[check] TOTAL hits %d: shell %d (%.1f%%), kit allowed %d, HOLE %d (%.2f%%), EXPOSED %d (%.2f%%), SHELL_BACK %d (%.2f%%)" % ( seen, totals["shell"], 100.0 * totals["shell"] / max(seen, 1), totals["kit_allowed"], totals["HOLE"], 100.0 * totals["HOLE"] / max(seen, 1), totals["EXPOSED"], 100.0 * totals["EXPOSED"] / max(seen, 1), totals["SHELL_BACK"], 100.0 * totals["SHELL_BACK"] / max(seen, 1))) order = sorted(spots.items(), key=lambda kv: -kv[1]["n"]) print("[check] %d places with something wrong (2 m cells); the worst:" % len(order)) for (kind, cx, cy), s in order[:45]: what = sorted(s["what"].items(), key=lambda kv: -kv[1])[:3] dd = sorted(s["dirs"].items(), key=lambda kv: -kv[1])[:2] print(" %-10s x %5.0f..%-5.0f y %5.0f..%-5.0f z %5.2f..%5.2f %5d rays from %s %s" % ( kind, cx * 2.0, cx * 2.0 + 2, cy * 2.0, cy * 2.0 + 2, s["z"][0], s["z"][1], s["n"], ", ".join("%s" % k.strip() for k, n in dd), "; ".join("%s x%d" % (k, n) for k, n in what))) print("[check] exposed kit meshes (front faces seen from outside that are not on the allowed list):") for (kind, mesh, module), n in sorted(tally.items(), key=lambda kv: -kv[1]): if kind == "EXPOSED": print(" %6d %-30s %s" % (n, mesh, module)) print("[check] allowed kit seen from outside:") for (kind, mesh, module), n in sorted(tally.items(), key=lambda kv: -kv[1])[:60]: if kind == "kit_allowed" and n >= 30: print(" %6d %-30s %s" % (n, mesh, module)) # ---- the other way round: from inside the rooms nothing of the shell may show, and no ray may get out ---------- inside = {"eyes": 0, "rays": 0, "shell_seen": 0, "inner_skin_seen": 0, "ice_seen": 0, "escaped": 0} in_spots = {} if "--no-inside" not in ARGS: # the ice and the rocks come back for this part: none of it may show inside a room back = [ob for ob in sc.objects if ob.type == "MESH" and ob.get("anchorage_surroundings")] for ob in back: ob.hide_viewport = False info[ob.name] = (True, "surroundings", ob.data.name) if back: bpy.context.view_layer.update() dg = bpy.context.evaluated_depsgraph_get() N = 150 sphere = [] for k in range(N): zz = 1.0 - 2.0 * (k + 0.5) / N rr = math.sqrt(max(0.0, 1.0 - zz * zz)) ph = k * math.pi * (3.0 - math.sqrt(5.0)) sphere.append(Vector((rr * math.cos(ph), rr * math.sin(ph), zz))) def is_kit(o): return o is not None and not info.get(o.original.name, (False, "", ""))[0] gx = int((hi.x - lo.x) / 1.5) + 1 gy = int((hi.y - lo.y) / 1.5) + 1 for ix in range(gx): for iy in range(gy): for ez in (1.6, -0.2): eye = Vector((lo.x + 0.4 + ix * 1.5, lo.y + 0.4 + iy * 1.5, ez)) h1, l1, n1, i1, o1, m1 = sc.ray_cast(dg, eye, Vector((0, 0, -1)), distance=2.0) h2, l2, n2, i2, o2, m2 = sc.ray_cast(dg, eye, Vector((0, 0, 1)), distance=6.5) if not (h1 and h2 and is_kit(o1) and is_kit(o2)) or n1.z < 0.5 or n2.z > -0.5: continue # not standing on a kit floor under a kit ceiling # and with a kit surface facing the eye on all four sides: that leaves out wall cavities, door # reveals and window recesses, which have a floor and a lintel but are not in a room room = True for hv in (Vector((1, 0, 0)), Vector((-1, 0, 0)), Vector((0, 1, 0)), Vector((0, -1, 0))): hh, hl, hn, hi_, ho, hm = sc.ray_cast(dg, eye, hv, distance=60.0) if not (hh and is_kit(ho) and (hn.dot(hv) < 0.0 or not culled(ho.original, hi_))): room = False break if not room: continue inside["eyes"] += 1 for dv in sphere: o = eye.copy() for step in range(8): hit, loc, nrm, idx, ob, mtx = sc.ray_cast(dg, o, dv, distance=400.0) if not hit: inside["escaped"] += 1 key = ("ESCAPED", int(math.floor(eye.x / 2.0)), int(math.floor(eye.y / 2.0))) s = in_spots.setdefault(key, {"n": 0, "what": {}}) s["n"] += 1 s["what"]["towards (%.1f %.1f %.1f)" % (round(dv.x, 1), round(dv.y, 1), round(dv.z, 1))] = 1 break eob = ob.original custom, module, mesh = info.get(eob.name, (False, "", eob.data.name)) front = nrm.dot(dv) < 0.0 or not culled(eob, idx) or mesh == "CHECK_hull" if not front: o = loc + dv * 0.002 # a one-sided face from behind is not drawn: look on continue if eob.get("anchorage_surroundings"): if loc.z <= l1.z - 0.05: inside["inner_skin_seen"] += 1 # ice under the floor, through a gap in the floor else: inside["ice_seen"] += 1 # ice or rock inside a room: it must never happen key = ("ICE_SEEN", int(math.floor(loc.x / 2.0)), int(math.floor(loc.y / 2.0))) s = in_spots.setdefault(key, {"n": 0, "what": {}}) s["n"] += 1 s["what"]["%s at z %.2f" % (mesh, loc.z)] = 1 elif custom and mesh != "CHECK_hull": first_inner = eob.data.get("anchorage_inner_from") if first_inner is not None and idx >= first_inner: inside["inner_skin_seen"] += 1 # a gap in the kit, closed by the inner skin else: inside["shell_seen"] += 1 key = ("SHELL_SEEN", int(math.floor(loc.x / 2.0)), int(math.floor(loc.y / 2.0))) s = in_spots.setdefault(key, {"n": 0, "what": {}}) s["n"] += 1 s["what"][mesh] = s["what"].get(mesh, 0) + 1 break inside["rays"] += 1 print("[check] FROM INSIDE: %d eye points, %d rays: outer shell seen %d, ice or rock seen %d, rays that got out %d " "(gaps in the kit closed by the inner skin: %d)" % ( inside["eyes"], inside["rays"], inside["shell_seen"], inside["ice_seen"], inside["escaped"], inside["inner_skin_seen"])) for (kind, cx, cy), s in sorted(in_spots.items(), key=lambda kv: -kv[1]["n"])[:25]: print(" %-10s x %5.0f..%-5.0f y %5.0f..%-5.0f %5d rays %s" % ( kind, cx * 2.0, cx * 2.0 + 2, cy * 2.0, cy * 2.0 + 2, s["n"], "; ".join(list(s["what"])[:4]))) if REPORT: with open(REPORT, "w", encoding="utf-8") as f: json.dump({"step": STEP, "totals": totals, "places": [{"kind": k[0], "x": k[1] * 2.0, "y": k[2] * 2.0, "rays": s["n"], "z": s["z"], "what": s["what"]} for k, s in order]}, f, indent=1)