///|
/// Material and texture policy analysis for asset build systems.
///
/// The parser exposes the core PBR structures. This module turns them into
/// operational checks: texture slot reachability, sampler compatibility,
/// alpha policy, numerical ranges, duplicate material signatures, and memory
/// budgets that can be used by an importer or CI quality gate.
///|
pub(all) enum TextureSemantic {
BaseColor
MetallicRoughness
Normal
Occlusion
Emissive
} derive(Debug, ToJson, Eq)
///|
pub(all) struct TextureSlotReport {
material : Int
semantic : TextureSemantic
texture : Int
image : Int?
sampler : Int?
tex_coord : Int
uri : String?
embedded : Bool
external : Bool
valid : Bool
issues : Array[String]
} derive(Debug, ToJson, Eq)
///|
pub(all) struct MaterialIssue {
material : Int
severity : DiagnosticSeverity
code : String
path : String
message : String
hint : String?
} derive(Debug, ToJson, Eq)
///|
pub(all) struct MaterialReport {
material : Int
name : String?
alpha_mode : AlphaMode
texture_slot_count : Int
texcoord_set_count : Int
has_normal_map : Bool
has_emissive : Bool
has_transparency : Bool
double_sided : Bool
metallic_factor : Double
roughness_factor : Double
base_color : Color4
texture_slots : Array[TextureSlotReport]
issues : Array[MaterialIssue]
signature : String
valid : Bool
} derive(Debug, ToJson, Eq)
///|
pub(all) struct TextureReport {
texture : Int
name : String?
source_image : Int?
sampler : Int?
uri : String?
uri_kind : UriKind?
semantic_count : Int
semantic_names : Array[String]
texcoord_count : Int
valid : Bool
issues : Array[String]
} derive(Debug, ToJson, Eq)
///|
pub(all) struct MaterialBudget {
max_materials : Int
max_texture_slots : Int
max_unique_images : Int
max_external_images : Int
max_texcoord_sets : Int
allow_double_sided : Bool
allow_blend : Bool
} derive(Debug, ToJson, Eq)
///|
pub(all) struct MaterialBudgetResult {
passed : Bool
violations : Array[String]
material_overage : Int
texture_slot_overage : Int
image_overage : Int
external_image_overage : Int
texcoord_overage : Int
} derive(Debug, ToJson, Eq)
///|
pub fn default_material_budget() -> MaterialBudget {
{
max_materials: 4096,
max_texture_slots: 20000,
max_unique_images: 4096,
max_external_images: 4096,
max_texcoord_sets: 4,
allow_double_sided: true,
allow_blend: true,
}
}
///|
pub fn mobile_material_budget() -> MaterialBudget {
{
max_materials: 512,
max_texture_slots: 4096,
max_unique_images: 1024,
max_external_images: 1024,
max_texcoord_sets: 2,
allow_double_sided: false,
allow_blend: true,
}
}
///|
pub fn texture_semantic_name(value : TextureSemantic) -> String {
match value {
BaseColor => "baseColor"
MetallicRoughness => "metallicRoughness"
Normal => "normal"
Occlusion => "occlusion"
Emissive => "emissive"
}
}
///|
pub fn alpha_mode_name(value : AlphaMode) -> String {
match value {
Opaque => "OPAQUE"
Mask => "MASK"
Blend => "BLEND"
}
}
///|
pub fn material_name(doc : GltfDocument, material : Int) -> String {
match doc.materials.get(material) {
Some(value) =>
match value.name {
Some(name) => name
None => "material[\{material}]"
}
None => "material[\{material}]"
}
}
///|
pub fn texture_name(doc : GltfDocument, texture : Int) -> String {
match doc.textures.get(texture) {
Some(value) =>
match value.name {
Some(name) => name
None => "texture[\{texture}]"
}
None => "texture[\{texture}]"
}
}
///|
pub fn material_texture_info(
material : GltfMaterial,
semantic : TextureSemantic,
) -> TextureInfo? {
match semantic {
BaseColor => material.pbr_metallic_roughness.base_color_texture
MetallicRoughness =>
material.pbr_metallic_roughness.metallic_roughness_texture
Normal => material.normal_texture
Occlusion => material.occlusion_texture
Emissive => material.emissive_texture
}
}
///|
pub fn material_texture_count_by_semantic(
material : GltfMaterial,
semantic : TextureSemantic,
) -> Int {
if material_texture_info(material, semantic) is Some(_) {
1
} else {
0
}
}
///|
pub fn material_texture_semantics(
material : GltfMaterial,
) -> Array[TextureSemantic] {
let result : Array[TextureSemantic] = []
for semantic in [BaseColor, MetallicRoughness, Normal, Occlusion, Emissive] {
if material_texture_info(material, semantic) is Some(_) {
result.push(semantic)
}
}
result
}
///|
pub fn material_texcoord_sets(material : GltfMaterial) -> Array[Int] {
let result : Array[Int] = []
for semantic in material_texture_semantics(material) {
match material_texture_info(material, semantic) {
Some(info) =>
if !result.contains(info.tex_coord) {
result.push(info.tex_coord)
}
None => ()
}
}
result
}
///|
pub fn material_has_texture(
material : GltfMaterial,
semantic : TextureSemantic,
) -> Bool {
material_texture_info(material, semantic) is Some(_)
}
///|
pub fn material_has_normal_map(material : GltfMaterial) -> Bool {
material_has_texture(material, Normal)
}
///|
pub fn material_has_emissive_map(material : GltfMaterial) -> Bool {
material_has_texture(material, Emissive)
}
///|
pub fn material_is_transparent(material : GltfMaterial) -> Bool {
material.alpha_mode == Blend ||
(
material.alpha_mode == Mask &&
material.pbr_metallic_roughness.base_color_factor.a < 1.0
)
}
///|
pub fn material_is_opaque(material : GltfMaterial) -> Bool {
material.alpha_mode == Opaque &&
material.pbr_metallic_roughness.base_color_factor.a >= 1.0
}
///|
pub fn material_uses_nonzero_texcoord(material : GltfMaterial) -> Bool {
for value in material_texcoord_sets(material) {
if value != 0 {
return true
}
}
false
}
///|
pub fn material_pbr_parameter_error_count(material : GltfMaterial) -> Int {
let pbr = material.pbr_metallic_roughness
let mut errors = 0
if pbr.metallic_factor < 0.0 || pbr.metallic_factor > 1.0 {
errors += 1
}
if pbr.roughness_factor < 0.0 || pbr.roughness_factor > 1.0 {
errors += 1
}
if material.alpha_cutoff < 0.0 || material.alpha_cutoff > 1.0 {
errors += 1
}
if !color_is_valid(pbr.base_color_factor) {
errors += 1
}
errors
}
///|
pub fn material_signature(material : GltfMaterial) -> String {
let pbr = material.pbr_metallic_roughness
let texture_signature = material_texture_semantics(material)
.map(texture_semantic_name)
.join(",")
"\{pbr.base_color_factor.r}:\{pbr.base_color_factor.g}:\{pbr.base_color_factor.b}:\{pbr.base_color_factor.a}:\{pbr.metallic_factor}:\{pbr.roughness_factor}:\{alpha_mode_name(material.alpha_mode)}:\{material.alpha_cutoff}:\{material.double_sided}:\{texture_signature}"
}
///|
fn image_uri(doc : GltfDocument, image : Int) -> String? {
match doc.images.get(image) {
Some(value) => value.uri
None => None
}
}
///|
fn texture_slot_report(
doc : GltfDocument,
material_index : Int,
semantic : TextureSemantic,
info : TextureInfo,
) -> TextureSlotReport {
let issues : Array[String] = []
let texture = info.index
let texture_value = doc.textures.get(texture)
let image = match texture_value {
Some(value) => value.source
None => None
}
let sampler = match texture_value {
Some(value) => value.sampler
None => None
}
if texture_value is None {
issues.push("texture index is outside the document")
}
match image {
Some(index) =>
if index < 0 || index >= doc.images.length() {
issues.push("image index is outside the document")
}
None => issues.push("texture has no source image")
}
if info.tex_coord < 0 {
issues.push("texCoord cannot be negative")
}
let uri = match image {
Some(index) => image_uri(doc, index)
None => None
}
let embedded = match uri {
Some(value) => value.has_prefix("data:")
None => false
}
let external = match uri {
Some(value) => !value.has_prefix("data:")
None => false
}
{
material: material_index,
semantic,
texture,
image,
sampler,
tex_coord: info.tex_coord,
uri,
embedded,
external,
valid: issues.length() == 0,
issues,
}
}
///|
pub fn material_texture_slot_reports(
doc : GltfDocument,
material_index : Int,
) -> Array[TextureSlotReport] {
match doc.materials.get(material_index) {
None => []
Some(material) => {
let result : Array[TextureSlotReport] = []
for semantic in material_texture_semantics(material) {
match material_texture_info(material, semantic) {
Some(info) =>
result.push(
texture_slot_report(doc, material_index, semantic, info),
)
None => ()
}
}
result
}
}
}
///|
fn material_issue(
material : Int,
severity : DiagnosticSeverity,
code : String,
path : String,
message : String,
hint : String?,
) -> MaterialIssue {
{ material, severity, code, path, message, hint }
}
///|
pub fn material_report(
doc : GltfDocument,
material_index : Int,
) -> MaterialReport {
match doc.materials.get(material_index) {
None =>
{
material: material_index,
name: None,
alpha_mode: Opaque,
texture_slot_count: 0,
texcoord_set_count: 0,
has_normal_map: false,
has_emissive: false,
has_transparency: false,
double_sided: false,
metallic_factor: 0.0,
roughness_factor: 1.0,
base_color: { r: 1.0, g: 1.0, b: 1.0, a: 1.0 },
texture_slots: [],
issues: [
material_issue(
material_index,
Error,
"INVALID_MATERIAL",
"materials[\{material_index}]",
"material index is outside the document",
None,
),
],
signature: "",
valid: false,
}
Some(material) => {
let slots = material_texture_slot_reports(doc, material_index)
let issues : Array[MaterialIssue] = []
let pbr_errors = material_pbr_parameter_error_count(material)
if pbr_errors > 0 {
issues.push(
material_issue(
material_index,
Error,
"PBR_RANGE",
"materials[\{material_index}]",
"one or more PBR factors are outside their legal range",
Some("clamp metallic, roughness, alpha cutoff, and color values"),
),
)
}
if material.double_sided {
issues.push(
material_issue(
material_index,
Info,
"DOUBLE_SIDED",
"materials[\{material_index}].doubleSided",
"material disables back-face culling",
Some("use double-sided only for intentionally thin surfaces"),
),
)
}
if material.alpha_mode == Mask &&
(material.alpha_cutoff < 0.0 || material.alpha_cutoff > 1.0) {
issues.push(
material_issue(
material_index,
Error,
"ALPHA_CUTOFF",
"materials[\{material_index}].alphaCutoff",
"alpha cutoff must be between zero and one",
None,
),
)
}
for slot in slots {
for text in slot.issues {
issues.push(
material_issue(
material_index,
Error,
"TEXTURE_REFERENCE",
"materials[\{material_index}]",
text,
Some("repair texture, image, and sampler references"),
),
)
}
}
{
material: material_index,
name: material.name,
alpha_mode: material.alpha_mode,
texture_slot_count: slots.length(),
texcoord_set_count: material_texcoord_sets(material).length(),
has_normal_map: material_has_normal_map(material),
has_emissive: material_has_emissive_map(material),
has_transparency: material_is_transparent(material),
double_sided: material.double_sided,
metallic_factor: material.pbr_metallic_roughness.metallic_factor,
roughness_factor: material.pbr_metallic_roughness.roughness_factor,
base_color: material.pbr_metallic_roughness.base_color_factor,
texture_slots: slots,
issues,
signature: material_signature(material),
valid: issues.fold(init=true, (passed, issue) => {
passed && issue.severity != Error
}),
}
}
}
}
///|
pub fn all_material_reports(doc : GltfDocument) -> Array[MaterialReport] {
let reports : Array[MaterialReport] = []
for index in 0.. Array[Int] {
let counts : Array[Int] = []
for _ in doc.materials {
counts.push(0)
}
for mesh in doc.meshes {
for primitive in mesh.primitives {
match primitive.material {
Some(index) =>
if index >= 0 && index < counts.length() {
counts[index] += 1
}
None => ()
}
}
}
counts
}
///|
pub fn unused_material_indices(doc : GltfDocument) -> Array[Int] {
let counts = material_usage_counts_by_mesh(doc)
let result : Array[Int] = []
for index, count in counts {
if count == 0 {
result.push(index)
}
}
result
}
///|
pub fn duplicate_material_groups(doc : GltfDocument) -> Array[Array[Int]] {
let groups : Map[String, Array[Int]] = Map([])
for index, material in doc.materials {
let signature = material_signature(material)
match groups.get(signature) {
Some(values) => values.push(index)
None => groups[signature] = [index]
}
}
let result : Array[Array[Int]] = []
for _, values in groups {
if values.length() > 1 {
result.push(values)
}
}
result
}
///|
fn texture_semantic_usage(
doc : GltfDocument,
texture_index : Int,
) -> Array[TextureSemantic] {
let result : Array[TextureSemantic] = []
for material in doc.materials {
for semantic in material_texture_semantics(material) {
match material_texture_info(material, semantic) {
Some(info) =>
if info.index == texture_index && !result.contains(semantic) {
result.push(semantic)
}
None => ()
}
}
}
result
}
///|
pub fn texture_report(doc : GltfDocument, texture_index : Int) -> TextureReport {
match doc.textures.get(texture_index) {
None =>
{
texture: texture_index,
name: None,
source_image: None,
sampler: None,
uri: None,
uri_kind: None,
semantic_count: 0,
semantic_names: [],
texcoord_count: 0,
valid: false,
issues: ["texture index is outside the document"],
}
Some(texture) => {
let issues : Array[String] = []
let uri = match texture.source {
Some(image) => image_uri(doc, image)
None => None
}
if texture.source is None {
issues.push("texture has no source image")
}
let semantics = texture_semantic_usage(doc, texture_index)
let names = semantics.map(texture_semantic_name)
let texcoords : Map[Int, Bool] = Map([])
for material in doc.materials {
for semantic in material_texture_semantics(material) {
match material_texture_info(material, semantic) {
Some(info) =>
if info.index == texture_index {
texcoords[info.tex_coord] = true
}
None => ()
}
}
}
{
texture: texture_index,
name: texture.name,
source_image: texture.source,
sampler: texture.sampler,
uri,
uri_kind: uri.map(classify_uri),
semantic_count: semantics.length(),
semantic_names: names,
texcoord_count: texcoords.length(),
valid: issues.length() == 0,
issues,
}
}
}
}
///|
pub fn all_texture_reports(doc : GltfDocument) -> Array[TextureReport] {
let reports : Array[TextureReport] = []
for index in 0.. Int {
all_texture_reports(doc).fold(init=0, (count, report) => {
match report.uri {
Some(uri) => if !uri.has_prefix("data:") { count + 1 } else { count }
None => count
}
})
}
///|
pub fn texture_embedded_count(doc : GltfDocument) -> Int {
all_texture_reports(doc).fold(init=0, (count, report) => {
match report.uri {
Some(uri) => if uri.has_prefix("data:") { count + 1 } else { count }
None => count
}
})
}
///|
pub fn material_alpha_mode_count(doc : GltfDocument, mode : AlphaMode) -> Int {
doc.materials.fold(init=0, (count, material) => {
if material.alpha_mode == mode {
count + 1
} else {
count
}
})
}
///|
pub fn material_double_sided_count(doc : GltfDocument) -> Int {
doc.materials.fold(init=0, (count, material) => {
if material.double_sided {
count + 1
} else {
count
}
})
}
///|
pub fn material_texcoord_max(doc : GltfDocument) -> Int {
let mut maximum = 0
for material in doc.materials {
for value in material_texcoord_sets(material) {
if value > maximum {
maximum = value
}
}
}
maximum
}
///|
pub fn material_invalid_count(doc : GltfDocument) -> Int {
all_material_reports(doc).fold(init=0, (count, report) => {
if report.valid {
count
} else {
count + 1
}
})
}
///|
pub fn material_budget_result(
doc : GltfDocument,
budget : MaterialBudget,
) -> MaterialBudgetResult {
let reports = all_material_reports(doc)
let texture_slots = reports.fold(init=0, (sum, report) => {
sum + report.texture_slot_count
})
let images : Map[Int, Bool] = Map([])
for texture in doc.textures {
match texture.source {
Some(image) => images[image] = true
None => ()
}
}
let external_images = doc.images.fold(init=0, (count, image) => {
match image.uri {
Some(uri) => if !uri.has_prefix("data:") { count + 1 } else { count }
None => count
}
})
material_budget_result_from_values(
doc.materials.length(),
texture_slots,
images.length(),
external_images,
material_texcoord_max(doc) + 1,
material_double_sided_count(doc) > 0,
material_alpha_mode_count(doc, Blend) > 0,
budget,
)
}
///|
/// Evaluate a material policy from reports when the original document is not
/// available. Report data includes the resources actually referenced by
/// material slots, so this remains useful for cached or streamed analyses.
pub fn material_budget_result_from_reports(
reports : Array[MaterialReport],
budget : MaterialBudget,
) -> MaterialBudgetResult {
let texture_slots = reports.fold(init=0, (sum, report) => {
sum + report.texture_slot_count
})
let images : Map[Int, Bool] = Map([])
let external_images : Map[Int, Bool] = Map([])
let mut max_texcoord_sets = 0
let mut has_double_sided = false
let mut has_blend = false
for report in reports {
if report.texcoord_set_count > max_texcoord_sets {
max_texcoord_sets = report.texcoord_set_count
}
if report.double_sided {
has_double_sided = true
}
if report.alpha_mode == Blend {
has_blend = true
}
for slot in report.texture_slots {
match slot.image {
Some(image) => {
images[image] = true
if slot.external {
external_images[image] = true
}
}
None => ()
}
}
}
material_budget_result_from_values(
reports.length(),
texture_slots,
images.length(),
external_images.length(),
max_texcoord_sets,
has_double_sided,
has_blend,
budget,
)
}
///|
fn material_budget_result_from_values(
material_count : Int,
texture_slots : Int,
unique_images : Int,
external_images : Int,
texcoord_sets : Int,
has_double_sided : Bool,
has_blend : Bool,
budget : MaterialBudget,
) -> MaterialBudgetResult {
let material_overage = if material_count > budget.max_materials {
material_count - budget.max_materials
} else {
0
}
let texture_slot_overage = if texture_slots > budget.max_texture_slots {
texture_slots - budget.max_texture_slots
} else {
0
}
let image_overage = if unique_images > budget.max_unique_images {
unique_images - budget.max_unique_images
} else {
0
}
let external_image_overage = if external_images > budget.max_external_images {
external_images - budget.max_external_images
} else {
0
}
let texcoord_overage = if texcoord_sets > budget.max_texcoord_sets {
texcoord_sets - budget.max_texcoord_sets
} else {
0
}
let violations : Array[String] = []
if material_overage > 0 {
violations.push("material budget exceeded by \{material_overage}")
}
if texture_slot_overage > 0 {
violations.push("texture slot budget exceeded by \{texture_slot_overage}")
}
if image_overage > 0 {
violations.push("unique image budget exceeded by \{image_overage}")
}
if external_image_overage > 0 {
violations.push(
"external image budget exceeded by \{external_image_overage}",
)
}
if texcoord_overage > 0 {
violations.push(
"texture coordinate set budget exceeded by \{texcoord_overage}",
)
}
if !budget.allow_double_sided && has_double_sided {
violations.push("double-sided materials are disabled by policy")
}
if !budget.allow_blend && has_blend {
violations.push("blend materials are disabled by policy")
}
{
passed: violations.length() == 0,
violations,
material_overage,
texture_slot_overage,
image_overage,
external_image_overage,
texcoord_overage,
}
}
///|
pub fn material_report_summary_lines(
reports : Array[MaterialReport],
) -> Array[String] {
let transparent = reports.fold(init=0, (count, report) => {
if report.has_transparency {
count + 1
} else {
count
}
})
let normal = reports.fold(init=0, (count, report) => {
if report.has_normal_map {
count + 1
} else {
count
}
})
[
"materials=\{reports.length()}, transparent=\{transparent}",
"normal_maps=\{normal}, invalid=\{reports.fold(init=0, (count, report) => if report.valid { count } else { count + 1 })}",
"textures=\{reports.fold(init=0, (sum, report) => sum + report.texture_slot_count)}",
]
}