153 lines
6.4 KiB
Python
153 lines
6.4 KiB
Python
|
|
# Stage 18: is the line network SHADING (custom split normals) rather than shape?
|
||
|
|
#
|
||
|
|
# blender --background --python 18_normal_probe.py -- <in.blend> <out.blend> <review_dir>
|
||
|
|
#
|
||
|
|
# 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")
|