///|
/// Non-destructive canonicalization plans for reproducible asset builds.
///|
pub(all) enum NormalizationAction {
NormalizeUri
NormalizeQuaternion
NormalizeWeights
ClampMaterialFactors
RepairNodeNames
RemoveUnusedRecord
MergeDuplicateRecord
AlignBufferView
ReorderExtensionList
} derive(Debug, ToJson, Eq)
///|
pub(all) struct NormalizationFinding {
action : NormalizationAction
path : String
before : String
after : String
safe : Bool
applied : Bool
} derive(Debug, ToJson, Eq)
///|
pub(all) struct NormalizationPlan {
findings : Array[NormalizationFinding]
safe_count : Int
review_count : Int
valid : Bool
fingerprint : Int
} derive(Debug, ToJson, Eq)
///|
pub(all) struct NormalizationPolicy {
normalize_uris : Bool
normalize_quaternions : Bool
normalize_weights : Bool
clamp_material_factors : Bool
repair_names : Bool
remove_unused : Bool
merge_duplicates : Bool
align_views : Bool
} derive(Debug, ToJson, Eq)
///|
pub fn default_normalization_policy() -> NormalizationPolicy {
{
normalize_uris: true,
normalize_quaternions: true,
normalize_weights: true,
clamp_material_factors: true,
repair_names: false,
remove_unused: false,
merge_duplicates: false,
align_views: true,
}
}
///|
pub fn normalization_action_name(action : NormalizationAction) -> String {
match action {
NormalizeUri => "normalize-uri"
NormalizeQuaternion => "normalize-quaternion"
NormalizeWeights => "normalize-weights"
ClampMaterialFactors => "clamp-material-factors"
RepairNodeNames => "repair-node-names"
RemoveUnusedRecord => "remove-unused-record"
MergeDuplicateRecord => "merge-duplicate-record"
AlignBufferView => "align-buffer-view"
ReorderExtensionList => "reorder-extension-list"
}
}
///|
fn normalization_finding(
action : NormalizationAction,
path : String,
before : String,
after : String,
safe : Bool,
) -> NormalizationFinding {
{ action, path, before, after, safe, applied: false }
}
///|
pub fn normalized_node_name(name : String, fallback : String) -> String {
let trimmed = name.trim()
if trimmed.is_empty() {
fallback
} else {
let builder = StringBuilder()
let mut previous_space = false
for character in trimmed {
let is_space = character == ' ' || character == '\t' || character == '\n'
if is_space {
if !previous_space {
builder.write_char('_')
}
previous_space = true
} else {
builder.write_char(character)
previous_space = false
}
}
builder.to_string()
}
}
///|
pub fn normalized_uri_value(uri : String) -> String {
match classify_uri(uri) {
Relative => normalize_relative_uri(uri)
_ => uri
}
}
///|
pub fn normalized_quaternion_value(value : Quat) -> Quat {
normalize_quaternion(value)
}
///|
pub fn normalized_weight_values(values : Array[Double]) -> Array[Double] {
normalize_weights(values)
}
///|
pub fn clamped_material_factor(value : Double) -> Double {
if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
///|
pub fn normalized_material_factors(
material : GltfMaterial,
) -> (Double, Double, Double) {
(
clamped_material_factor(material.pbr_metallic_roughness.metallic_factor),
clamped_material_factor(material.pbr_metallic_roughness.roughness_factor),
clamped_material_factor(material.alpha_cutoff),
)
}
///|
fn add_uri_findings(
doc : GltfDocument,
findings : Array[NormalizationFinding],
) -> Unit {
for index, buffer in doc.buffers {
match buffer.uri {
Some(uri) => {
let normalized = normalized_uri_value(uri)
if uri != normalized {
findings.push(
normalization_finding(
NormalizeUri,
"buffers[\{index}].uri",
uri,
normalized,
true,
),
)
}
}
None => ()
}
}
for index, image in doc.images {
match image.uri {
Some(uri) => {
let normalized = normalized_uri_value(uri)
if uri != normalized {
findings.push(
normalization_finding(
NormalizeUri,
"images[\{index}].uri",
uri,
normalized,
true,
),
)
}
}
None => ()
}
}
}
///|
fn add_node_name_findings(
doc : GltfDocument,
findings : Array[NormalizationFinding],
policy : NormalizationPolicy,
) -> Unit {
if policy.repair_names {
for index, node in doc.nodes {
match node.name {
Some(name) => {
let normalized = normalized_node_name(name, "node_\{index}")
if name != normalized {
findings.push(
normalization_finding(
RepairNodeNames,
"nodes[\{index}].name",
name,
normalized,
false,
),
)
}
}
None =>
findings.push(
normalization_finding(
RepairNodeNames,
"nodes[\{index}].name",
"",
"node_\{index}",
false,
),
)
}
}
}
}
///|
fn add_normalization_material_findings(
doc : GltfDocument,
findings : Array[NormalizationFinding],
) -> Unit {
for index, material in doc.materials {
let before = normalized_material_factors(material)
let changed = before.0 != material.pbr_metallic_roughness.metallic_factor ||
before.1 != material.pbr_metallic_roughness.roughness_factor ||
before.2 != material.alpha_cutoff
if changed {
findings.push(
normalization_finding(
ClampMaterialFactors,
"materials[\{index}]",
"\{material.pbr_metallic_roughness.metallic_factor},\{material.pbr_metallic_roughness.roughness_factor},\{material.alpha_cutoff}",
"\{before.0},\{before.1},\{before.2}",
true,
),
)
}
}
}
///|
fn add_accessor_findings(
doc : GltfDocument,
findings : Array[NormalizationFinding],
policy : NormalizationPolicy,
) -> Unit {
if policy.align_views {
for index, view in doc.buffer_views {
if view.byte_offset % 4 != 0 {
findings.push(
normalization_finding(
AlignBufferView,
"bufferViews[\{index}].byteOffset",
"\{view.byte_offset}",
"\{view.byte_offset - view.byte_offset % 4}",
false,
),
)
}
}
}
}
///|
pub fn normalization_plan(
doc : GltfDocument,
policy : NormalizationPolicy,
) -> NormalizationPlan {
let findings : Array[NormalizationFinding] = []
if policy.normalize_uris {
add_uri_findings(doc, findings)
}
add_node_name_findings(doc, findings, policy)
if policy.clamp_material_factors {
add_normalization_material_findings(doc, findings)
}
add_accessor_findings(doc, findings, policy)
if policy.merge_duplicates {
for group in duplicate_material_groups(doc) {
findings.push(
normalization_finding(
MergeDuplicateRecord,
"materials",
"\{group.length()} records",
"one canonical record",
false,
),
)
}
}
let safe = findings.fold(init=0, (count, finding) => {
if finding.safe {
count + 1
} else {
count
}
})
let review = findings.length() - safe
let fingerprint = findings.fold(init=0, (hash, finding) => {
hash * 31 + String::hash(finding.path) + String::hash(finding.after)
})
{ findings, safe_count: safe, review_count: review, valid: true, fingerprint }
}
///|
pub fn normalization_plan_default(doc : GltfDocument) -> NormalizationPlan {
normalization_plan(doc, default_normalization_policy())
}
///|
pub fn normalization_plan_safe(
plan : NormalizationPlan,
) -> Array[NormalizationFinding] {
plan.findings.filter(finding => finding.safe)
}
///|
pub fn normalization_plan_review(
plan : NormalizationPlan,
) -> Array[NormalizationFinding] {
plan.findings.filter(finding => !finding.safe)
}
///|
pub fn normalization_plan_for_action(
plan : NormalizationPlan,
action : NormalizationAction,
) -> Array[NormalizationFinding] {
plan.findings.filter(finding => finding.action == action)
}
///|
pub fn normalization_plan_summary_lines(
plan : NormalizationPlan,
) -> Array[String] {
[
"findings=\{plan.findings.length()}, safe=\{plan.safe_count}, review=\{plan.review_count}",
"fingerprint=\{plan.fingerprint}, valid=\{plan.valid}",
]
}