ship(mako): mako_base_v01 — fullhead body (24k body / full-res 158k head) as male Ariki default
Decimate body-only (01), AccuRig manual rig, index-exact graft (02, mesh moved 0mm), QuatSkin via ariki-game converter (HEAD_SAFE=1, body-only normals fix). Ships as Ariki_Male_Mako.glb, lineage MALE-MAKO-FULLHEAD-V1. First mako ship. Height 1.818m provisional. Ceremony per REGISTRY.md; irreplaceable AccuRig FBX preserved in rig/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Masters for the mako fullhead lane (ship: male/mako_base_v01)
|
||||
mako_fullhead_rigged.blend # 02_graft output — the rigged master the ship GLB came from
|
||||
@@ -0,0 +1,114 @@
|
||||
# Mako work lane, stage 01: decimate the Tripo original with the HEAD FULLY PROTECTED.
|
||||
# Body -> 24k tris, hands -> 40k tris, head above the neck line -> UNTOUCHED (full
|
||||
# Tripo resolution). Jeremy's ask 2026-08-11: a decimated mako that cuts at the neck
|
||||
# like the Lena head graft did, so the face keeps every tri for later head work.
|
||||
#
|
||||
# Differences from tools/rigbait_decimate.py (same region geometry, different intent):
|
||||
# * NO P_HEAD pass — head verts sit in every protect group instead.
|
||||
# * Output is a GLB master (packed textures carried), not an FBX rig bait.
|
||||
# Same as the bait: NO scale / recenter (native ~0.98 m, feet at Z=0); region cuts are
|
||||
# bbox-relative — neck at 86.5% of height, hands beyond 75.6% of half-span.
|
||||
#
|
||||
# blender --background --python 01_decimate_fullhead.py -- <src.glb> <out.glb> <review_dir> [body hand]
|
||||
import bpy, os, sys, math
|
||||
from mathutils import Vector
|
||||
|
||||
args = sys.argv[sys.argv.index("--") + 1:]
|
||||
SRC, OUT_GLB, REVIEW = args[0], args[1], args[2]
|
||||
T_BODY, T_HAND = (int(a) for a in args[3:5]) if len(args) >= 5 else (24000, 40000)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUT_GLB)), exist_ok=True)
|
||||
os.makedirs(REVIEW, exist_ok=True)
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=SRC)
|
||||
body = max((o for o in bpy.data.objects if o.type == "MESH"), key=lambda o: len(o.data.vertices))
|
||||
me = body.data
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
body.select_set(True)
|
||||
bpy.context.view_layer.objects.active = body
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
bb = [body.matrix_world @ Vector(c) for c in body.bound_box]
|
||||
mnz, mxz = min(v.z for v in bb), max(v.z for v in bb)
|
||||
half_span = max(abs(v.x) for v in bb)
|
||||
NECK_Z = mnz + 0.865 * (mxz - mnz)
|
||||
HAND_X = 0.756 * half_span
|
||||
print(f"[fullhead] height={mxz-mnz:.3f} half_span={half_span:.3f} neck_z={NECK_Z:.3f} hand_x={HAND_X:.3f}")
|
||||
|
||||
# anti-facet shading fixes (shading only, geometry untouched)
|
||||
try: bpy.ops.mesh.customdata_custom_splitnormals_clear()
|
||||
except Exception: pass
|
||||
if "sharp_edge" in me.attributes:
|
||||
me.attributes.remove(me.attributes["sharp_edge"])
|
||||
|
||||
def is_head(v): return v.co.z > NECK_Z
|
||||
def is_hand(v): return abs(v.co.x) > HAND_X
|
||||
|
||||
def group(name, pred):
|
||||
g = body.vertex_groups.new(name=name)
|
||||
g.add([v.index for v in me.vertices if pred(v)], 1.0, "REPLACE")
|
||||
return g
|
||||
|
||||
head_tris0 = sum(1 for p in me.polygons if all(is_head(me.vertices[vi]) for vi in p.vertices))
|
||||
hand_tris0 = sum(1 for p in me.polygons if all(is_hand(me.vertices[vi]) for vi in p.vertices))
|
||||
print(f"[fullhead] regions: head_tris={head_tris0} hand_tris={hand_tris0} total={len(me.polygons)}")
|
||||
if head_tris0 < 20000 or hand_tris0 < 20000:
|
||||
raise SystemExit(f"[fullhead] region cut looks wrong (head={head_tris0}, hands={hand_tris0})")
|
||||
|
||||
# Two passes only. The head is inside every protect group, so no pass ever touches it.
|
||||
group("P_BODY", lambda v: is_head(v) or is_hand(v)) # pass decimates the body
|
||||
group("P_HAND", lambda v: not is_hand(v)) # pass decimates the hands (head is not a hand)
|
||||
|
||||
targets = {"P_BODY": T_BODY, "P_HAND": T_HAND}
|
||||
region_now = {"P_BODY": len(me.polygons) - head_tris0 - hand_tris0, "P_HAND": hand_tris0}
|
||||
for pg in ("P_BODY", "P_HAND"):
|
||||
other_now = len(me.polygons) - region_now[pg]
|
||||
dec = body.modifiers.new("dec", "DECIMATE")
|
||||
dec.ratio = min(1.0, (targets[pg] + other_now) / max(1, len(me.polygons)))
|
||||
dec.vertex_group = pg
|
||||
dec.invert_vertex_group = True
|
||||
dec.delimit = {"UV"}
|
||||
bpy.ops.object.modifier_apply(modifier="dec")
|
||||
me = body.data
|
||||
region_now[pg] = targets[pg]
|
||||
print(f"[fullhead] after {pg} pass: tris={len(me.polygons)}")
|
||||
|
||||
# gate: the head must have survived bit-for-tri
|
||||
head_tris1 = sum(1 for p in me.polygons if all(is_head(me.vertices[vi]) for vi in p.vertices))
|
||||
print(f"[fullhead] head tris {head_tris0} -> {head_tris1}")
|
||||
if head_tris1 < head_tris0 * 0.999:
|
||||
raise SystemExit(f"[fullhead] HEAD WAS DECIMATED ({head_tris0} -> {head_tris1}) — abort, do not use output")
|
||||
|
||||
bpy.ops.object.shade_smooth()
|
||||
|
||||
def render(name, loc, look_z):
|
||||
cam = bpy.data.objects.get("cam")
|
||||
if cam is None:
|
||||
camd = bpy.data.cameras.new("cam"); cam = bpy.data.objects.new("cam", camd)
|
||||
bpy.context.scene.collection.objects.link(cam)
|
||||
bpy.context.scene.camera = cam
|
||||
cam.location = loc
|
||||
cam.rotation_euler = (math.radians(90), 0, 0)
|
||||
cam.location.z = look_z
|
||||
sc = bpy.context.scene
|
||||
sc.render.engine = "BLENDER_WORKBENCH"
|
||||
sc.display.shading.light = "STUDIO"
|
||||
sc.display.shading.color_type = "TEXTURE"
|
||||
sc.render.resolution_x = 800; sc.render.resolution_y = 800
|
||||
sc.render.filepath = os.path.join(REVIEW, name)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
h = mxz - mnz
|
||||
render("qa_front.png", Vector((0, -2.0 * h, 0)), mnz + 0.5 * h)
|
||||
render("qa_head.png", Vector((0, -0.45 * h, 0)), mnz + 0.93 * h)
|
||||
render("qa_hand.png", Vector((HAND_X + 0.08 * h, -0.35 * h, 0)), mnz + 0.72 * h)
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT"); body.select_set(True)
|
||||
for g in list(body.vertex_groups): body.vertex_groups.remove(g)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUT_GLB, export_format="GLB", export_yup=True, export_apply=False,
|
||||
export_animations=False, export_skins=False, export_morph=False)
|
||||
print(f"[fullhead] wrote {OUT_GLB} ({os.path.getsize(OUT_GLB)/1e6:.1f} MB), "
|
||||
f"final tris={len(me.polygons)} (head {head_tris1} + budgets {T_BODY}+{T_HAND})")
|
||||
print("[fullhead] DONE")
|
||||
@@ -0,0 +1,194 @@
|
||||
# Mako work lane, stage 02: graft AccuRig skeleton + weights onto the fullhead GLB.
|
||||
# Direct adaptation of work/lena/46_graft.py — sole difference: the target is the GLB itself
|
||||
# (the mako lane has no .blend master; the GLB is the source of truth).
|
||||
#
|
||||
# blender --background --python 02_graft.py -- <target.glb> <rigged.fbx> <out.blend> <out.glb>
|
||||
#
|
||||
# blender --background --python 46_graft.py -- <target.glb> <rigged.fbx> <out.blend> <out.glb>
|
||||
#
|
||||
# The lane's rule (`.agents/plans/rig-graft-lane-2026-08-04.md`): FBX is a disposable rig carrier,
|
||||
# the GLB is the sole source of truth for mesh and materials, and it only ever GAINS bones and
|
||||
# weights. So nothing here touches a vertex position, a UV, or a texture — the mesh that comes out
|
||||
# is byte-for-byte v10's, and that is asserted at the end, not hoped for.
|
||||
#
|
||||
# INDEX-EXACT, not nearest-surface. The plan assumed a decimated bait would have to hand weights to
|
||||
# a full-res mesh by proximity, which is where the July lane mangled the fingers (close-packed
|
||||
# digits confuse a proximity search). AccuRig rigged the shipping mesh whole and returned the same
|
||||
# 31,111 vertices in the same order, so weights copy index-to-index and the failure mode is gone.
|
||||
#
|
||||
# TWO CORRECTIONS, both SOLVED here rather than hardcoded. The carrier was exported at 1.700 m and
|
||||
# AccuRig also shifted the body ~1.2 cm (the same family as the July lane's 4 cm offset trap). Both
|
||||
# are recovered by least-squares fitting a uniform scale + translation between the two vertex sets,
|
||||
# so this stays correct if either changes on a future re-rig.
|
||||
import bpy, sys, os, time
|
||||
import numpy as np
|
||||
from mathutils import Vector, Matrix
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
GLB, FBX, OUTB, OUTG = argv[0], argv[1], os.path.abspath(argv[2]), os.path.abspath(argv[3])
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[graft {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=GLB)
|
||||
for _o in bpy.data.objects:
|
||||
if _o.type == "MESH":
|
||||
bpy.context.view_layer.objects.active = _o; _o.select_set(True)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
me = body.data
|
||||
n = len(me.vertices)
|
||||
V = np.empty(n * 3); me.vertices.foreach_get("co", V); V = V.reshape(-1, 3)
|
||||
V_ref = V.copy()
|
||||
log(f"target: '{body.name}' {n}v {len(me.polygons)}f uv={[l.name for l in me.uv_layers]}")
|
||||
|
||||
before = {o.name for o in bpy.data.objects}
|
||||
bpy.ops.import_scene.fbx(filepath=FBX)
|
||||
new = [o for o in bpy.data.objects if o.name not in before]
|
||||
arm = next(o for o in new if o.type == 'ARMATURE')
|
||||
src = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||||
log(f"carrier: armature '{arm.name}' {len(arm.data.bones)} bones, mesh {len(src.data.vertices)}v")
|
||||
|
||||
# AccuRig ships a "T-Pose" take, and Blender's importer binds it as an ACTION. An action re-applies
|
||||
# its pose on every evaluation and on every file load, so clearing pose values while it exists is
|
||||
# futile — the in-session assert passes, the saved file comes back posed, and the body deforms
|
||||
# 1.25 units into the air with every edge length unchanged. Drop the animation data first.
|
||||
dropped = []
|
||||
for o in (arm, src):
|
||||
if o.animation_data:
|
||||
dropped.append(o.name)
|
||||
o.animation_data_clear()
|
||||
dat = o.data
|
||||
if dat and getattr(dat, "animation_data", None):
|
||||
dat.animation_data_clear()
|
||||
sk = getattr(dat, "shape_keys", None)
|
||||
if sk and sk.animation_data:
|
||||
sk.animation_data_clear()
|
||||
for act in list(bpy.data.actions):
|
||||
if act.users == 0:
|
||||
bpy.data.actions.remove(act)
|
||||
log(f"animation data cleared on {dropped}; actions left: {[a.name for a in bpy.data.actions]}")
|
||||
|
||||
assert len(src.data.vertices) == n, \
|
||||
f"vertex count differs ({len(src.data.vertices)} vs {n}) — index-exact graft is off the table"
|
||||
|
||||
# ---- recover scale + translation between carrier and target (order is preserved) ----
|
||||
S = np.empty(n * 3); src.data.vertices.foreach_get("co", S); S = S.reshape(-1, 3)
|
||||
M = np.array(src.matrix_world)
|
||||
Sw = S @ M[:3, :3].T + M[:3, 3]
|
||||
ca, cb = V.mean(axis=0), Sw.mean(axis=0)
|
||||
A, B = V - ca, Sw - cb
|
||||
s = float((A * B).sum() / (B * B).sum())
|
||||
t = ca - s * cb
|
||||
res = np.linalg.norm(V - (s * Sw + t), axis=1)
|
||||
UNITM = 1.750 / (V[:, 2].max() - V[:, 2].min())
|
||||
log(f"fit: scale {s:.6f} (1/{1/s:.6f}) translation {np.round(t, 6)} in target units")
|
||||
log(f" carrier offset removed: {np.round(-t / s * 1000, 3)} mm in carrier space")
|
||||
log(f" index-exact residual: mean {res.mean()*UNITM*1000:.4f} mm p99 "
|
||||
f"{np.percentile(res,99)*UNITM*1000:.4f} mm max {res.max()*UNITM*1000:.4f} mm")
|
||||
|
||||
# ---- move the skeleton into the target's space; the mesh never moves ----
|
||||
arm.matrix_world = Matrix.Translation(Vector(t)) @ Matrix.Diagonal(
|
||||
Vector((s, s, s))).to_4x4() @ arm.matrix_world
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
log(f"armature placed; scale now {tuple(round(v,5) for v in arm.scale)}")
|
||||
|
||||
# Clear the carrier's POSE, and do it AFTER transform_apply. The mesh binds to the REST skeleton,
|
||||
# so any pose riding along is pure displacement — and this FBX arrives with one. Clearing it by
|
||||
# setting location/rotation/scale properties BEFORE the apply did not survive: transform_apply
|
||||
# reintroduced non-identity rotations on the thigh and foot, the saved file deformed the body
|
||||
# 1.25 units into the air, and every edge length stayed put — rigid motion, which reads as
|
||||
# "weights are fine, model is gone". Use the pose operator, last, and assert it took.
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
bpy.ops.pose.select_all(action='SELECT')
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.context.view_layer.update()
|
||||
worst_pb = max(np.abs(np.array(pb.matrix_basis) - np.eye(4)).max() for pb in arm.pose.bones)
|
||||
log(f"pose cleared; largest deviation from identity across {len(arm.pose.bones)} bones: "
|
||||
f"{worst_pb:.2e}")
|
||||
assert worst_pb < 1e-6, "pose did not clear — the bind would be displaced"
|
||||
|
||||
# ---- copy weights index-to-index ----
|
||||
for vg in list(body.vertex_groups):
|
||||
body.vertex_groups.remove(vg)
|
||||
name_of = {}
|
||||
for g in src.vertex_groups:
|
||||
name_of[g.index] = g.name
|
||||
body.vertex_groups.new(name=g.name)
|
||||
tgt = {g.name: g for g in body.vertex_groups}
|
||||
copied = 0
|
||||
for v in src.data.vertices:
|
||||
for g in v.groups:
|
||||
w = g.weight
|
||||
if w > 0.0:
|
||||
tgt[name_of[g.group]].add([v.index], w, 'REPLACE')
|
||||
copied += 1
|
||||
log(f"copied {copied} weights into {len(body.vertex_groups)} groups")
|
||||
|
||||
# ---- bind ----
|
||||
for m_ in list(body.modifiers):
|
||||
if m_.type == 'ARMATURE':
|
||||
body.modifiers.remove(m_)
|
||||
mod = body.modifiers.new("Armature", 'ARMATURE')
|
||||
mod.object = arm
|
||||
body.parent = arm
|
||||
body.matrix_parent_inverse = arm.matrix_world.inverted()
|
||||
|
||||
# ---- the carrier is disposable: nothing visual survives from the FBX ----
|
||||
carrier_mats = [m_ for m_ in src.data.materials if m_]
|
||||
bpy.data.objects.remove(src, do_unlink=True)
|
||||
for m_ in carrier_mats:
|
||||
if m_.users == 0:
|
||||
bpy.data.materials.remove(m_)
|
||||
for im in list(bpy.data.images):
|
||||
if im.users == 0 and im.name not in {"Render Result", "Viewer Node"}:
|
||||
bpy.data.images.remove(im)
|
||||
|
||||
# ---- verify ----
|
||||
V2 = np.empty(n * 3); me.vertices.foreach_get("co", V2); V2 = V2.reshape(-1, 3)
|
||||
moved = np.linalg.norm(V2 - V_ref, axis=1).max()
|
||||
tot = np.zeros(n)
|
||||
for v in me.vertices:
|
||||
tot[v.index] = sum(g.weight for g in v.groups)
|
||||
print("\n=== VERIFY ===")
|
||||
print(f" mesh vertices moved: {moved*UNITM*1000:.6f} mm (must be 0)")
|
||||
print(f" height {V2[:,2].max()-V2[:,2].min():.5f} units, feet min-Z {V2[:,2].min():+.6f}")
|
||||
print(f" weight sums: min {tot.min():.4f} mean {tot.mean():.4f} max {tot.max():.4f}")
|
||||
print(f" unweighted vertices: {int((tot < 1e-6).sum())}")
|
||||
print(f" bones {len(arm.data.bones)} vertex groups {len(body.vertex_groups)}")
|
||||
print(f" uv layers {[l.name for l in me.uv_layers]} materials "
|
||||
f"{[m_.name for m_ in me.materials if m_]}")
|
||||
print(f" images {[i.name for i in bpy.data.images if i.size[0]]}")
|
||||
assert moved < 1e-9, "the mesh moved — the graft must be additive only"
|
||||
|
||||
# The rest mesh being right proves nothing: the armature modifier is what the engine will run.
|
||||
# Evaluate it and require the deformed body to sit exactly where the undeformed one does.
|
||||
dg = bpy.context.evaluated_depsgraph_get()
|
||||
evo = body.evaluated_get(dg)
|
||||
tmp = evo.to_mesh()
|
||||
Dv = np.empty(len(tmp.vertices) * 3); tmp.vertices.foreach_get("co", Dv)
|
||||
Dv = Dv.reshape(-1, 3)
|
||||
evo.to_mesh_clear()
|
||||
drift = np.linalg.norm(Dv - V_ref, axis=1).max() * UNITM * 1000
|
||||
print(f" DEFORMED at rest pose: z {Dv[:,2].min():.5f}..{Dv[:,2].max():.5f} "
|
||||
f"max drift vs undeformed {drift:.6f} mm")
|
||||
assert drift < 0.01, (f"the armature displaces the body by {drift:.3f} mm at rest — a leftover "
|
||||
f"pose, not a weighting problem")
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUTB)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
arm.select_set(True); body.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.export_scene.gltf(filepath=OUTG, export_format='GLB', use_selection=True,
|
||||
export_image_format='AUTO', export_jpeg_quality=95,
|
||||
export_yup=True, export_apply=False, export_skins=True)
|
||||
log(f"EXPORTED {OUTG} ({os.path.getsize(OUTG)/1e6:.2f} MB)")
|
||||
print("GRAFT_DONE")
|
||||
Reference in New Issue
Block a user