# Stage 18: is the line network SHADING (custom split normals) rather than shape? # # blender --background --python 18_normal_probe.py -- # # THE SUSPICION. Every stage from 11 to 17 moved vertices along the seam network and the lines # did not change; 17's membrane moved 12.6k verts for no visible difference, and 16c proved there # is no crack to weld. What survives vertex movement is SHADING: this mesh carries custom split # normals from the Tripo import (has_custom_normals=True). If those baked normals encode the scan # panel borders as creases, the lines are painted into the normals and geometry work cannot touch # them — which would explain the whole failure streak at once, including why the lines appear in # clay renders (clay swaps the MATERIAL, so no normal map is involved, but custom split normals # belong to the MESH and survive the swap). # # Note every prior stage ended with normals_split_custom_set_from_vertices(...), which SHOULD have # smoothed them. This probe measures whether that actually took effect, then clears the custom # normals outright and re-renders. Measurement first, then the change, so we learn which it was. import bpy, sys, os, math, time import numpy as np from mathutils import Vector argv = sys.argv[sys.argv.index("--") + 1:] BLEND, OUT, REVIEW = argv[0], argv[1], argv[2] os.makedirs(REVIEW, exist_ok=True) t0 = time.time() def log(m): print(f"[nrm {time.time()-t0:6.1f}s] {m}", flush=True) bpy.ops.wm.open_mainfile(filepath=BLEND) ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices)) me = ob.data n_v = len(me.vertices) n_l = len(me.loops) log(f"{ob.name}: {n_v}v {len(me.polygons)}f {n_l} loops " f"has_custom_normals={me.has_custom_normals}") flat = np.empty(len(me.polygons), dtype=bool) me.polygons.foreach_get("use_smooth", flat) log(f"flat-shaded polygons: {int((~flat).sum())} of {len(flat)}") # ---- how far do the corner normals deviate from the smooth vertex normals? ---- vn = np.empty(n_v * 3) me.vertices.foreach_get("normal", vn) vn = vn.reshape(-1, 3) lv = np.empty(n_l, dtype=np.int32) me.loops.foreach_get("vertex_index", lv) cn = np.empty(n_l * 3) try: me.corner_normals.foreach_get("vector", cn) cn = cn.reshape(-1, 3) dot = np.clip((cn * vn[lv]).sum(axis=1), -1, 1) dev = np.degrees(np.arccos(dot)) print(f"CORNER-vs-VERTEX normal deviation: mean {dev.mean():.3f}deg " f"p50 {np.percentile(dev,50):.3f} p95 {np.percentile(dev,95):.3f} " f"p99.9 {np.percentile(dev,99.9):.3f} max {dev.max():.3f}") print(f" loops deviating >5deg: {int((dev>5).sum())} ({100.0*(dev>5).mean():.3f}%)") print(f" loops deviating >15deg: {int((dev>15).sum())} ({100.0*(dev>15).mean():.3f}%)") # per-vertex spread between its own corner normals = a shading crease at that vertex spread = np.zeros(n_v) np.maximum.at(spread, lv, dev) torso = None co = np.empty(n_v * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90) print(f" torso verts whose corner normals deviate >10deg from smooth: " f"{int((torso & (spread > 10)).sum())}") except Exception as ex: log(f"corner_normals read failed: {ex}") # ============================================================================= # clear the custom split normals and force smooth shading # ============================================================================= had = me.has_custom_normals bpy.context.view_layer.objects.active = ob ob.select_set(True) cleared = False try: bpy.ops.mesh.customdata_custom_splitnormals_clear() cleared = True except Exception as ex: log(f"operator clear failed: {ex}") if not cleared: for nm in ("custom_normal",): if nm in me.attributes: me.attributes.remove(me.attributes[nm]) cleared = True log(f"removed attribute '{nm}'") sm = np.ones(len(me.polygons), dtype=bool) me.polygons.foreach_set("use_smooth", sm) me.update() log(f"custom normals: had={had} now has_custom_normals={me.has_custom_normals} cleared={cleared}") # ============================================================================= # clay renders, same framing as 04_review so they compare 1:1 # ============================================================================= scn = bpy.context.scene w = bpy.data.worlds.new("W") w.color = (0.22, 0.22, 0.24) scn.world = w key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN')) key.data.energy = 3.0 key.data.use_shadow = False bpy.context.collection.objects.link(key) fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN')) fill.data.energy = 1.0 fill.data.use_shadow = False bpy.context.collection.objects.link(fill) cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam")) cam.data.lens = 85 bpy.context.collection.objects.link(cam) scn.camera = cam scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT' scn.render.resolution_x = scn.render.resolution_y = 1000 clay = bpy.data.materials.new("Clay") clay.use_nodes = True clay.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (0.62, 0.60, 0.58, 1) clay.node_tree.nodes["Principled BSDF"].inputs["Roughness"].default_value = 0.45 orig = [ms.material for ms in ob.material_slots] def shoot(tag, ctr, span, yaw_deg, use_clay): for i, ms in enumerate(ob.material_slots): ms.material = clay if use_clay else orig[i] yaw = math.radians(yaw_deg) dist = span * 3.0 cam.location = Vector(ctr) + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.02)) cam.rotation_euler = (Vector(ctr) - cam.location).to_track_quat('-Z', 'Y').to_euler() key.rotation_euler = (math.radians(62), 0, math.radians(35 + yaw_deg)) fill.rotation_euler = (math.radians(75), 0, math.radians(yaw_deg - 110)) scn.render.filepath = os.path.abspath(os.path.join(REVIEW, f"{tag}.png")) bpy.ops.render.render(write_still=True) log(f"render {tag}") CHEST = (0.0, 0.0, 0.675) FULL = (0.0, 0.0, 0.50) HIP = (0.0, 0.0, 0.53) shoot("chest_clay_0", CHEST, 0.22, 0, True) shoot("chest_clay_40", CHEST, 0.22, 40, True) shoot("chest_tex_0", CHEST, 0.22, 0, False) shoot("hip_clay_0", HIP, 0.22, 0, True) shoot("full_clay_0", FULL, 0.55, 0, True) bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave bpy.ops.wm.save_as_mainfile(filepath=OUT) log(f"WROTE {OUT}") print("NRM_DONE")