I supposed we can always delete my reply if it's against license...
Below is a script which will export a .json to a filename.oca folder as part of my existing RXLAB open cell animation formation workflow.
Texture paths are stored inside of filename.oca folder.
This is the python script below:
# Blender → Spine JSON 4.3.39-beta exporter (root-free fallback + SAFE edges)
# - Correct weighted encoding for Spine meshes ("vertices" format)
# - Uses a root Empty as frame if present (keeps world tidy)
# - AUTO rig scale from pixels/BU based on source images
# - Bone rotations/offsets in parent-local (XZ plane) with +90° root option
# - Region-space UV vertex build with optional per-mesh rotation
# - Hull-first vertex order as Spine expects
# - **Edges are now SAFE**: malformed/non-even/out-of-range edges are auto-dropped per-attachment
# - Toggle edges with EDGES_MODE = "off" | "safe" | "full" (default "safe")
# - **No-root fallback for unweighted verts**: uses mesh’s dominant deform bone or sensible non-root fallback
import bpy, json, os, shutil, statistics, math
from mathutils import Vector, Matrix
from pathlib import Path
SPINE_VERSION = "4.3.39-beta" # Match your Spine version exactly
# --- Scale controls -----------------------------------------------------------
RIG_SCALE_MODE = "AUTO" # "AUTO" or "CONSTANT"
RIG_SCALE_CONSTANT = 100.0
AUTO_MIN_S = 0.01
AUTO_MAX_S = 10000.0
# ------------------------------------------------------------------------------
ROOT_ROTATION_DEG = 90.0 # set 0.0 if you want the Empty to carry orientation instead
VERTICAL_TOL_DEG = 8.0 # treat |rel angle| within 90±tol as vertical → no flip
WEIGHT_EPS = 1e-4 # drop tiny weights (< WEIGHT_EPS)
MAX_INFLUENCES = 4 # clamp per-vertex influences
# Slot bone selection policy: "NEUTRAL_ROOT"
SLOT_BONE_POLICY = "NEUTRAL_ROOT"
COPY_IF_MISSING = True
PRESERVE_PARENT_DIRS = 2
IMG_EXTS = {".png",".jpg",".jpeg",".tga",".bmp",".psd",".tif",".tiff",".webp"}
# --- Per-object mesh rotation (degrees) ---------------------------------------
MESH_ROTATE_DEG_DEFAULT = -90.0
MESH_ROTATE_DEG_BY_OBJECT = {
# "Torso_Shirt": None,
}
# --- Use a root Empty as the scene frame --------------------------------
USE_EMPTY_AS_ROOT_FRAME = True
ROOT_EMPTY_NAME = None
# Nonessential data (hull/edges) toggle and mode
EMIT_NONESSENTIAL = True
EDGES_MODE = "safe" # "off" = never write, "safe" = sanitize & drop if bad, "full" = write as-is
# ---------------- helpers ----------------
def normalize_deg(a):
a = (a + 180.0) % 360.0 - 180.0
if a == -180.0: return 180.0
return a
def is_vertical_rel_deg(rel_ccw, tol=VERTICAL_TOL_DEG):
rel_n = abs(normalize_deg(rel_ccw))
return abs(rel_n - 90.0) <= tol
def active_armature():
a = bpy.context.view_layer.objects.active
if a and a.type == 'ARMATURE': return a
for o in bpy.context.scene.objects:
if o.type == 'ARMATURE': return o
return None
def armature_for_mesh(obj):
for m in obj.modifiers:
if m.type == 'ARMATURE' and m.object and m.object.type == 'ARMATURE':
return m.object
p = obj.parent
while p:
if p.type == 'ARMATURE': return p
p = p.parent
return None
def first_image_and_uvmap_for_obj(obj):
"""Return (image_stem, source_path, uvmap_name_or_None, (w,h)|None)."""
if not obj.material_slots: return None
mat = obj.material_slots[0].material if obj.material_slots[0].material else None
if not mat or not mat.use_nodes: return None
for n in mat.node_tree.nodes:
if n.type == 'TEX_IMAGE' and getattr(n,'image',None):
img = n.image
uvmap_name = None
if n.inputs.get("Vector") and n.inputs["Vector"].links:
src = n.inputs["Vector"].links[0].from_node
if src.type == "UVMAP":
uvmap_name = getattr(src, "uv_map", None) or src.uv_map
try:
p = Path(bpy.path.abspath(img.filepath_raw or img.filepath))
src_path = str(p.resolve()) if p.suffix.lower() in IMG_EXTS and p.exists() else None
except:
src_path = None
stem = Path(img.name).stem
size = None
try:
w, h = int(img.size[0]), int(img.size[1])
if w > 0 and h > 0:
size = (w, h)
except:
pass
return (stem, src_path, uvmap_name, size)
return None
def rel_path_inside(base: Path, absfile: Path):
try: return absfile.relative_to(base)
except Exception: return None
def copy_into_textures(oca_dir: Path, src_path: str):
if not src_path: return None
src = Path(src_path)
parents=[]; p=src.parent
for _ in range(PRESERVE_PARENT_DIRS):
if p and p.name and p.name not in ("/","\\"):
parents.insert(0,p.name); p=p.parent
else: break
dst = oca_dir / "textures" / Path(*parents) / src.name
dst.parent.mkdir(parents=True, exist_ok=True)
try:
if not dst.exists(): shutil.copy2(src, dst)
return dst.relative_to(oca_dir)
except Exception as e:
print(f"[SpineExport] WARN copy failed: {src} -> {dst}: {e}")
return None
# --------- Root Empty detection / frame matrix ---------
def find_root_empty(arm):
if ROOT_EMPTY_NAME:
obj = bpy.data.objects.get(ROOT_EMPTY_NAME)
if obj and obj.type == 'EMPTY':
return obj
if arm.parent and arm.parent.type == 'EMPTY':
return arm.parent
for name in ("Root","root","ROOT","RigRoot","SceneRoot","ArmatureRoot"):
obj = bpy.data.objects.get(name)
if obj and obj.type == 'EMPTY':
return obj
return None
def frame_matrices(arm):
if USE_EMPTY_AS_ROOT_FRAME:
rempty = find_root_empty(arm)
if rempty:
try:
toFrame = rempty.matrix_world.inverted_safe()
except Exception:
toFrame = rempty.matrix_world.inverted()
return toFrame, rempty.matrix_world
return Matrix.Identity(4), Matrix.Identity(4)
# --------- Rig math evaluated in 'frame' space ----------
def head_tail_in_frame(bone, arm_obj, toFrame: Matrix):
Mw = arm_obj.matrix_world
hW = Mw @ bone.head_local
tW = Mw @ bone.tail_local
hF = toFrame @ hW.to_4d(); tF = toFrame @ tW.to_4d()
return hF.xyz, tF.xyz
def angle_ccw_xz(p_head, p_tail):
dx = (p_tail.x - p_head.x)
dz = (p_tail.z - p_head.z)
if abs(dx) < 1e-12 and abs(dz) < 1e-12:
return 0.0
return math.degrees(math.atan2(dz, dx))
def basis_from_parent(p_head, p_tail):
dx = (p_tail.x - p_head.x)
dz = (p_tail.z - p_head.z)
L = math.hypot(dx, dz)
if L < 1e-12:
ux, uz = 1.0, 0.0
else:
ux, uz = dx/L, dz/L
vx, vz = -uz, ux # CCW +90°
return (ux, uz), (vx, vz)
def project_to_basis_px(p_head, basis_x, basis_y, pointF, scale_px):
vx = (pointF.x - p_head.x)
vz = (pointF.z - p_head.z)
lx_bu = basis_x[0]*vx + basis_x[1]*vz
ly_bu = basis_y[0]*vx + basis_y[1]*vz
return (lx_bu*scale_px, ly_bu*scale_px)
def rotate2d(x, y, deg):
if not deg or abs(deg) < 1e-9:
return x, y
rad = math.radians(deg)
c, s = math.cos(rad), math.sin(rad)
return (c*x - s*y, s*x + c*y)
# ---------------- scaling logic ----------------
def compute_auto_rig_scale(meshes):
samples=[]
for o in meshes:
info = first_image_and_uvmap_for_obj(o)
if not info or not info[3]:
continue
img_w, img_h = info[3]
dim_x = max(o.dimensions.x, 1e-6)
dim_z = max(o.dimensions.z, 1e-6)
if img_w > 0: samples.append(img_w / dim_x)
if img_h > 0: samples.append(img_h / dim_z)
if not samples:
return 1.0
s = statistics.median(samples)
return float(max(AUTO_MIN_S, min(AUTO_MAX_S, s)))
# ---------------- convex hull + tri utils ----------------
def convex_hull_indices_xy(pairs_xy):
n = len(pairs_xy)
if n == 0: return []
if n == 1: return [0]
if n == 2: return [0,1]
seen = {}
uniq = []
for i, (x, y) in enumerate(pairs_xy):
key = (round(float(x), 6), round(float(y), 6))
if key not in seen:
seen[key] = i
uniq.append((key[0], key[1], i))
if len(uniq) <= 2:
return [u[2] for u in uniq]
uniq.sort()
def cross(o, a, b):
return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0])
lower = []
for p in uniq:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper = []
for p in reversed(uniq):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
full = (lower[:-1] + upper[:-1]) if len(lower) + len(upper) > 2 else lower
out = []
seen_idx = set()
for x, y, idx in full:
if idx not in seen_idx:
seen_idx.add(idx)
out.append(idx)
return out if out else [uniq[0][2], uniq[-1][2]]
def tri_area2_xy(pairs_xy, a, b, c):
(x1, y1) = pairs_xy[a]
(x2, y2) = pairs_xy[b]
(x3, y3) = pairs_xy[c]
return (x2-x1)*(y3-y1) - (y2-y1)*(x3-x1)
def unique_pairs_from_triangles(tris):
"""Return sorted unique undirected edge PAIRS [(i,j), ...] from triangle index list."""
if not tris: return []
edges = set()
for i in range(0, len(tris), 3):
a, b, c = int(tris[i]), int(tris[i+1]), int(tris[i+2])
if a != b: edges.add((a, b) if a < b else (b, a))
if b != c: edges.add((b, c) if b < c else (c, b))
if c != a: edges.add((c, a) if c < a else (a, c))
return sorted(edges)
def clamp_even_and_inrange(edges, vcount):
"""Sanity filter for flat [i0,j0,i1,j1,...] within [0,vcount)."""
if not edges: return []
if len(edges) % 2: edges = edges[:-1]
out = []
for k in range(0, len(edges), 2):
a, b = edges[k], edges[k+1]
if (isinstance(a, int) and isinstance(b, int)
and 0 <= a < vcount and 0 <= b < vcount and a != b):
out.extend([a, b])
return out
def build_edges_from_tris_and_manual(triangles, new_to_src, region_xy, me_edges):
"""
Build a robust 'edges' array in the unique-vertex domain that:
- Includes triangle perimeter edges
- Adds Blender manual/internal edges, mapped across UV-splits
- Prefers edges that actually exist along triangle adjacency
- Falls back to nearest UV-instance pairing across seams
Returns a flat even-length list [i0,j0,i1,j1,...] or [] if none valid.
"""
vcount = len(region_xy)
if vcount == 0:
return []
tri_pairs = unique_pairs_from_triangles(triangles)
tri_adj = set(tri_pairs) # (i,j), i<j
# src vertex id -> unique-vertex ids (after UV split + reordering)
src_to_uni = {}
for uni_i, (src_vi, _src_loop) in enumerate(new_to_src):
src_to_uni.setdefault(int(src_vi), []).append(uni_i)
out_pairs = set(tri_pairs) # keep perimeter edges
# Map Blender edges into unique-vertex edges
for e in me_edges:
v0, v1 = int(e.vertices[0]), int(e.vertices[1])
L = src_to_uni.get(v0, [])
R = src_to_uni.get(v1, [])
if not L or not R:
continue
found_any = False
for i in L:
for j in R:
a, b = (i, j) if i < j else (j, i)
if a != b and (a, b) in tri_adj:
out_pairs.add((a, b))
found_any = True
if not found_any:
best = None
for i in L:
xi, yi = region_xy[i]
for j in R:
xj, yj = region_xy[j]
d2 = (xi - xj) * (xi - xj) + (yi - yj) * (yi - yj)
if best is None or d2 < best[0]:
a, b = (i, j) if i < j else (j, i)
if a != b:
best = (d2, a, b)
if best:
out_pairs.add((best[1], best[2]))
flat = []
for a, b in sorted(out_pairs):
flat.extend([int(a), int(b)])
return clamp_even_and_inrange(flat, vcount)
# ---------------- weight fallback helpers (no-root) ----------------
def _dominant_deform_vg_on_mesh(obj, deform_names):
"""Return the deform bone name that appears most across the mesh's vertex groups (by count of assignments > 0)."""
counts = {}
me = obj.data
vg_index_to_name = {i: vg.name for i, vg in enumerate(obj.vertex_groups)}
for v in me.vertices:
for g in v.groups:
vg = vg_index_to_name.get(g.group)
if vg in deform_names and g.weight > 0.0:
counts[vg] = counts.get(vg, 0) + 1
if not counts:
return None
return max(counts.items(), key=lambda kv: kv[1])[0]
def _first_nonroot_deform_child(all_bones, deform_names):
"""Pick a reasonable non-root deform bone as a fallback."""
tops = [b for b in all_bones if b.parent is None]
roots = {b.name for b in tops}
# 1) any deform bone whose parent is a root
for b in all_bones:
if b.name in deform_names and b.parent and b.parent.name in roots:
return b.name
# 2) any deform bone that's not a root
for b in all_bones:
if b.name in deform_names and b.parent is not None:
return b.name
# 3) last resort: any deform bone (even if it's a root)
for b in all_bones:
if b.name in deform_names:
return b.name
return None
# ---------------- edges sanitization ----------------
def sanitize_edges(edges, vcount):
"""
Return a *valid* flat edges list (even length, in-range, deduped) or [] if invalid.
In 'safe' mode, invalid edges are dropped; in 'full' mode, caller may bypass this.
"""
if not isinstance(edges, list) or vcount <= 0:
return []
# ints only
edges = [int(e) for e in edges if isinstance(e, int)]
# in range
edges = [e for e in edges if 0 <= e < vcount]
# even length
if len(edges) % 2:
edges = edges[:-1]
if len(edges) < 2:
return []
# de-duplicate undirected pairs
pairs = set()
out = []
for i in range(0, len(edges), 2):
a, b = edges[i], edges[i+1]
if a == b:
continue
p = (a, b) if a < b else (b, a)
if p not in pairs:
pairs.add(p)
out.extend([p[0], p[1]])
return out
# ---------------- exporter ----------------
def export_now():
blend_path = Path(bpy.data.filepath) if bpy.data.filepath else Path(os.getcwd())/"untitled.blend"
base = blend_path.stem
oca_dir = blend_path.with_name(f"{base}.oca"); oca_dir.mkdir(parents=True, exist_ok=True)
out_json = oca_dir / f"{base}.json"
arm = active_armature()
if not arm: raise RuntimeError("No armature found.")
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH' and armature_for_mesh(o)==arm]
if not meshes: raise RuntimeError("No meshes linked to active armature.")
meshes.sort(key=lambda o:(o.matrix_world.translation.y, o.name), reverse=True)
if RIG_SCALE_MODE.upper() == "CONSTANT":
SCALE = float(RIG_SCALE_CONSTANT)
else:
SCALE = compute_auto_rig_scale(meshes)
print(f"[SpineExport] Rig scale = {SCALE:.4f} px/BU")
toFrame, fromFrame = frame_matrices(arm)
if USE_EMPTY_AS_ROOT_FRAME:
if toFrame.is_identity:
print("[SpineExport] Frame: WORLD (no Empty found).")
else:
rempty = find_root_empty(arm)
nm = rempty.name if rempty else "<unknown>"
print(f"[SpineExport] Frame: EMPTY '{nm}' used as scene root.")
else:
print("[SpineExport] Frame: WORLD (Empty disabled).")
all_bones = list(arm.data.bones)
headF, tailF, angleF = {}, {}, {}
for b in all_bones:
hF, tF = head_tail_in_frame(b, arm, toFrame)
headF[b.name] = hF
tailF[b.name] = tF
angleF[b.name] = angle_ccw_xz(hF, tF)
bones_out=[]
for b in all_bones:
hF_b = headF[b.name]; tF_b = tailF[b.name]
length_px = max(0.01, math.hypot(tF_b.x - hF_b.x, tF_b.z - hF_b.z) * SCALE)
if b.parent is None:
d={"name": b.name, "length": round(length_px,4), "rotation": float(f"{ROOT_ROTATION_DEG:.4f}")}
else:
p = b.parent
bx, by = basis_from_parent(headF[p.name], tailF[p.name])
x_px, y_px = project_to_basis_px(headF[p.name], bx, by, headF[b.name], SCALE)
rel_ccw = angleF[b.name] - angleF[p.name]
rot_spine = normalize_deg( -rel_ccw )
if not is_vertical_rel_deg(rel_ccw, VERTICAL_TOL_DEG):
rot_spine = -rot_spine
d={"name": b.name, "parent": p.name, "length": round(length_px,4)}
if abs(x_px)>1e-6: d["x"]=round(x_px,4)
if abs(y_px)>1e-6: d["y"]=round(y_px,4)
if abs(rot_spine)>1e-6: d["rotation"]=round(rot_spine,4)
bones_out.append(d)
bone_to_skel_idx = {bones_out[i]["name"]: i for i in range(len(bones_out))}
deform_bones = [b for b in all_bones if getattr(b, "use_deform", True)]
deform_names = {b.name for b in deform_bones}
tops = [b for b in all_bones if b.parent is None]
def pick_neutral_root_name():
for tb in tops:
if tb.name.lower() == "root":
return tb.name
return tops[0].name if tops else all_bones[0].name
def pick_slot_bone(_obj):
return pick_neutral_root_name()
slots_out=[]; skin_atts={}
for o in meshes:
slot_bone = pick_slot_bone(o)
slot_name = o.name
slots_out.append({"name": slot_name, "bone": slot_bone, "attachment": slot_name})
me = o.data
me.calc_loop_triangles()
info = first_image_and_uvmap_for_obj(o)
if info:
stem, src_path, uvmap_name, img_size = info
img_w, img_h = img_size if img_size else (100, 100)
rel = (rel_path_inside(oca_dir, Path(src_path)) if src_path else None)
if not rel and src_path and COPY_IF_MISSING:
rel = copy_into_textures(oca_dir, src_path)
if rel:
rp = Path(str(rel)); parts=list(rp.parts)
if parts and parts[0]=="textures": parts=parts[1:]
region_path = Path(*parts).with_suffix("").as_posix()
else:
region_path = stem
else:
img_w, img_h = 100, 100
region_path = slot_name
# --- choose UV layer (safe) ---
if info and uvmap_name and (uvmap_name in me.uv_layers):
uv_layer = me.uv_layers[uvmap_name]
else:
uv_layer = me.uv_layers.active
if uv_layer is None and len(me.uv_layers) == 0:
uv_layer = me.uv_layers.new(name="UVMap")
me.calc_loop_triangles()
# --- Build unique vertex list in region space (after V flip and rotation)
uvs=[]; loopkey_to_new={}; new_to_src=[]; region_xy=[]
cx = img_w * 0.5; cy = img_h * 0.5
uv_data_len = len(uv_layer.data) if uv_layer else 0
for li, loop in enumerate(me.loops):
vi = loop.vertex_index
if uv_layer and li < uv_data_len:
uv = uv_layer.data[li].uv
u = float(uv.x)
v = float(1.0 - uv.y)
else:
u = 0.0; v = 1.0 # fallback
key = (vi, round(u,6), round(v,6))
if key not in loopkey_to_new:
loopkey_to_new[key] = len(loopkey_to_new)
new_to_src.append((vi, li))
uvs.extend([round(u,6), round(v,6)])
vx = u*img_w - cx
vy = cy - v*img_h
region_xy.append((float(vx), float(vy)))
if not region_xy:
print(f"[SpineExport] WARN: '{slot_name}' has no UV vertices; skipping attachment.")
continue
# Rotation in region space
rot_deg = MESH_ROTATE_DEG_BY_OBJECT.get(o.name, MESH_ROTATE_DEG_DEFAULT)
if rot_deg is not None and abs(rot_deg) > 1e-9:
region_xy = [rotate2d(x, y, rot_deg) for (x, y) in region_xy]
# Triangles remapped into the unique-vertex index space (safe UV access)
triangles=[]
for lt in me.loop_triangles:
l0, l1, l2 = lt.loops[0], lt.loops[1], lt.loops[2]
def uv_key(v_index, l_index):
if uv_layer and l_index < uv_data_len:
uv = uv_layer.data[l_index].uv
return (v_index, round(uv.x,6), round(1.0 - uv.y,6))
else:
return (v_index, 0.0, 1.0)
k0 = uv_key(lt.vertices[0], l0)
k1 = uv_key(lt.vertices[1], l1)
k2 = uv_key(lt.vertices[2], l2)
if k0 in loopkey_to_new and k1 in loopkey_to_new and k2 in loopkey_to_new:
a = loopkey_to_new[k0]; b = loopkey_to_new[k1]; c = loopkey_to_new[k2]
triangles.extend([a,b,c])
# --- Center verts in region space (median)
mesh_cx = sum(x for x,_ in region_xy) / len(region_xy)
mesh_cy = sum(y for _,y in region_xy) / len(region_xy)
region_xy_centered = [(x - mesh_cx, y - mesh_cy) for (x, y) in region_xy]
# --- Attachment offset in frame space (centroid of world verts)
vsum = Vector((0.0, 0.0, 0.0)); cnt = 0
for v in me.vertices:
vsum += o.matrix_world @ v.co
cnt += 1
center_world = (vsum / cnt) if cnt > 0 else (o.matrix_world @ Vector((0.0,0.0,0.0)))
center_frame = (toFrame @ center_world.to_4d()).xyz
bx_slot, by_slot = basis_from_parent(headF[slot_bone], tailF[slot_bone])
att_lx_slot, att_ly_slot = project_to_basis_px(headF[slot_bone], bx_slot, by_slot, center_frame, SCALE)
# ---------- Vertex weights (from original VGs) ----------
vg_index_to_name = {i: vg.name for i, vg in enumerate(o.vertex_groups)}
# Choose a **root-free fallback** bone for this mesh:
fallback_bone = _dominant_deform_vg_on_mesh(o, deform_names)
if (not fallback_bone) or (fallback_bone not in deform_names):
fallback_bone = _first_nonroot_deform_child(all_bones, deform_names)
if not fallback_bone:
fallback_bone = slot_bone
vgroups_for_vert = {}
used_any = False
for v in me.vertices:
infl_raw = []
for g in v.groups:
vg_name = vg_index_to_name.get(g.group)
if vg_name and vg_name in deform_names and g.weight > 0.0:
infl_raw.append((vg_name, float(g.weight)))
infl_raw = [(bn, w) for (bn, w) in infl_raw if w > WEIGHT_EPS]
infl_raw.sort(key=lambda t: t[1], reverse=True)
infl_raw = infl_raw[:MAX_INFLUENCES]
if infl_raw:
s = sum(w for _, w in infl_raw)
if s > 0:
infl = [(bn, w/s) for (bn, w) in infl_raw]
used_any = True
else:
infl = []
else:
infl = [(fallback_bone, 1.0)]
vgroups_for_vert[v.index] = infl
# ============================================================
# Hull-first reorder and triangle cleanup
# ============================================================
hull_idx = convex_hull_indices_xy(region_xy_centered) if len(region_xy_centered) >= 3 else list(range(len(region_xy_centered)))
hull_count = max(3, len(hull_idx)) if len(region_xy_centered) >= 3 else len(region_xy_centered)
hull_set = set(hull_idx)
tail_idx = [i for i in range(len(region_xy_centered)) if i not in hull_set]
new_order = list(hull_idx) + tail_idx
old_to_new = {old:i for i, old in enumerate(new_order)}
region_xy_centered = [region_xy_centered[old] for old in new_order]
uvs_pairs = [(uvs[i*2], uvs[i*2+1]) for i in range(len(uvs)//2)]
uvs_pairs = [uvs_pairs[old] for old in new_order]
uvs = [x for pair in uvs_pairs for x in pair]
new_to_src = [new_to_src[old] for old in new_order]
# Remap + enforce CCW + drop degenerates
remapped_tris = []
for i in range(0, len(triangles), 3):
a, b, c = triangles[i], triangles[i+1], triangles[i+2]
a2, b2, c2 = old_to_new[a], old_to_new[b], old_to_new[c]
area2 = tri_area2_xy(region_xy_centered, a2, b2, c2)
if abs(area2) < 1e-9:
continue
if area2 < 0:
b2, c2 = c2, b2
remapped_tris.extend([a2, b2, c2])
triangles = remapped_tris
# Trivial fan if empty
if not triangles and len(region_xy_centered) >= 3:
for i in range(1, len(region_xy_centered)-1):
triangles.extend([0, i, i+1])
# --- Robust edges preserving quads and manual cuts --------------------
edges = []
if EMIT_NONESSENTIAL and EDGES_MODE != "off":
raw_edges = build_edges_from_tris_and_manual(
triangles=triangles,
new_to_src=new_to_src,
region_xy=region_xy_centered,
me_edges=me.edges
)
if EDGES_MODE == "full":
edges = raw_edges
else:
# "safe": sanitize and drop if anything smells off
edges = sanitize_edges(raw_edges, len(region_xy_centered))
# ---------- Encode per Spine JSON spec ----------
vertices_seq = []
for new_i, (src_vi, _src_loop) in enumerate(new_to_src):
vx_reg, vy_reg = region_xy_centered[new_i]
lx_slot = vx_reg + att_lx_slot
ly_slot = vy_reg + att_ly_slot
dx_bu = (bx_slot[0] * (lx_slot / SCALE) + by_slot[0] * (ly_slot / SCALE))
dz_bu = (bx_slot[1] * (lx_slot / SCALE) + by_slot[1] * (ly_slot / SCALE))
pF = Vector((headF[slot_bone].x + dx_bu, 0.0, headF[slot_bone].z + dz_bu))
infl = vgroups_for_vert.get(src_vi)
if not infl: # ultra-safe
infl = [(fallback_bone, 1.0)]
vertices_seq.append(len(infl))
for bn, w in infl:
if bn not in bone_to_skel_idx:
bn = fallback_bone
bx_b, by_b = basis_from_parent(headF[bn], tailF[bn])
lx_px, ly_px = project_to_basis_px(headF[bn], bx_b, by_b, pF, SCALE)
vertices_seq.extend([int(bone_to_skel_idx[bn]),
round(lx_px,4), round(ly_px,4),
round(float(w),6)])
att = {
"type": "mesh",
"name": slot_name,
"path": region_path,
"x": 0, "y": 0,
"uvs": uvs,
"triangles": [int(t) for t in triangles],
"vertices": vertices_seq
}
if EMIT_NONESSENTIAL:
att["hull"] = int(hull_count)
# only write edges if we have a valid even-length list
if EDGES_MODE != "off" and edges and len(edges) % 2 == 0:
att["edges"] = edges
skin_atts.setdefault(slot_name, {})[slot_name] = att
spine_doc = {
"skeleton": {
"hash":"", "spine": SPINE_VERSION,
"x": 0, "y": 0, "width":0, "height":0,
"images":"./textures/"
},
"bones": bones_out,
"slots": slots_out,
"skins": [{"name":"default","attachments": skin_atts}],
"animations": {}
}
out_json.write_text(json.dumps(spine_doc, ensure_ascii=False, indent=2), encoding="utf-8")
frame_info = "empty-frame" if USE_EMPTY_AS_ROOT_FRAME else "world-frame"
rot_info = ("none" if MESH_ROTATE_DEG_DEFAULT is None else f"{MESH_ROTATE_DEG_DEFAULT}°")
print(f"[SpineExport] Wrote: {out_json} (rootRot={ROOT_ROTATION_DEG}°; {frame_info}; geom-rot={rot_info}; scale={SCALE:.4f}; nonessential={'on' if EMIT_NONESSENTIAL else 'off'}; edges_mode={EDGES_MODE})")
# ---------------- run ----------------
export_now()