mod bytes;
mod container;
mod plan;
mod proof;
mod rest_bind;
mod rest_bind_proof;
mod rules;
use crate::capability::{
GltfCapabilityManifest, GltfCapabilityViolation, GltfCapabilityViolationKind,
GltfContainerKind, GltfScaleSource, NodeTransformFault, node_transform_faults,
};
use crate::{LoadError, WriteError};
use animsmith_core::scale::{
ScaleCapabilityCoverage, ScaleCapabilityFacts, ScaleError, ScaleOperation, ScaleRequest,
plan_scale,
};
use animsmith_core::{
SourceConstructKindV1, SourceFactsViewV1, SourceResourceLocatorV1, SourceSetCoverageStateV1,
};
use bytes::{AccessorSpan, ComponentExtrema};
use rules::{AccessorRule, JsonArrayRule};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};
pub use proof::{GltfScaleArtifactProof, prove_rewritten_artifact};
pub use rest_bind::rewrite_rest_bind;
pub use rest_bind_proof::prove_rewritten_rest_bind;
pub fn capability_facts(manifest: &GltfCapabilityManifest) -> ScaleCapabilityFacts {
let violations = manifest_violations(manifest);
capability_facts_from_violations(manifest, &violations)
}
pub fn capability_facts_for_source(source: &GltfScaleSource) -> ScaleCapabilityFacts {
join_source_facts(source, capability_facts(source.manifest()))
}
fn join_source_facts(
source: &GltfScaleSource,
mut facts: ScaleCapabilityFacts,
) -> ScaleCapabilityFacts {
let source_facts = source.source_facts();
if relevant_source_coverage_incomplete(source_facts) {
facts.coverage = ScaleCapabilityCoverage::Unavailable;
}
for row in source_facts.constructs().rows() {
if row.kind() != SourceConstructKindV1::Extension {
continue;
}
match row.name().as_str() {
"KHR_lights_punctual" => facts.lights_present = true,
"EXT_mesh_gpu_instancing" => facts.instancing_present = true,
_ => facts.unregistered_extensions_present = true,
}
}
if source_facts
.resources()
.rows()
.iter()
.any(|row| resource_is_external(row.locator()))
{
facts.external_resources_present = true;
}
facts
}
fn relevant_source_coverage_incomplete(source: SourceFactsViewV1<'_>) -> bool {
[
source.constructs().coverage().state(),
source.resources().coverage().state(),
]
.into_iter()
.any(|state| state != SourceSetCoverageStateV1::Complete)
}
fn resource_is_external(locator: &SourceResourceLocatorV1) -> bool {
!matches!(
locator,
SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
)
}
fn capability_facts_from_violations(
manifest: &GltfCapabilityManifest,
violations: &[GltfCapabilityViolation],
) -> ScaleCapabilityFacts {
let mut facts = ScaleCapabilityFacts::default();
facts.coverage = ScaleCapabilityCoverage::Complete;
if manifest
.buffers
.iter()
.any(|buffer| buffer.source_kind == crate::capability::GltfBufferSourceKind::External)
{
facts.coverage = ScaleCapabilityCoverage::Unavailable;
}
for violation in violations {
record_violation(&mut facts, violation.kind);
}
facts.morphs_present = manifest
.primitives
.iter()
.any(|primitive| primitive.morph_target_count > 0);
facts.morph_weights_present = !manifest.morph_weight_locations.is_empty();
facts.whole_document_morphs_preservable = (facts.morphs_present || facts.morph_weights_present)
&& manifest
.primitives
.iter()
.all(|primitive| primitive.unsupported_morph_locations.is_empty());
facts
}
fn record_violation(facts: &mut ScaleCapabilityFacts, kind: GltfCapabilityViolationKind) {
use GltfCapabilityViolationKind as Kind;
match kind {
Kind::ExternalResource => facts.external_resources_present = true,
Kind::MorphTarget => facts.morphs_present = true,
Kind::MorphWeights => facts.morph_weights_present = true,
Kind::Camera => facts.cameras_present = true,
Kind::Light => facts.lights_present = true,
Kind::Instancing => facts.instancing_present = true,
Kind::ExtensionDeclaration | Kind::ExtensionPayload => {
facts.unregistered_extensions_present = true;
}
Kind::Extras => facts.extras_present = true,
Kind::UnknownJsonMember => facts.unknown_source_members_present = true,
Kind::NonTrianglePrimitive => facts.non_triangle_primitives_present = true,
Kind::UnsupportedVertexAttribute => facts.unsupported_vertex_attributes_present = true,
Kind::SecondarySkinInfluences => facts.secondary_skin_influences_present = true,
Kind::MissingInverseBinds
| Kind::EmptyInverseBindAccessor
| Kind::InverseBindCountMismatch
| Kind::UnreadableInverseBinds => facts.inverse_bind_issues_present = true,
Kind::UnsafeAccessorLayout
| Kind::ConflictingAccessorUse
| Kind::OverlappingAccessorRanges
| Kind::ImagePayloadOverlap => facts.unsafe_accessor_layout_present = true,
Kind::ConflictingNodeTransform | Kind::NonAffineNodeMatrix | Kind::AnimatedMatrixNode => {
facts.unknown_source_members_present = true;
}
}
}
fn manifest_violations(manifest: &GltfCapabilityManifest) -> Vec<GltfCapabilityViolation> {
use GltfCapabilityViolationKind as Kind;
let mut out = Vec::new();
let mut add = |kind: Kind, location: String| {
out.push(GltfCapabilityViolation { kind, location });
};
for location in &manifest.external_resource_locations {
add(Kind::ExternalResource, location.clone());
}
for location in &manifest.extras_locations {
add(Kind::Extras, location.clone());
}
for location in &manifest.unknown_member_locations {
add(Kind::UnknownJsonMember, location.clone());
}
for name in &manifest.extensions {
add(
match name.as_str() {
"KHR_lights_punctual" => Kind::Light,
"EXT_mesh_gpu_instancing" => Kind::Instancing,
_ => Kind::ExtensionDeclaration,
},
format!("/extensionsUsed:{name}"),
);
}
for location in &manifest.extension_locations {
add(Kind::ExtensionPayload, location.clone());
}
if manifest.camera_count > 0 {
add(Kind::Camera, "/cameras".to_owned());
}
for instancing in &manifest.instancing {
add(
Kind::Instancing,
format!(
"/nodes/{}/extensions/EXT_mesh_gpu_instancing",
instancing.node_index
),
);
}
let matrix_nodes = manifest
.nodes
.iter()
.filter_map(|node| {
(node.rest_kind == crate::capability::GltfNodeRestKind::Matrix)
.then_some(node.node_index)
})
.collect::<BTreeSet<_>>();
for channel in &manifest.animation_channels {
if matrix_nodes.contains(&channel.target_node_index) {
add(
Kind::AnimatedMatrixNode,
format!(
"/animations/{}/channels/{}/target",
channel.animation_index, channel.channel_index
),
);
}
}
for primitive in &manifest.primitives {
let base = format!(
"/meshes/{}/primitives/{}",
primitive.mesh_index, primitive.primitive_index
);
for location in &primitive.unsupported_morph_locations {
add(Kind::MorphTarget, location.clone());
}
if primitive.mode != 4 {
add(Kind::NonTrianglePrimitive, format!("{base}/mode"));
}
for attribute in &primitive.attributes {
let semantic = attribute.semantic.as_str();
let location = format!("{base}/attributes/{semantic}");
if is_secondary_influence(semantic) {
add(Kind::SecondarySkinInfluences, location);
} else if !matches!(
semantic,
"POSITION" | "NORMAL" | "TEXCOORD_0" | "JOINTS_0" | "WEIGHTS_0"
) {
add(Kind::UnsupportedVertexAttribute, location);
}
}
}
for skin in &manifest.skins {
let location = format!("/skins/{}/inverseBindMatrices", skin.skin_index);
let accessor = skin
.inverse_bind_accessor_index
.and_then(|index| manifest.accessors.get(index));
match (skin.inverse_bind_accessor_index, skin.inverse_bind_count) {
(None, _) => add(Kind::MissingInverseBinds, location),
(Some(_), Some(0)) => add(Kind::EmptyInverseBindAccessor, location),
(Some(_), Some(count)) if count != skin.joint_count as u64 => {
add(Kind::InverseBindCountMismatch, location);
}
(Some(_), _)
if !accessor.is_some_and(|accessor| {
accessor.buffer_view_index.is_some()
&& accessor.component_type == 5126
&& accessor.accessor_type == "MAT4"
&& !accessor.sparse
}) =>
{
add(Kind::UnreadableInverseBinds, location);
}
_ => {}
}
}
for accessor_index in scale_bearing_accessors(manifest) {
let Some(accessor) = manifest.accessors.get(accessor_index) else {
add(
Kind::UnsafeAccessorLayout,
format!("/accessors/{accessor_index}"),
);
continue;
};
let element_size =
rules::components_per_element(&accessor.accessor_type).map(|components| components * 4);
let stride = accessor
.buffer_view_index
.and_then(|index| manifest.buffer_views.get(index))
.and_then(|view| view.byte_stride);
if accessor.sparse
|| accessor.normalized
|| accessor.component_type != 5126
|| accessor.buffer_view_index.is_none()
|| accessor.count == 0
|| element_size.is_none()
|| stride.is_some_and(|stride| Some(stride as usize) != element_size)
{
add(
Kind::UnsafeAccessorLayout,
format!("/accessors/{accessor_index}"),
);
}
}
out
}
pub fn operation_capability_facts(
manifest: &GltfCapabilityManifest,
operation: ScaleOperation,
) -> Result<ScaleCapabilityFacts, GltfScaleRewriteError> {
let mut violations = manifest_violations(manifest);
let facts = capability_facts_from_violations(manifest, &violations);
if facts.is_supported_for(operation) {
return Ok(facts);
}
if matches!(operation, ScaleOperation::RestBindUniformScale { .. }) {
for primitive in &manifest.primitives {
if primitive.morph_target_count > 0 {
violations.push(GltfCapabilityViolation {
kind: GltfCapabilityViolationKind::MorphTarget,
location: format!(
"/meshes/{}/primitives/{}/targets",
primitive.mesh_index, primitive.primitive_index
),
});
}
}
violations.extend(
manifest
.morph_weight_locations
.iter()
.cloned()
.map(|location| GltfCapabilityViolation {
kind: GltfCapabilityViolationKind::MorphWeights,
location,
}),
);
violations.sort_by(|left, right| {
(left.kind, left.location.as_str()).cmp(&(right.kind, right.location.as_str()))
});
violations.dedup();
}
let count = violations.len();
Err(GltfScaleRewriteError::Capability { violations, count })
}
pub fn operation_capability_facts_for_source(
source: &GltfScaleSource,
operation: ScaleOperation,
) -> Result<ScaleCapabilityFacts, GltfScaleRewriteError> {
if relevant_source_coverage_incomplete(source.source_facts()) {
return Err(ScaleError::IncompleteCapability.into());
}
let facts = join_source_facts(
source,
operation_capability_facts(source.manifest(), operation)?,
);
if facts.is_supported_for(operation) {
Ok(facts)
} else {
Err(ScaleError::IncompleteCapability.into())
}
}
fn is_secondary_influence(semantic: &str) -> bool {
semantic
.strip_prefix("JOINTS_")
.or_else(|| semantic.strip_prefix("WEIGHTS_"))
.and_then(|index| index.parse::<u32>().ok())
.is_some_and(|index| index >= 1)
}
fn scale_bearing_accessors(manifest: &GltfCapabilityManifest) -> BTreeSet<usize> {
let mut out = BTreeSet::new();
for primitive in &manifest.primitives {
for attribute in &primitive.attributes {
if attribute.semantic == "POSITION" {
out.insert(attribute.accessor_index);
}
}
out.extend(primitive.morph_position_accessors.iter().copied());
}
for skin in &manifest.skins {
out.extend(skin.inverse_bind_accessor_index);
}
for channel in &manifest.animation_channels {
if channel.target_path == "translation" {
out.insert(channel.output_accessor_index);
}
}
out
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct GltfScaleArtifact {
container: GltfContainerKind,
bytes: Vec<u8>,
rewritten_accessors: Vec<usize>,
rewritten_json_pointers: Vec<String>,
reencoded_buffers: Vec<usize>,
affected_source_nodes: Vec<usize>,
affected_source_skins: Vec<usize>,
declared_factor: f64,
operation: ScaleOperation,
}
impl GltfScaleArtifact {
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn container(&self) -> GltfContainerKind {
self.container
}
pub fn rewritten_accessors(&self) -> &[usize] {
&self.rewritten_accessors
}
pub fn rewritten_json_pointers(&self) -> &[String] {
&self.rewritten_json_pointers
}
pub fn reencoded_buffers(&self) -> &[usize] {
&self.reencoded_buffers
}
pub fn affected_source_nodes(&self) -> &[usize] {
&self.affected_source_nodes
}
pub fn affected_source_skins(&self) -> &[usize] {
&self.affected_source_skins
}
pub fn declared_factor(&self) -> f64 {
self.declared_factor
}
pub fn operation(&self) -> ScaleOperation {
self.operation
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GltfRawJsonDifferenceKind {
ArtifactAdded,
ArtifactRemoved,
ValueChanged,
}
impl std::fmt::Display for GltfRawJsonDifferenceKind {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::ArtifactAdded => "artifact-added",
Self::ArtifactRemoved => "artifact-removed",
Self::ValueChanged => "value-changed",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GltfRawJsonDifference {
pub pointer: String,
pub kind: GltfRawJsonDifferenceKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GltfRawJsonDifferenceSummary {
pub differences: Vec<GltfRawJsonDifference>,
pub omitted: usize,
}
struct RawJsonDifferenceSuffix<'a>(Option<&'a GltfRawJsonDifferenceSummary>);
impl std::fmt::Display for RawJsonDifferenceSuffix<'_> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Some(summary) = self.0 else {
return Ok(());
};
formatter.write_str("; raw JSON differences: ")?;
for (index, difference) in summary.differences.iter().enumerate() {
if index > 0 {
formatter.write_str(", ")?;
}
write!(formatter, "{} ({})", difference.pointer, difference.kind)?;
}
if summary.omitted > 0 {
write!(formatter, "; {} omitted", summary.omitted)?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GltfScaleRewriteError {
#[error("glTF scale rewrite rejected {count} unsupported source domain(s)")]
Capability {
violations: Vec<GltfCapabilityViolation>,
count: usize,
},
#[error(transparent)]
Plan(#[from] ScaleError),
#[error(transparent)]
Load(#[from] LoadError),
#[error(transparent)]
Write(#[from] WriteError),
#[error("no registered length-field handler for {location}")]
UnhandledLengthField {
location: String,
},
#[error("accessor {accessor_index} is used with two disagreeing rewrite rules")]
ConflictingRewriteRule {
accessor_index: usize,
},
#[error("accessor {accessor_index} at {location} is not a rewritable dense f32 accessor")]
UnrewritableAccessor {
accessor_index: usize,
location: String,
},
#[error("{location} declares a TRS member alongside matrix")]
ConflictingNodeTransform {
location: String,
},
#[error("{location} is {value}, so the node matrix is not TRS-decomposable")]
NonAffineNodeMatrix {
location: String,
value: f64,
expected: f64,
},
#[error("{location} reads bytes that overlap rewritten accessor {accessor_index}")]
ImagePayloadOverlap {
location: String,
accessor_index: usize,
},
#[error("source container cannot be reassembled: {reason}")]
UnreassemblableContainer {
reason: &'static str,
},
#[error("converted value {value} at {location} is not representable as f32")]
ValueNotRepresentable {
location: String,
value: f64,
},
#[error(
"accessor {accessor_index} element {element} must scale by {first_factor} for {first_location} and by {second_factor} for {second_location}"
)]
ConflictingRestBindFactor {
accessor_index: usize,
element: usize,
first_location: String,
first_factor: f64,
second_location: String,
second_factor: f64,
},
#[error(
"the plan's affected closure {planned:?} is not the closure {derived:?} derived from the raw node hierarchy"
)]
ClosureMismatch {
planned: Vec<usize>,
derived: Vec<usize>,
},
#[error(
"source node {source_node_index} has a different parent in the skeleton than in the raw hierarchy"
)]
ParentChainDisagreement {
source_node_index: usize,
},
#[error("two source nodes both normalized to bone {bone}")]
AmbiguousSourceNodeProjection {
bone: animsmith_core::BoneId,
},
#[error("source node hierarchy is unusable: {reason}")]
UnusableSourceHierarchy {
reason: &'static str,
},
#[error(
"artifact proof claim {claim:?} observed {observed}, tolerance {tolerance}{diagnostics}",
diagnostics = RawJsonDifferenceSuffix(.raw_json_differences.as_ref())
)]
ArtifactProofFailed {
claim: &'static str,
observed: f64,
tolerance: f64,
raw_json_differences: Option<GltfRawJsonDifferenceSummary>,
},
}
pub fn rewrite_linear_units(
source: &GltfScaleSource,
factor: f64,
) -> Result<GltfScaleArtifact, GltfScaleRewriteError> {
let operation = ScaleOperation::WholeDocumentLinearUnits { factor };
let facts = operation_capability_facts_for_source(source, operation)?;
let plan = plan_scale(&ScaleRequest {
operation,
document: source.document(),
capability: &facts,
})?;
rewrite_linear_units_plan(source, &plan)
}
pub fn rewrite_scale_plan(
source: &GltfScaleSource,
plan: &animsmith_core::scale::ScalePlan,
) -> Result<GltfScaleArtifact, GltfScaleRewriteError> {
operation_capability_facts_for_source(source, plan.operation())?;
match plan.operation() {
ScaleOperation::WholeDocumentLinearUnits { .. } => rewrite_linear_units_plan(source, plan),
ScaleOperation::RestBindUniformScale { .. } => {
rest_bind::rewrite_rest_bind_plan(source, plan)
}
_ => Err(plan::plan_mismatch("gltf_operation_plan_mismatch")),
}
}
fn rewrite_linear_units_plan(
source: &GltfScaleSource,
plan: &animsmith_core::scale::ScalePlan,
) -> Result<GltfScaleArtifact, GltfScaleRewriteError> {
let manifest = source.manifest();
let ScaleOperation::WholeDocumentLinearUnits { factor } = plan.operation() else {
return Err(plan::plan_mismatch("gltf_operation_plan_mismatch"));
};
let gltf_plan = plan::GltfScalePlan::new(source, plan)?;
let root = source
.raw_json()
.as_object()
.ok_or_else(|| LoadError::Malformed("top-level glTF JSON is not an object".into()))?;
if let Some(location) = rules::unhandled_length_fields(source.raw_json())
.into_iter()
.next()
{
return Err(GltfScaleRewriteError::UnhandledLengthField { location });
}
reject_out_of_contract_nodes(root)?;
let accessor_rules = rules::collect_accessor_rules(&gltf_plan, factor != 1.0)?;
let mut spans = Vec::with_capacity(accessor_rules.len());
for (&accessor_index, &rule) in &accessor_rules {
spans.push((
bytes::accessor_span(root, source.resolved_buffers(), accessor_index, rule)?,
rule,
));
}
reject_image_payload_overlap(root, manifest, &spans)?;
let mut buffers = source.resolved_buffers().to_vec();
let mut extrema: BTreeMap<usize, ComponentExtrema> = BTreeMap::new();
let mut modified: BTreeSet<usize> = BTreeSet::new();
for &(span, rule) in &spans {
extrema.insert(
span.accessor_index,
bytes::scale_span(&mut buffers, span, rule, factor)?,
);
modified.insert(span.buffer);
}
let mut json = source.raw_json().clone();
let mut rewritten_json_pointers = Vec::new();
for (pointer, rule) in rules::collect_json_rewrites(&gltf_plan, factor != 1.0)? {
rewrite_json_array(&mut json, &pointer, rule, factor)?;
rewritten_json_pointers.push(pointer);
}
for (&accessor_index, &rule) in &accessor_rules {
let observed = &extrema[&accessor_index];
rewritten_json_pointers.extend(rewrite_accessor_bounds(
&mut json,
accessor_index,
rule,
factor,
observed,
)?);
}
rewritten_json_pointers.sort();
let reencoded_buffers = modified
.iter()
.copied()
.filter(|&buffer_index| {
manifest.buffers.get(buffer_index).is_some_and(|buffer| {
buffer.source_kind == crate::capability::GltfBufferSourceKind::DataUri
})
})
.collect();
let out = container::assemble(manifest, &json, &buffers, &modified)?;
Ok(GltfScaleArtifact {
container: manifest.container,
bytes: out,
rewritten_accessors: accessor_rules.keys().copied().collect(),
rewritten_json_pointers,
reencoded_buffers,
affected_source_nodes: gltf_plan.affected_source_nodes(false),
affected_source_skins: (0..raw_array_len(root, "skins")).collect(),
declared_factor: factor,
operation: plan.operation(),
})
}
fn raw_array_len(root: &Map<String, Value>, key: &str) -> usize {
root.get(key).and_then(Value::as_array).map_or(0, Vec::len)
}
fn reject_out_of_contract_nodes(root: &Map<String, Value>) -> Result<(), GltfScaleRewriteError> {
let Some(nodes) = root.get("nodes").and_then(Value::as_array) else {
return Ok(());
};
let Some(fault) = node_transform_faults(nodes).into_iter().next() else {
return Ok(());
};
let location = fault.location();
Err(match fault {
NodeTransformFault::TrsBesideMatrix { .. } => {
GltfScaleRewriteError::ConflictingNodeTransform { location }
}
NodeTransformFault::ProjectiveMatrixEntry {
value, expected, ..
} => GltfScaleRewriteError::NonAffineNodeMatrix {
location,
value,
expected,
},
NodeTransformFault::UnreadableMatrixEntry { .. } => {
LoadError::Malformed(format!("{location} is not a number")).into()
}
})
}
fn reject_image_payload_overlap(
root: &Map<String, Value>,
manifest: &GltfCapabilityManifest,
spans: &[(AccessorSpan, AccessorRule)],
) -> Result<(), GltfScaleRewriteError> {
reject_image_payload_overlap_spans(root, manifest, spans.iter().map(|(span, _)| *span))
}
fn reject_image_payload_overlap_spans(
root: &Map<String, Value>,
manifest: &GltfCapabilityManifest,
spans: impl Iterator<Item = AccessorSpan> + Clone,
) -> Result<(), GltfScaleRewriteError> {
let Some(images) = root.get("images").and_then(Value::as_array) else {
return Ok(());
};
for (image_index, image) in images.iter().enumerate() {
let Some(view_index) = image
.get("bufferView")
.and_then(Value::as_u64)
.and_then(|index| usize::try_from(index).ok())
else {
continue;
};
let Some(view) = manifest.buffer_views.get(view_index) else {
continue;
};
let start = view.byte_offset as usize;
let end = start.saturating_add(view.byte_length as usize);
if start >= end {
continue;
}
for span in spans.clone() {
if span.buffer == view.buffer_index && start < span.end && span.start < end {
return Err(GltfScaleRewriteError::ImagePayloadOverlap {
location: format!("/images/{image_index}/bufferView"),
accessor_index: span.accessor_index,
});
}
}
}
Ok(())
}
fn rewrite_json_array(
json: &mut Value,
pointer: &str,
rule: JsonArrayRule,
factor: f64,
) -> Result<(), GltfScaleRewriteError> {
let target = json
.pointer_mut(pointer)
.and_then(Value::as_array_mut)
.filter(|values| values.len() == rule.expected_len())
.ok_or_else(|| {
LoadError::Malformed(format!(
"{pointer} is not an array of {} numbers",
rule.expected_len()
))
})?;
for (component, entry) in target.iter_mut().enumerate() {
if !rule.scales_component(component) {
continue;
}
let location = format!("{pointer}/{component}");
let before = entry
.as_f64()
.ok_or_else(|| LoadError::Malformed(format!("{location} is not a number")))?;
*entry = number(bytes::narrow(before * factor, &location)?, &location)?;
}
Ok(())
}
fn rewrite_accessor_bounds(
json: &mut Value,
accessor_index: usize,
rule: AccessorRule,
factor: f64,
observed: &ComponentExtrema,
) -> Result<Vec<String>, GltfScaleRewriteError> {
rewrite_accessor_bounds_with(
json,
accessor_index,
&|component| rule.scales_component(component),
Some(factor),
observed,
)
}
fn rewrite_accessor_bounds_with(
json: &mut Value,
accessor_index: usize,
scales_component: &dyn Fn(usize) -> bool,
factor: Option<f64>,
observed: &ComponentExtrema,
) -> Result<Vec<String>, GltfScaleRewriteError> {
let mut rewritten = Vec::new();
for (member, is_min) in [("min", true), ("max", false)] {
let pointer = format!("/accessors/{accessor_index}/{member}");
let Some(bounds) = json.pointer_mut(&pointer).and_then(Value::as_array_mut) else {
continue;
};
if bounds.len() != observed.min.len() {
return Err(LoadError::Malformed(format!(
"{pointer} declares {} entries but the accessor has {} components",
bounds.len(),
observed.min.len()
))
.into());
}
for (component, entry) in bounds.iter_mut().enumerate() {
if !scales_component(component) {
continue;
}
let location = format!("{pointer}/{component}");
let before = entry
.as_f64()
.ok_or_else(|| LoadError::Malformed(format!("{location} is not a number")))?;
let converted = match factor {
Some(factor) => bytes::narrow(before * factor, &location)?,
None if is_min => observed.min[component],
None => observed.max[component],
};
let reconciled = if is_min {
converted.min(observed.min[component])
} else {
converted.max(observed.max[component])
};
*entry = number(reconciled, &location)?;
}
rewritten.push(pointer);
}
Ok(rewritten)
}
fn number(value: f32, location: &str) -> Result<Value, GltfScaleRewriteError> {
value
.to_string()
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.ok_or_else(|| GltfScaleRewriteError::ValueNotRepresentable {
location: location.to_owned(),
value: f64::from(value),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::{
GltfAccessorCapability, GltfAttributeCapability, GltfBufferCapability,
GltfBufferSourceKind, GltfBufferViewCapability, GltfPrimitiveCapability,
GltfSkinCapability,
};
fn manifest() -> GltfCapabilityManifest {
GltfCapabilityManifest {
container: GltfContainerKind::Gltf,
buffers: vec![GltfBufferCapability {
buffer_index: 0,
source_kind: GltfBufferSourceKind::DataUri,
declared_byte_length: 36,
}],
buffer_views: vec![GltfBufferViewCapability {
buffer_view_index: 0,
buffer_index: 0,
byte_offset: 0,
byte_length: 36,
byte_stride: None,
}],
accessors: vec![GltfAccessorCapability {
accessor_index: 0,
buffer_view_index: Some(0),
byte_offset: 0,
component_type: 5126,
accessor_type: "VEC3".to_owned(),
count: 3,
normalized: false,
sparse: false,
}],
nodes: Vec::new(),
animation_channels: Vec::new(),
primitives: vec![GltfPrimitiveCapability {
mesh_index: 0,
primitive_index: 0,
mode: 4,
attributes: vec![GltfAttributeCapability {
semantic: "POSITION".to_owned(),
accessor_index: 0,
}],
morph_target_count: 0,
morph_position_accessors: Vec::new(),
unsupported_morph_locations: Vec::new(),
}],
morph_weight_locations: Vec::new(),
instancing: Vec::new(),
skins: Vec::new(),
camera_count: 0,
extensions: Vec::new(),
extension_locations: Vec::new(),
external_resource_locations: Vec::new(),
extras_locations: Vec::new(),
unknown_member_locations: Vec::new(),
}
}
#[test]
fn a_clean_manifest_projects_to_complete_supported_facts() {
let facts = capability_facts(&manifest());
assert_eq!(facts.coverage, ScaleCapabilityCoverage::Complete);
assert!(facts.is_supported());
}
#[test]
fn shared_extension_presence_preserves_manifest_semantic_classification() {
for (name, lights, instancing, unregistered) in [
("KHR_lights_punctual", true, false, false),
("EXT_mesh_gpu_instancing", false, true, false),
("ACME_opaque", false, false, true),
] {
let source = past_the_gate(
"shared-extension.gltf",
serde_json::json!({
"asset": { "version": "2.0" },
"extensionsUsed": [name]
}),
);
let facts = capability_facts_for_source(&source);
assert_eq!(facts.lights_present, lights, "{name}");
assert_eq!(facts.instancing_present, instancing, "{name}");
assert_eq!(
facts.unregistered_extensions_present, unregistered,
"{name}"
);
}
}
#[test]
fn an_external_buffer_makes_coverage_unavailable_as_well_as_unsupported() {
let mut manifest = manifest();
manifest.buffers[0].source_kind = GltfBufferSourceKind::External;
manifest.external_resource_locations = vec!["/buffers/0/uri".to_owned()];
let facts = capability_facts(&manifest);
assert_eq!(facts.coverage, ScaleCapabilityCoverage::Unavailable);
assert!(facts.external_resources_present);
assert!(!facts.is_supported());
}
#[test]
fn every_unsupported_domain_sets_exactly_its_own_flag() {
type Case = (
&'static str,
Box<dyn Fn(&mut GltfCapabilityManifest)>,
fn(&ScaleCapabilityFacts) -> bool,
);
let cases: Vec<Case> = vec![
(
"morph targets",
Box::new(|m| m.primitives[0].morph_target_count = 2),
|f| f.morphs_present,
),
("camera", Box::new(|m| m.camera_count = 1), |f| {
f.cameras_present
}),
(
"extension",
Box::new(|m| m.extensions = vec!["ACME_opaque".to_owned()]),
|f| f.unregistered_extensions_present,
),
(
"punctual light",
Box::new(|m| m.extensions = vec!["KHR_lights_punctual".to_owned()]),
|f| f.lights_present,
),
(
"extras",
Box::new(|m| m.extras_locations = vec!["/extras".to_owned()]),
|f| f.extras_present,
),
(
"unknown member",
Box::new(|m| m.unknown_member_locations = vec!["/nope".to_owned()]),
|f| f.unknown_source_members_present,
),
(
"non-triangle mode",
Box::new(|m| m.primitives[0].mode = 1),
|f| f.non_triangle_primitives_present,
),
(
"unmodeled attribute",
Box::new(|m| {
m.primitives[0].attributes.push(GltfAttributeCapability {
semantic: "TANGENT".to_owned(),
accessor_index: 0,
})
}),
|f| f.unsupported_vertex_attributes_present,
),
(
"secondary influences",
Box::new(|m| {
m.primitives[0].attributes.push(GltfAttributeCapability {
semantic: "JOINTS_1".to_owned(),
accessor_index: 0,
})
}),
|f| f.secondary_skin_influences_present,
),
(
"missing inverse binds",
Box::new(|m| {
m.skins = vec![GltfSkinCapability {
skin_index: 0,
joint_count: 1,
inverse_bind_accessor_index: None,
inverse_bind_count: None,
}]
}),
|f| f.inverse_bind_issues_present,
),
(
"interleaved POSITION",
Box::new(|m| m.buffer_views[0].byte_stride = Some(16)),
|f| f.unsafe_accessor_layout_present,
),
(
"normalized POSITION",
Box::new(|m| m.accessors[0].normalized = true),
|f| f.unsafe_accessor_layout_present,
),
(
"sparse POSITION",
Box::new(|m| m.accessors[0].sparse = true),
|f| f.unsafe_accessor_layout_present,
),
];
for (name, mutate, flag) in cases {
let mut manifest = manifest();
mutate(&mut manifest);
let facts = capability_facts(&manifest);
assert!(flag(&facts), "{name} did not set its capability flag");
assert!(!facts.is_supported(), "{name} was still reported supported");
}
}
const IDENTITY_MATRIX: [f64; 16] = [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
];
fn nodes_root(nodes: Value) -> Map<String, Value> {
serde_json::json!({ "nodes": nodes })
.as_object()
.expect("literal JSON object")
.clone()
}
#[test]
fn the_rewriter_guard_still_refuses_a_matrix_beside_a_trs_member() {
for (member, member_value) in [
("translation", serde_json::json!([1.5, -2.0, 0.25])),
("rotation", serde_json::json!([0.0, 0.0, 0.0, 1.0])),
("scale", serde_json::json!([2.0, 2.0, 2.0])),
] {
let mut node = serde_json::json!({ "matrix": Vec::from(IDENTITY_MATRIX) });
node[member] = member_value;
match reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node]))) {
Err(GltfScaleRewriteError::ConflictingNodeTransform { location }) => {
assert_eq!(location, format!("/nodes/0/{member}"));
}
other => panic!("matrix + {member} must be refused, got {other:?}"),
}
}
}
#[test]
fn the_rewriter_guard_still_refuses_a_projective_node_matrix() {
for (component, authored, expected) in [
(3usize, 0.5f64, 0.0f64),
(7, -1.0, 0.0),
(11, 2.0, 0.0),
(15, 2.0, 1.0),
] {
let mut matrix = IDENTITY_MATRIX;
matrix[component] = authored;
let node = serde_json::json!({ "matrix": Vec::from(matrix) });
match reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node]))) {
Err(GltfScaleRewriteError::NonAffineNodeMatrix {
location,
value,
expected: reported,
}) => {
assert_eq!(location, format!("/nodes/0/matrix/{component}"));
assert_eq!(value, authored);
assert_eq!(reported, expected);
}
other => panic!("matrix[{component}] = {authored} must be refused, got {other:?}"),
}
}
}
#[test]
fn the_rewriter_guard_accepts_an_affine_matrix_with_a_translation_column() {
let mut matrix = IDENTITY_MATRIX;
matrix[12] = 1.5;
matrix[13] = -2.0;
matrix[14] = 0.25;
let node = serde_json::json!({ "matrix": Vec::from(matrix) });
reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node])))
.expect("an affine matrix with a translation column is in contract");
reject_out_of_contract_nodes(&nodes_root(serde_json::json!([{
"translation": [1.5, -2.0, 0.25],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [2.0, 2.0, 2.0]
}])))
.expect("TRS without matrix declares no conflict");
}
#[test]
fn a_non_numeric_matrix_entry_stays_a_malformed_source_rather_than_a_contract_fault() {
let mut matrix: Vec<Value> = Vec::from(IDENTITY_MATRIX)
.into_iter()
.map(Value::from)
.collect();
matrix[15] = Value::from("1.0");
let node = serde_json::json!({ "matrix": matrix });
match reject_out_of_contract_nodes(&nodes_root(serde_json::json!([node]))) {
Err(GltfScaleRewriteError::Load(_)) => {}
other => panic!("a non-numeric matrix entry is malformed, got {other:?}"),
}
}
fn image_overlap(image: (u64, u64), span: (usize, usize)) -> Result<(), GltfScaleRewriteError> {
let root = serde_json::json!({ "images": [{ "bufferView": 2, "mimeType": "image/png" }] })
.as_object()
.expect("literal JSON object")
.clone();
let decoy = |buffer_view_index| GltfBufferViewCapability {
buffer_view_index,
buffer_index: 1,
byte_offset: 0,
byte_length: 4096,
byte_stride: None,
};
let mut manifest = manifest();
manifest.buffer_views = vec![
decoy(0),
decoy(1),
GltfBufferViewCapability {
buffer_view_index: 2,
buffer_index: 0,
byte_offset: image.0,
byte_length: image.1,
byte_stride: None,
},
];
let spans = vec![(
AccessorSpan {
accessor_index: 0,
buffer: 0,
start: span.0,
end: span.1,
components: 3,
},
AccessorRule::AllComponents,
)];
reject_image_payload_overlap(&root, &manifest, &spans)
}
#[test]
fn the_rewriter_guard_still_refuses_an_image_payload_over_a_converted_span() {
for (name, image, span) in [
(
"image runs one byte into the span",
(0u64, 13u64),
(12usize, 48usize),
),
("span runs one byte into the image", (35, 13), (0, 36)),
] {
match image_overlap(image, span) {
Err(GltfScaleRewriteError::ImagePayloadOverlap {
location,
accessor_index,
}) => {
assert_eq!(location, "/images/0/bufferView", "{name}");
assert_eq!(accessor_index, 0, "{name}");
}
other => panic!("{name}: expected ImagePayloadOverlap, got {other:?}"),
}
}
}
#[test]
fn the_rewriter_guard_accepts_an_image_payload_adjacent_to_a_converted_span() {
for (name, image, span) in [
(
"image ends where the span begins",
(0u64, 12u64),
(12usize, 48usize),
),
("image begins where the span ends", (36, 12), (0, 36)),
] {
image_overlap(image, span)
.unwrap_or_else(|error| panic!("{name}: adjacency is not an overlap: {error:?}"));
}
}
#[test]
fn an_empty_image_view_inside_a_converted_span_is_not_an_overlap() {
for (name, image, span) in [
(
"empty view inside the span",
(12u64, 0u64),
(0usize, 36usize),
),
("empty view at the span's start", (0, 0), (0, 36)),
("empty view at the span's end", (36, 0), (0, 36)),
] {
image_overlap(image, span)
.unwrap_or_else(|error| panic!("{name}: an empty view aliases nothing: {error:?}"));
}
}
fn past_the_gate(name: &str, value: Value) -> crate::GltfScaleSource {
let bytes = serde_json::to_vec(&value).expect("literal JSON serializes");
crate::capability::scale_source_past_the_gate(std::path::Path::new(name), &bytes)
.unwrap_or_else(|error| panic!("{name} must still load past the gate: {error:?}"))
}
fn image_and_position_document(image_offset: usize, image_length: usize) -> Value {
use base64::{Engine as _, engine::general_purpose::STANDARD};
serde_json::json!({
"asset": { "version": "2.0" },
"buffers": [{
"uri": format!(
"data:application/octet-stream;base64,{}",
STANDARD.encode([0u8; 96])
),
"byteLength": 96
}],
"bufferViews": [
{ "buffer": 0, "byteOffset": 48, "byteLength": 12 },
{ "buffer": 0, "byteOffset": 0, "byteLength": 36 },
{ "buffer": 0, "byteOffset": image_offset, "byteLength": image_length }
],
"accessors": [{
"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3",
"min": [0, 0, 0], "max": [0, 0, 0]
}],
"images": [{ "bufferView": 2, "mimeType": "image/png" }],
"meshes": [{ "primitives": [{ "attributes": { "POSITION": 0 } }] }]
})
}
#[test]
fn rewrite_linear_units_still_calls_the_node_transform_guard() {
let mut node = serde_json::json!({ "matrix": Vec::from(IDENTITY_MATRIX) });
node["translation"] = serde_json::json!([1.5, -2.0, 0.25]);
let source = past_the_gate(
"matrix-plus-trs.gltf",
serde_json::json!({ "asset": { "version": "2.0" }, "nodes": [node] }),
);
match rewrite_linear_units(&source, 4.0) {
Err(GltfScaleRewriteError::ConflictingNodeTransform { location }) => {
assert_eq!(location, "/nodes/0/translation");
}
other => panic!("the wired guard must refuse matrix + translation, got {other:?}"),
}
let mut matrix = IDENTITY_MATRIX;
matrix[15] = 2.0;
let source = past_the_gate(
"projective-matrix.gltf",
serde_json::json!({
"asset": { "version": "2.0" },
"nodes": [{ "matrix": Vec::from(matrix) }]
}),
);
match rewrite_linear_units(&source, 4.0) {
Err(GltfScaleRewriteError::NonAffineNodeMatrix {
location,
value,
expected,
}) => {
assert_eq!(location, "/nodes/0/matrix/15");
assert_eq!(value, 2.0);
assert_eq!(expected, 1.0);
}
other => panic!("the wired guard must refuse a projective matrix, got {other:?}"),
}
}
#[test]
fn rewrite_linear_units_still_calls_the_image_payload_guard() {
let source = past_the_gate(
"image-overlap.gltf",
image_and_position_document(12, 12),
);
match rewrite_linear_units(&source, 2.0) {
Err(GltfScaleRewriteError::ImagePayloadOverlap {
location,
accessor_index,
}) => {
assert_eq!(location, "/images/0/bufferView");
assert_eq!(accessor_index, 0);
}
other => panic!("the wired guard must refuse an aliased image, got {other:?}"),
}
}
#[test]
fn the_wired_image_guard_still_accepts_a_disjoint_image_view() {
let source = past_the_gate(
"image-disjoint.gltf",
image_and_position_document(36, 12),
);
rewrite_linear_units(&source, 2.0)
.expect("an image view disjoint from every converted span converts");
}
#[test]
fn an_animated_weights_channel_is_projected_as_morph_weights() {
use crate::capability::GltfAnimationChannelCapability;
let mut manifest = manifest();
manifest.animation_channels = vec![GltfAnimationChannelCapability {
animation_index: 0,
channel_index: 0,
target_node_index: 0,
target_path: "weights".to_owned(),
interpolation: "LINEAR".to_owned(),
input_accessor_index: 1,
output_accessor_index: 2,
}];
manifest.morph_weight_locations = vec!["/animations/0/channels/0/target/path".to_owned()];
let facts = capability_facts(&manifest);
assert!(facts.morph_weights_present);
assert!(!facts.is_supported());
}
#[test]
fn an_animated_matrix_node_is_rederived_from_manifest_identity() {
use crate::capability::{
GltfAnimationChannelCapability, GltfNodeCapability, GltfNodeRestKind,
};
let mut manifest = manifest();
manifest.nodes = vec![GltfNodeCapability {
node_index: 9,
rest_kind: GltfNodeRestKind::Matrix,
mesh_index: None,
skin_index: None,
}];
manifest.animation_channels = vec![GltfAnimationChannelCapability {
animation_index: 3,
channel_index: 4,
target_node_index: 9,
target_path: "scale".to_owned(),
interpolation: "STEP".to_owned(),
input_accessor_index: 5,
output_accessor_index: 6,
}];
assert_eq!(
manifest_violations(&manifest),
vec![GltfCapabilityViolation {
kind: GltfCapabilityViolationKind::AnimatedMatrixNode,
location: "/animations/3/channels/4/target".to_owned(),
}]
);
let facts = capability_facts(&manifest);
assert!(facts.unknown_source_members_present);
assert!(!facts.is_supported());
}
}