# Stage 48 (v02 candidate): decimate the body, KEEP THE HEAD — Jeremy 2026-08-11. # # blender --background --python 48_decimate_headsafe.py -- [body_target_v] # # The v01 decimation was one global ratio (30_decimate.py, 0.037728): it kept 4% of her head # triangles, and the face is where that shows — Tripo modelled her brows, lash lines and lips as # raised GEOMETRY, so the 197,306-tri head collapsed to 7,836 and the brows went faceted. # # Here the head is excluded outright and the reduction is spent on the body alone: # - vertex group 'decim': weight 1.0 below u=0.80 (shoulders), feathered to 0.0 at u=0.86 # (chin). The feather ramps triangle density through the neck instead of snapping at a line. # - Decimate COLLAPSE with that group; the ratio is BISECTED on the evaluated depsgraph until # the BODY-region vertex count lands on target (the same measure-don't-guess pattern as # 30_decimate — the ratio is faces-global, so the body count is what must converge). # - The head is then ASSERTED untouched: exact vertex count and positional checksum over # u > 0.87, not hoped for. If the modifier nibbled it, this aborts rather than ships. # # Budget consequence, stated up front: head 101,136 v rides along whole, so the result is # ~128k v / ~250k tris — 4x the v01 game budget. That is the point: quality over budget, # Jeremy's call. The head budget can be dialled later; the body work is not redone. import bpy, sys, os, time import numpy as np argv = sys.argv[sys.argv.index("--") + 1:] BLEND, OUT = argv[0], argv[1] BODY_TARGET = int(argv[2]) if len(argv) > 2 else 27500 # v01's body-region density # Feather narrowed 0.80-0.86 -> 0.84-0.87 after the first run: the wide band held 26,405 # partially-protected verts, and because the bisect counted everything below u=0.87 as "body", # the band soaked up nearly the whole 27,500 budget — the true body came out at ~4k verts. # The bisect now counts ONLY u <= FEATHER_LO, so the budget lands where it was meant to. FEATHER_LO, FEATHER_HI = 0.84, 0.87 HEAD_CHECK = 0.875 t0 = time.time() def log(m): print(f"[dec48 {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)) bpy.context.view_layer.objects.active = ob for o in bpy.data.objects: o.select_set(o is ob) me = ob.data n0 = len(me.vertices) co = np.empty(n0 * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3) zlo, zhi = co[:, 2].min(), co[:, 2].max() u = (co[:, 2] - zlo) / (zhi - zlo) head0 = u > HEAD_CHECK head_n0 = int(head0.sum()) head_sum0 = co[head0].sum(axis=0) # positional checksum of the protected region log(f"in: {n0} v / {len(me.polygons)} f head(u>{HEAD_CHECK}): {head_n0} v") # feathered protection weights w = np.clip((FEATHER_HI - u) / (FEATHER_HI - FEATHER_LO), 0.0, 1.0) vg = ob.vertex_groups.get("decim") or ob.vertex_groups.new(name="decim") for band in (0.0, 0.25, 0.5, 0.75, 1.0): idx = np.nonzero(np.isclose(np.round(w * 4) / 4, band))[0] if len(idx): vg.add(idx.tolist(), float(band), 'REPLACE') log(f"vertex group: {int((w >= 0.999).sum())} full-weight, " f"{int(((w > 0) & (w < 1)).sum())} feathered, {int((w <= 0).sum())} protected") # Applying Decimate discards CUSTOM SPLIT NORMALS for the entire mesh — measured consequence: # the head's lash and eye shells (dense sliver geometry that depends on Tripo's authored normals) # rendered as faceted glass. Keep an untouched duplicate as a normal donor; after the decimate is # applied, the head's normals are transferred back. Positions up there are identical by # construction, so POLYINTERP_NEAREST is an exact restore, feathered off through the neck. donor = ob.copy() donor.data = ob.data.copy() donor.name = "nrm_donor" bpy.context.scene.collection.objects.link(donor) mod = ob.modifiers.new("dec", 'DECIMATE') mod.decimate_type = 'COLLAPSE' mod.vertex_group = "decim" mod.vertex_group_factor = 10.0 def body_count(ratio): mod.ratio = ratio dg = bpy.context.evaluated_depsgraph_get() ev = ob.evaluated_get(dg) m = ev.to_mesh() n = len(m.vertices) a = np.empty(n * 3); m.vertices.foreach_get("co", a); a = a.reshape(-1, 3) uu = (a[:, 2] - zlo) / (zhi - zlo) body = int((uu <= FEATHER_LO).sum()) # TRUE body only — the feather band is excluded head = int((uu > HEAD_CHECK).sum()) ev.to_mesh_clear() return body, head, n # analytic first guess: protected faces survive, so the global ratio must budget for them face_head = 0 lv = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lv) ls = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", ls) prot = u[lv[ls]] > FEATHER_HI # cheap: classify face by first corner face_head = int(prot.sum()) guess = (face_head + BODY_TARGET * 2.05) / len(me.polygons) lo_r, hi_r = guess * 0.4, min(1.0, guess * 2.5) log(f"protected faces ~{face_head}; first guess ratio {guess:.5f}") best = None for it in range(9): r = 0.5 * (lo_r + hi_r) b, h, n = body_count(r) log(f" it{it:02d} ratio {r:.5f} -> body {b} v, head {h} v, total {n}") if abs(b - BODY_TARGET) / BODY_TARGET < 0.02: best = r break if b < BODY_TARGET: lo_r = r else: hi_r = r best = r mod.ratio = best bpy.ops.object.modifier_apply(modifier=mod.name) log(f"applied ratio {best:.5f}") # ---- assert the head did not move ---- me = ob.data n1 = len(me.vertices) co1 = np.empty(n1 * 3); me.vertices.foreach_get("co", co1); co1 = co1.reshape(-1, 3) u1 = (co1[:, 2] - zlo) / (zhi - zlo) head1 = u1 > HEAD_CHECK head_n1 = int(head1.sum()) head_sum1 = co1[head1].sum(axis=0) drift = np.abs(head_sum1 - head_sum0).max() print(f"\nHEAD CHECK: {head_n0} -> {head_n1} verts, positional checksum drift {drift:.9f}") assert head_n1 == head_n0 and drift < 1e-4, \ "the decimation touched the protected head — do not ship this" print(f"RESULT: {n0} -> {n1} v ({len(me.polygons)} f); body {n1 - head_n1} v, head {head_n1} v") print(f"height unchanged: {co1[:,2].max()-co1[:,2].min():.5f} vs {zhi-zlo:.5f}") # restore the head's authored normals from the donor vgn = ob.vertex_groups.new(name="nrm_keep") co2 = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co2); co2 = co2.reshape(-1, 3) u2 = (co2[:, 2] - zlo) / (zhi - zlo) wn = np.clip((u2 - 0.82) / (0.86 - 0.82), 0.0, 1.0) for band in (0.25, 0.5, 0.75, 1.0): idx = np.nonzero(np.isclose(np.round(wn * 4) / 4, band))[0] if len(idx): vgn.add(idx.tolist(), float(band), 'REPLACE') dt = ob.modifiers.new("nrm", 'DATA_TRANSFER') dt.object = donor dt.use_loop_data = True dt.data_types_loops = {'CUSTOM_NORMAL'} dt.loop_mapping = 'POLYINTERP_NEAREST' dt.vertex_group = "nrm_keep" bpy.context.view_layer.objects.active = ob bpy.ops.object.modifier_apply(modifier=dt.name) log("head custom normals restored from donor (feathered 0.82-0.86)") bpy.data.objects.remove(donor, do_unlink=True) ob.vertex_groups.remove(ob.vertex_groups["nrm_keep"]) ob.vertex_groups.remove(ob.vertex_groups["decim"]) bpy.ops.wm.save_as_mainfile(filepath=OUT) log(f"WROTE {OUT}") print("DEC48_DONE")