///|
/// Skin and joint validation for production import pipelines.
///
/// A skin is a cross-reference between a node hierarchy, a joint list, an
/// optional inverse-bind accessor, and per-vertex JOINTS/WEIGHTS attributes.
/// This module makes those constraints inspectable without coupling the
/// library to a renderer or a particular GPU skinning layout.
///|
pub(all) struct SkinJointInfo {
skin_index : Int
joint_slot : Int
node : Int
parent_joint_slot : Int?
depth : Int
path : String
has_inverse_bind_matrix : Bool
world_transform : Mat4
} derive(Debug, ToJson, Eq)
///|
pub(all) struct WeightSummary {
vertex_count : Int
influence_slots : Int
normalized_vertex_count : Int
non_normalized_vertex_count : Int
zero_weight_vertex_count : Int
negative_weight_count : Int
over_one_weight_count : Int
max_influences_per_vertex : Int
average_weight_sum : Double
min_weight_sum : Double
max_weight_sum : Double
valid : Bool
} derive(Debug, ToJson, Eq)
///|
pub(all) struct SkinIssue {
skin : Int
severity : DiagnosticSeverity
code : String
path : String
message : String
hint : String?
} derive(Debug, ToJson, Eq)
///|
pub(all) struct SkinReport {
skin_index : Int
name : String?
joint_count : Int
root_joint : Int?
max_depth : Int
duplicate_joint_count : Int
unreachable_joint_count : Int
invalid_joint_count : Int
inverse_bind_accessor : Int?
inverse_bind_matrix_count : Int
joints : Array[SkinJointInfo]
weight_summary : WeightSummary
issues : Array[SkinIssue]
valid : Bool
} derive(Debug, ToJson, Eq)
///|
pub(all) struct SkinBudget {
max_joints : Int
max_influences : Int
max_depth : Int
require_normalized_weights : Bool
allow_zero_weight_vertices : Bool
max_weight_error : Double
} derive(Debug, ToJson, Eq)
///|
pub(all) struct SkinBudgetResult {
passed : Bool
violations : Array[String]
joint_overage : Int
influence_overage : Int
depth_overage : Int
weight_error : Double
} derive(Debug, ToJson, Eq)
///|
pub fn default_skin_budget() -> SkinBudget {
{
max_joints: 256,
max_influences: 4,
max_depth: 128,
require_normalized_weights: true,
allow_zero_weight_vertices: false,
max_weight_error: 0.001,
}
}
///|
pub fn mobile_skin_budget() -> SkinBudget {
{
max_joints: 64,
max_influences: 4,
max_depth: 64,
require_normalized_weights: true,
allow_zero_weight_vertices: false,
max_weight_error: 0.0001,
}
}
///|
fn skin_issue(
skin : Int,
severity : DiagnosticSeverity,
code : String,
path : String,
message : String,
hint : String?,
) -> SkinIssue {
{ skin, severity, code, path, message, hint }
}
///|
pub fn skin_name(doc : GltfDocument, skin_index : Int) -> String? {
match doc.skins.get(skin_index) {
Some(skin) => skin.name
None => None
}
}
///|
pub fn skin_joint_count(doc : GltfDocument, skin_index : Int) -> Int {
match doc.skins.get(skin_index) {
Some(skin) => skin.joints.length()
None => 0
}
}
///|
pub fn skin_joint_node(
doc : GltfDocument,
skin_index : Int,
joint_slot : Int,
) -> Int? {
match doc.skins.get(skin_index) {
Some(skin) => skin.joints.get(joint_slot)
None => None
}
}
///|
pub fn skin_joint_slots(doc : GltfDocument, skin_index : Int) -> Map[Int, Int] {
let slots : Map[Int, Int] = Map([])
match doc.skins.get(skin_index) {
Some(skin) =>
for slot, node in skin.joints {
if !slots.contains(node) {
slots[node] = slot
}
}
None => ()
}
slots
}
///|
pub fn skin_duplicate_joint_count(doc : GltfDocument, skin_index : Int) -> Int {
match doc.skins.get(skin_index) {
None => 0
Some(skin) => {
let seen : Map[Int, Bool] = Map([])
let mut duplicates = 0
for node in skin.joints {
if seen.contains(node) {
duplicates += 1
} else {
seen[node] = true
}
}
duplicates
}
}
}
///|
pub fn skin_root_joint_slot(doc : GltfDocument, skin_index : Int) -> Int? {
match doc.skins.get(skin_index) {
None => None
Some(skin) => {
let slots = skin_joint_slots(doc, skin_index)
let parents = node_parent_indices(doc)
let mut result : Int? = None
for slot, node in skin.joints {
let mut parent_is_joint = false
match parents.get(node) {
Some(parent) => parent_is_joint = slots.contains(parent)
None => ()
}
if !parent_is_joint && result is None {
result = Some(slot)
}
}
result
}
}
}
///|
pub fn skin_joint_parent_slot(
doc : GltfDocument,
skin_index : Int,
joint_slot : Int,
) -> Int? {
match skin_joint_node(doc, skin_index, joint_slot) {
None => None
Some(node) =>
match node_parent_indices(doc).get(node) {
Some(parent) => skin_joint_slots(doc, skin_index).get(parent)
None => None
}
}
}
///|
pub fn skin_joint_depth(
doc : GltfDocument,
skin_index : Int,
joint_slot : Int,
) -> Int {
let mut current : Int? = Some(joint_slot)
let mut depth = 0
let seen : Map[Int, Bool] = Map([])
while current is Some(slot) {
if seen.contains(slot) {
return depth
}
seen[slot] = true
depth += 1
current = skin_joint_parent_slot(doc, skin_index, slot)
}
depth
}
///|
pub fn skin_max_depth(doc : GltfDocument, skin_index : Int) -> Int {
let mut maximum = 0
for slot in 0.. maximum {
maximum = depth
}
}
maximum
}
///|
pub fn skin_joint_path(
doc : GltfDocument,
skin_index : Int,
joint_slot : Int,
) -> String {
match skin_joint_node(doc, skin_index, joint_slot) {
None => "invalid-joint[\{joint_slot}]"
Some(node) => scene_node_path_text(scene_node_path(doc, None, node))
}
}
///|
pub fn skin_joint_world_transform(
doc : GltfDocument,
skin_index : Int,
joint_slot : Int,
) -> Mat4 {
match skin_joint_node(doc, skin_index, joint_slot) {
Some(node) => node_world_transform(doc, node)
None => identity_mat4()
}
}
///|
pub fn skin_joint_infos(
doc : GltfDocument,
skin_index : Int,
) -> Array[SkinJointInfo] {
let result : Array[SkinJointInfo] = []
match doc.skins.get(skin_index) {
None => result
Some(skin) => {
for slot, node in skin.joints {
result.push({
skin_index,
joint_slot: slot,
node,
parent_joint_slot: skin_joint_parent_slot(doc, skin_index, slot),
depth: skin_joint_depth(doc, skin_index, slot),
path: skin_joint_path(doc, skin_index, slot),
has_inverse_bind_matrix: skin.inverse_bind_matrices is Some(_),
world_transform: skin_joint_world_transform(doc, skin_index, slot),
})
}
result
}
}
}
///|
pub fn decode_skin_inverse_bind_matrices(
doc : GltfDocument,
asset : BinaryAsset,
skin_index : Int,
) -> Array[Mat4] {
match doc.skins.get(skin_index) {
None => []
Some(skin) =>
match skin.inverse_bind_matrices {
None => []
Some(accessor_index) => {
let decoded = decode_accessor(doc, asset, accessor_index)
if !decoded.valid || decoded.component_count != 16 {
[]
} else {
let matrices : Array[Mat4] = []
for index in 0.. Mat4 {
match
decode_skin_inverse_bind_matrices(doc, asset, skin_index).get(joint_slot) {
Some(matrix) => matrix
None => identity_mat4()
}
}
///|
pub fn skin_joint_matrix(
doc : GltfDocument,
asset : BinaryAsset,
skin_index : Int,
joint_slot : Int,
) -> Mat4 {
mat4_mul(
skin_joint_world_transform(doc, skin_index, joint_slot),
skin_inverse_bind_matrix(doc, asset, skin_index, joint_slot),
)
}
///|
pub fn skin_joint_matrices(
doc : GltfDocument,
asset : BinaryAsset,
skin_index : Int,
) -> Array[Mat4] {
let matrices : Array[Mat4] = []
for slot in 0.. Bool {
value == value && value != 1.0 / 0.0 && value != -1.0 / 0.0
}
///|
pub fn weights_are_normalized(
values : Array[Double],
tolerance? : Double = 0.001,
) -> Bool {
let sum = values.fold(init=0.0, (total, value) => total + value)
Double::abs(sum - 1.0) <= tolerance
}
///|
pub fn normalize_weights(values : Array[Double]) -> Array[Double] {
let positive : Array[Double] = []
let mut sum = 0.0
for value in values {
let clamped = if value < 0.0 { 0.0 } else { value }
positive.push(clamped)
sum += clamped
}
if sum <= 0.0 {
positive
} else {
positive.map(value => value / sum)
}
}
///|
pub fn weight_sum(values : Array[Double]) -> Double {
values.fold(init=0.0, (sum, value) => sum + value)
}
///|
pub fn weight_max(values : Array[Double]) -> Double {
values.fold(init=0.0, (maximum, value) => {
if value > maximum {
value
} else {
maximum
}
})
}
///|
pub fn weight_nonzero_count(values : Array[Double]) -> Int {
values.fold(init=0, (count, value) => {
if value > 0.0 {
count + 1
} else {
count
}
})
}
///|
pub fn weight_negative_count(values : Array[Double]) -> Int {
values.fold(init=0, (count, value) => {
if value < 0.0 {
count + 1
} else {
count
}
})
}
///|
pub fn weight_over_one_count(values : Array[Double]) -> Int {
values.fold(init=0, (count, value) => {
if value > 1.0 {
count + 1
} else {
count
}
})
}
///|
pub fn skin_weight_summary(
doc : GltfDocument,
asset : BinaryAsset,
primitive : GltfPrimitive,
) -> WeightSummary {
let weights = match primitive.attributes.get("WEIGHTS_0") {
Some(accessor) => decode_accessor_normalized(doc, asset, accessor)
None =>
{
accessor_index: -1,
count: 0,
component_count: 0,
values: [],
valid: false,
errors: ["WEIGHTS_0 is missing"],
}
}
let mut normalized = 0
let mut non_normalized = 0
let mut zero = 0
let mut negative = 0
let mut over_one = 0
let mut maximum_influences = 0
let mut total_sum = 0.0
let mut minimum_sum = 0.0
let mut maximum_sum = 0.0
for vertex in 0.. maximum_influences {
maximum_influences = nonzero
}
if sum <= 0.0000001 {
zero += 1
}
if weights_are_normalized(values) {
normalized += 1
} else {
non_normalized += 1
}
negative += weight_negative_count(values)
over_one += weight_over_one_count(values)
total_sum += sum
if vertex == 0 || sum < minimum_sum {
minimum_sum = sum
}
if sum > maximum_sum {
maximum_sum = sum
}
}
{
vertex_count: weights.count,
influence_slots: weights.component_count,
normalized_vertex_count: normalized,
non_normalized_vertex_count: non_normalized,
zero_weight_vertex_count: zero,
negative_weight_count: negative,
over_one_weight_count: over_one,
max_influences_per_vertex: maximum_influences,
average_weight_sum: if weights.count == 0 {
0.0
} else {
total_sum / weights.count.to_double()
},
min_weight_sum: minimum_sum,
max_weight_sum: maximum_sum,
valid: weights.valid && negative == 0,
}
}
///|
pub fn primitive_joint_indices(
doc : GltfDocument,
asset : BinaryAsset,
primitive : GltfPrimitive,
) -> Array[Int] {
match primitive.attributes.get("JOINTS_0") {
None => []
Some(accessor) => decode_index_values(doc, asset, accessor)
}
}
///|
pub fn primitive_influence_count(
doc : GltfDocument,
asset : BinaryAsset,
primitive : GltfPrimitive,
) -> Int {
ignore(asset)
match primitive.attributes.get("WEIGHTS_0") {
None => 0
Some(accessor) =>
match doc.accessors.get(accessor) {
Some(value) => accessor_component_count(value.accessor_type)
None => 0
}
}
}
///|
pub fn skin_used_by_mesh(doc : GltfDocument, skin_index : Int) -> Array[Int] {
let meshes : Array[Int] = []
for node in doc.nodes {
if node.skin == Some(skin_index) {
match node.mesh {
Some(mesh) => if !meshes.contains(mesh) { meshes.push(mesh) }
None => ()
}
}
}
meshes
}
///|
pub fn skin_used_by_node(doc : GltfDocument, skin_index : Int) -> Array[Int] {
let nodes : Array[Int] = []
for index, node in doc.nodes {
if node.skin == Some(skin_index) {
nodes.push(index)
}
}
nodes
}
///|
pub fn skin_issue_count(
report : SkinReport,
severity : DiagnosticSeverity,
) -> Int {
report.issues.fold(init=0, (count, issue) => {
if issue.severity == severity {
count + 1
} else {
count
}
})
}
///|
fn empty_weight_summary(valid : Bool) -> WeightSummary {
{
vertex_count: 0,
influence_slots: 0,
normalized_vertex_count: 0,
non_normalized_vertex_count: 0,
zero_weight_vertex_count: 0,
negative_weight_count: 0,
over_one_weight_count: 0,
max_influences_per_vertex: 0,
average_weight_sum: 0.0,
min_weight_sum: 0.0,
max_weight_sum: 0.0,
valid,
}
}
///|
fn merge_weight_summaries(
left : WeightSummary,
right : WeightSummary,
) -> WeightSummary {
let vertex_count = left.vertex_count + right.vertex_count
let min_weight_sum = if left.vertex_count == 0 {
right.min_weight_sum
} else if right.vertex_count == 0 {
left.min_weight_sum
} else if left.min_weight_sum < right.min_weight_sum {
left.min_weight_sum
} else {
right.min_weight_sum
}
{
vertex_count,
influence_slots: if left.influence_slots > right.influence_slots {
left.influence_slots
} else {
right.influence_slots
},
normalized_vertex_count: left.normalized_vertex_count +
right.normalized_vertex_count,
non_normalized_vertex_count: left.non_normalized_vertex_count +
right.non_normalized_vertex_count,
zero_weight_vertex_count: left.zero_weight_vertex_count +
right.zero_weight_vertex_count,
negative_weight_count: left.negative_weight_count +
right.negative_weight_count,
over_one_weight_count: left.over_one_weight_count +
right.over_one_weight_count,
max_influences_per_vertex: if left.max_influences_per_vertex >
right.max_influences_per_vertex {
left.max_influences_per_vertex
} else {
right.max_influences_per_vertex
},
average_weight_sum: if vertex_count == 0 {
0.0
} else {
(
left.average_weight_sum * left.vertex_count.to_double() +
right.average_weight_sum * right.vertex_count.to_double()
) /
vertex_count.to_double()
},
min_weight_sum,
max_weight_sum: if left.max_weight_sum > right.max_weight_sum {
left.max_weight_sum
} else {
right.max_weight_sum
},
valid: left.valid && right.valid,
}
}
///|
fn skin_weight_summary_for_skin(
doc : GltfDocument,
asset : BinaryAsset,
skin_index : Int,
) -> WeightSummary {
let mut result = empty_weight_summary(true)
let mut found = false
for node in doc.nodes {
if node.skin == Some(skin_index) {
match node.mesh {
None => ()
Some(mesh_index) =>
match doc.meshes.get(mesh_index) {
None => ()
Some(mesh) =>
for primitive in mesh.primitives {
match primitive.attributes.get("WEIGHTS_0") {
None => ()
Some(_) => {
let summary = skin_weight_summary(doc, asset, primitive)
result = if found {
merge_weight_summaries(result, summary)
} else {
summary
}
found = true
}
}
}
}
}
}
}
result
}
///|
pub fn skin_report(
doc : GltfDocument,
asset : BinaryAsset,
skin_index : Int,
) -> SkinReport {
match doc.skins.get(skin_index) {
None =>
{
skin_index,
name: None,
joint_count: 0,
root_joint: None,
max_depth: 0,
duplicate_joint_count: 0,
unreachable_joint_count: 0,
invalid_joint_count: 1,
inverse_bind_accessor: None,
inverse_bind_matrix_count: 0,
joints: [],
weight_summary: {
vertex_count: 0,
influence_slots: 0,
normalized_vertex_count: 0,
non_normalized_vertex_count: 0,
zero_weight_vertex_count: 0,
negative_weight_count: 0,
over_one_weight_count: 0,
max_influences_per_vertex: 0,
average_weight_sum: 0.0,
min_weight_sum: 0.0,
max_weight_sum: 0.0,
valid: false,
},
issues: [
skin_issue(
skin_index,
Error,
"INVALID_SKIN",
"skins[\{skin_index}]",
"skin index is outside the document",
None,
),
],
valid: false,
}
Some(skin) => {
let mut invalid_joint_count = 0
let mut unreachable_count = 0
let issues : Array[SkinIssue] = []
for slot, node in skin.joints {
if node < 0 || node >= doc.nodes.length() {
invalid_joint_count += 1
issues.push(
skin_issue(
skin_index,
Error,
"INVALID_JOINT",
"skins[\{skin_index}].joints[\{slot}]",
"joint node index is outside the document",
None,
),
)
}
if node >= 0 &&
node < doc.nodes.length() &&
node_ancestors(doc, node).length() == 0 &&
node_parent_indices(doc).get(node) is None {
unreachable_count += 1
}
}
let duplicate = skin_duplicate_joint_count(doc, skin_index)
if duplicate > 0 {
issues.push(
skin_issue(
skin_index,
Error,
"DUPLICATE_JOINT",
"skins[\{skin_index}].joints",
"skin contains duplicate joint nodes",
Some("deduplicate the joint list in the exporter"),
),
)
}
let matrices = decode_skin_inverse_bind_matrices(doc, asset, skin_index)
match skin.inverse_bind_matrices {
Some(_) =>
if matrices.length() != skin.joints.length() {
issues.push(
skin_issue(
skin_index,
Error,
"INVERSE_BIND_COUNT",
"skins[\{skin_index}].inverseBindMatrices",
"inverse bind matrix count does not match joint count",
None,
),
)
}
None => ()
}
if skin.joints.length() == 0 {
issues.push(
skin_issue(
skin_index,
Warning,
"EMPTY_SKIN",
"skins[\{skin_index}].joints",
"skin has no joints",
Some("remove unused skin data"),
),
)
}
{
skin_index,
name: skin.name,
joint_count: skin.joints.length(),
root_joint: skin_root_joint_slot(doc, skin_index),
max_depth: skin_max_depth(doc, skin_index),
duplicate_joint_count: duplicate,
unreachable_joint_count: unreachable_count,
invalid_joint_count,
inverse_bind_accessor: skin.inverse_bind_matrices,
inverse_bind_matrix_count: matrices.length(),
joints: skin_joint_infos(doc, skin_index),
weight_summary: skin_weight_summary_for_skin(doc, asset, skin_index),
issues,
valid: issues.fold(init=true, (passed, issue) => {
passed && issue.severity != Error
}),
}
}
}
}
///|
pub fn all_skin_reports(
doc : GltfDocument,
asset : BinaryAsset,
) -> Array[SkinReport] {
let reports : Array[SkinReport] = []
for index in 0.. SkinBudgetResult {
let mut joint_overage = 0
let mut depth_overage = 0
let mut influence_overage = 0
let mut weight_error = 0.0
let violations : Array[String] = []
for report in reports {
if report.joint_count > budget.max_joints {
let overage = report.joint_count - budget.max_joints
if overage > joint_overage {
joint_overage = overage
}
}
if report.max_depth > budget.max_depth {
let overage = report.max_depth - budget.max_depth
if overage > depth_overage {
depth_overage = overage
}
}
if report.weight_summary.max_influences_per_vertex > budget.max_influences {
let overage = report.weight_summary.max_influences_per_vertex -
budget.max_influences
if overage > influence_overage {
influence_overage = overage
}
}
let error = Double::abs(report.weight_summary.average_weight_sum - 1.0)
if error > weight_error {
weight_error = error
}
}
if joint_overage > 0 {
violations.push("joint budget exceeded by \{joint_overage}")
}
if influence_overage > 0 {
violations.push("vertex influence budget exceeded by \{influence_overage}")
}
if depth_overage > 0 {
violations.push("skeleton depth budget exceeded by \{depth_overage}")
}
if budget.require_normalized_weights && weight_error > budget.max_weight_error {
violations.push(
"skin weights are not normalized within the configured tolerance",
)
}
if !budget.allow_zero_weight_vertices {
for report in reports {
if report.weight_summary.zero_weight_vertex_count > 0 {
violations.push("skin contains zero-weight vertices")
}
}
}
{
passed: violations.length() == 0,
violations,
joint_overage,
influence_overage,
depth_overage,
weight_error,
}
}
///|
pub fn skin_report_summary_lines(report : SkinReport) -> Array[String] {
[
"skin=\{report.skin_index}, joints=\{report.joint_count}, root=\{Repr(report.root_joint)}",
"depth=\{report.max_depth}, duplicate_joints=\{report.duplicate_joint_count}",
"inverse_bind_matrices=\{report.inverse_bind_matrix_count}",
"issues=\{report.issues.length()}, valid=\{report.valid}",
]
}