extends RefCounted class_name SkinSurfaces ## What every surface of a character IS — body, cloth, hair or accessory. ## ## Written at build time by tools/surface_map.py into `.rig.json`, read ## here. Nothing at runtime re-derives it, which is the point: the question has ## one right answer per model and it is knowable in Blender, where the mesh, the ## weights and the skeleton are all in hand. Asking it again from a material at ## load time is how the mannequin's flat yellow body came to be rendered as a ## black silhouette — "untextured" is not the same question as "is ink". ## ## The heuristic that mistake came from is still here, as `guess()`, and still ## earns its place: it is the fallback for a model imported before the surface ## table existed, or one whose material genuinely says nothing. But it is now ## the last resort rather than the only source. const BODY := "body" const CLOTH := "cloth" const HAIR := "hair" const ACCESSORY := "accessory" const LINEWORK := "linework" ## Same threshold as tools/surface_map.INK_LEVEL. The two must agree, or a model ## with a surface table would render differently from one without. const INK_LEVEL := 0.18 var _by_slot: Dictionary = {} # "mesh|index" -> record var _by_material: Dictionary = {} # material name -> record ## Materials used by two surfaces the classifier disagreed about. Taila reuses ## `ClothA` on three meshes and `FullBlack` on three more; those agree, so they ## stay usable. One that did not would silently give whichever surface was read ## first, so it is dropped from the material index instead and falls back to the ## slot key. var _material_conflict: Dictionary = {} static func from_rig_info(rig_info: Dictionary) -> SkinSurfaces: var out := SkinSurfaces.new() for record in rig_info.get("surfaces", []): if not record is Dictionary: continue out._by_slot["%s|%d" % [record.get("mesh", ""), int(record.get("surface", 0))]] = record var mat: String = record.get("material", "") if mat == "": continue var seen = out._by_material.get(mat) if seen != null and seen.get("class", "") != record.get("class", ""): out._material_conflict[mat] = true else: out._by_material[mat] = record return out func is_empty() -> bool: return _by_slot.is_empty() ## The build-time record for one surface, or an empty Dictionary. ## ## The mesh node name and surface index are tried first because they name ## exactly one surface. The material name is the fallback because it is what ## survives best — every character in this game arrives with its meshes called ## `Object_7` through `Object_32`, and a renamed node would take the slot key ## with it while `ClothB` stays `ClothB`. func lookup(mesh_name: String, surface_index: int, material_name: String) -> Dictionary: var by_slot = _by_slot.get("%s|%d" % [mesh_name, surface_index]) if by_slot != null: return by_slot if material_name != "" and not _material_conflict.has(material_name): var by_mat = _by_material.get(material_name) if by_mat != null: return by_mat return {} ## (class, detail) for a surface with no build-time record. ## ## This is the pre-surface-table heuristic, kept verbatim for the one job it is ## still right for. It answers a narrow question — is this untextured surface ## part of the model's own DRAWING? — and it answers it from the three things ## that actually distinguish line-work: being black, being drawn inside-out, or ## saying outright that it is an eye card. Anything else is treated as body, ## which is the safe answer because body is ordinary character shading. static func guess(mat: BaseMaterial3D) -> Array: if mat == null: return [BODY, "skin"] var name := mat.resource_name.to_lower() if mat.albedo_texture == null: if name.begins_with("eyes"): return [BODY, "eyes_highlight" if name.contains("hl") else "eyes_ink"] if mat.cull_mode == BaseMaterial3D.CULL_FRONT: return [LINEWORK, "outline_hull"] var c: Color = mat.albedo_color if maxf(maxf(c.r, c.g), c.b) < INK_LEVEL: return [LINEWORK, "outline_hull"] return [BODY, "skin"] ## (class, detail) for a surface, from the table where it has an entry and from ## the heuristic where it does not. ## ## The cull-mode test is re-run even when the table HAS an entry, because that ## is the one piece of evidence the build side cannot see: glTF has no way to ## say "draw only the backfaces", so an inverted-hull outline arrives in Blender ## indistinguishable from an ordinary surface and only shows itself here. func resolve(mesh_name: String, surface_index: int, mat: BaseMaterial3D) -> Array: var material_name := "" if mat == null else mat.resource_name if mat != null and mat.albedo_texture == null \ and mat.cull_mode == BaseMaterial3D.CULL_FRONT: return [LINEWORK, "outline_hull"] var record := lookup(mesh_name, surface_index, material_name) if record.is_empty(): return guess(mat) return [record.get("class", BODY), record.get("detail", "")]