//! Offline Collada conversion, deterministic mesh assets, and opt-in uploads. // Owned DTO parameters and the public model shapes follow the fixed mapped API; // format bounds make the intentional numeric narrowing safe. #![allow(clippy::pedantic)] use std::collections::HashMap; use std::fs; use std::io::Write; use std::path::{Component, Path, PathBuf}; use flate2::{Compression, write::ZlibEncoder}; use libremetaverse_imaging::ITextureCodec; use libremetaverse_structured_data::{OSD, OSDFormat, OSDParser}; use libremetaverse_types::compat::{CancellationToken, Uri}; use libremetaverse_types::{AssetType, Color4, Error, Quaternion, UUID, Vector2, Vector3}; use crate::GridClient; use crate::rendering::Vertex; const MAX_COLLADA_BYTES: usize = 64 * 1024 * 1024; const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024; const MAX_VERTICES: usize = 1_000_000; const MAX_FACES: usize = 100_000; fn model_error(context: &'static str) -> Error { Error::Parse { position: 0, context, } } pub struct ModelMaterial { pub diffuse_color: Color4, pub id: String, pub texture: String, pub texture_data: Vec, } impl Clone for ModelMaterial { fn clone(&self) -> Self { Self { diffuse_color: self.diffuse_color, id: self.id.clone(), texture: self.texture.clone(), texture_data: self.texture_data.clone(), } } } impl std::fmt::Debug for ModelMaterial { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ModelMaterial") .field("id", &self.id) .field("texture", &self.texture) .field("texture_bytes", &self.texture_data.len()) .finish() } } impl ModelMaterial { pub fn new() -> Result { Ok(Self { diffuse_color: Color4::white(), id: String::new(), texture: String::new(), texture_data: Vec::new(), }) } } pub struct ModelFace { pub indices: Vec, pub material: ModelMaterial, pub material_id: String, pub vertices: Vec, } impl std::fmt::Debug for ModelFace { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ModelFace") .field("indices", &self.indices) .field("material", &self.material) .field("material_id", &self.material_id) .field("vertex_count", &self.vertices.len()) .finish() } } impl ModelFace { pub fn new() -> Result { Ok(Self { indices: Vec::new(), material: ModelMaterial::new()?, material_id: String::new(), vertices: Vec::new(), }) } pub fn add_vertex(&mut self, vertex: Vertex) -> Result<(), Error> { if self.indices.len() >= MAX_VERTICES * 3 { return Err(Error::Argument); } let index = self .vertices .iter() .position(|candidate| vertex_equal(candidate, &vertex)); let index = match index { Some(index) => index, None => { if self.vertices.len() >= MAX_VERTICES { return Err(Error::Argument); } self.vertices.push(vertex); self.vertices.len() - 1 } }; self.indices .push(u32::try_from(index).map_err(|_| Error::Argument)?); Ok(()) } } fn vertex_equal(left: &Vertex, right: &Vertex) -> bool { left.position.x.to_bits() == right.position.x.to_bits() && left.position.y.to_bits() == right.position.y.to_bits() && left.position.z.to_bits() == right.position.z.to_bits() && left.normal.x.to_bits() == right.normal.x.to_bits() && left.normal.y.to_bits() == right.normal.y.to_bits() && left.normal.z.to_bits() == right.normal.z.to_bits() && left.tex_coord.x.to_bits() == right.tex_coord.x.to_bits() && left.tex_coord.y.to_bits() == right.tex_coord.y.to_bits() } #[derive(Debug)] pub struct ModelPrim { pub asset: Vec, pub bound_max: Vector3, pub bound_min: Vector3, pub faces: Vec, pub id: String, pub position: Vector3, pub positions: Vec, pub rotation: Quaternion, pub scale: Vector3, } impl ModelPrim { pub fn new() -> Result { Ok(Self { asset: Vec::new(), bound_max: Vector3 { x: f32::MIN, y: f32::MIN, z: f32::MIN, }, bound_min: Vector3 { x: f32::MAX, y: f32::MAX, z: f32::MAX, }, faces: Vec::new(), id: String::new(), position: Vector3::zero(), positions: Vec::new(), rotation: Quaternion::identity(), scale: Vector3::one(), }) } pub fn physics_stub() -> Result { Ok(OSD::Map(HashMap::from([ ( "Max".to_owned(), OSD::from_vector3(Vector3 { x: 0.5, y: 0.5, z: 0.5, })?, ), ( "Min".to_owned(), OSD::from_vector3(Vector3 { x: -0.5, y: -0.5, z: -0.5, })?, ), ( "BoundingVerts".to_owned(), OSD::Binary(vec![ 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 255, 255, 255, 127, 0, 0, 255, 255, 255, 127, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 127, 255, 255, 255, 255, 255, 255, 0, 0, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 0, 0, 255, 255, ]), ), ]))) } pub fn create_asset(&mut self, creator: UUID) -> Result<(), Error> { if self.faces.len() > MAX_FACES { return Err(Error::Argument); } let mut face_values = Vec::with_capacity(self.faces.len()); for face in &self.faces { if face.vertices.len() > u16::MAX as usize || face.indices.iter().any(|index| *index > u16::MAX as u32) { return Err(Error::Argument); } let (uv_min, uv_max) = uv_bounds(&face.vertices); let mut positions = Vec::with_capacity(face.vertices.len() * 6); let mut normals = Vec::with_capacity(face.vertices.len() * 6); let mut tex_coords = Vec::with_capacity(face.vertices.len() * 4); for vertex in &face.vertices { for value in [vertex.position.x, vertex.position.y, vertex.position.z] { positions.extend_from_slice(&quantize(value, -0.5, 0.5).to_le_bytes()); } for value in [vertex.normal.x, vertex.normal.y, vertex.normal.z] { normals.extend_from_slice(&quantize(value, -1.0, 1.0).to_le_bytes()); } tex_coords.extend_from_slice( &quantize(vertex.tex_coord.x, uv_min.x, uv_max.x).to_le_bytes(), ); tex_coords.extend_from_slice( &quantize(vertex.tex_coord.y, uv_min.y, uv_max.y).to_le_bytes(), ); } let mut triangles = Vec::with_capacity(face.indices.len() * 2); for index in &face.indices { triangles.extend_from_slice(&(*index as u16).to_le_bytes()); } face_values.push(OSD::Map(HashMap::from([ ( "TexCoord0Domain".to_owned(), OSD::Map(HashMap::from([ ("Min".to_owned(), OSD::from_vector2(uv_min)?), ("Max".to_owned(), OSD::from_vector2(uv_max)?), ])), ), ( "PositionDomain".to_owned(), OSD::Map(HashMap::from([ ( "Min".to_owned(), OSD::from_vector3(Vector3 { x: -0.5, y: -0.5, z: -0.5, })?, ), ( "Max".to_owned(), OSD::from_vector3(Vector3 { x: 0.5, y: 0.5, z: 0.5, })?, ), ])), ), ("Position".to_owned(), OSD::Binary(positions)), ("Normal".to_owned(), OSD::Binary(normals)), ("TexCoord0".to_owned(), OSD::Binary(tex_coords)), ("TriangleList".to_owned(), OSD::Binary(triangles)), ]))); } let mesh = compress_osd(OSD::Array(face_values))?; let physics = compress_osd(Self::physics_stub()?)?; let header = OSD::Map(HashMap::from([ ("version".to_owned(), OSD::Integer(1)), ("creator".to_owned(), OSD::UUID(creator)), ("date".to_owned(), OSD::Date(std::time::UNIX_EPOCH)), ( "high_lod".to_owned(), OSD::Map(HashMap::from([ ("offset".to_owned(), OSD::Integer(0)), ( "size".to_owned(), OSD::Integer(i32::try_from(mesh.len()).map_err(|_| Error::Argument)?), ), ])), ), ( "physics_convex".to_owned(), OSD::Map(HashMap::from([ ( "offset".to_owned(), OSD::Integer(i32::try_from(mesh.len()).map_err(|_| Error::Argument)?), ), ( "size".to_owned(), OSD::Integer(i32::try_from(physics.len()).map_err(|_| Error::Argument)?), ), ])), ), ])); let header = OSDParser::serialize_llsd_binary_with_osd_boolean(header, false)?; let total = header .len() .checked_add(mesh.len()) .and_then(|n| n.checked_add(physics.len())) .ok_or(Error::Argument)?; if total > MAX_COLLADA_BYTES { return Err(Error::Argument); } self.asset = Vec::with_capacity(total); self.asset.extend_from_slice(&header); self.asset.extend_from_slice(&mesh); self.asset.extend_from_slice(&physics); Ok(()) } } fn uv_bounds(vertices: &[Vertex]) -> (Vector2, Vector2) { if vertices.is_empty() { return (Vector2::zero(), Vector2::zero()); } let mut min = Vector2 { x: f32::MAX, y: f32::MAX, }; let mut max = Vector2 { x: f32::MIN, y: f32::MIN, }; for vertex in vertices { min.x = min.x.min(vertex.tex_coord.x); min.y = min.y.min(vertex.tex_coord.y); max.x = max.x.max(vertex.tex_coord.x); max.y = max.y.max(vertex.tex_coord.y); } (min, max) } fn quantize(value: f32, min: f32, max: f32) -> u16 { if !value.is_finite() || !min.is_finite() || !max.is_finite() || max <= min { 0 } else { (((value - min) / (max - min)).clamp(0.0, 1.0) * 65535.0).round() as u16 } } fn compress_osd(value: OSD) -> Result, Error> { let bytes = OSDParser::serialize_llsd_binary_with_osd_boolean(value, false)?; let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); encoder .write_all(&bytes) .map_err(|_| Error::InvalidOperation)?; encoder.finish().map_err(|_| Error::InvalidOperation) } pub struct ColladaLoader { texture_codec: Option>, } impl std::fmt::Debug for ColladaLoader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ColladaLoader") .field("has_texture_codec", &self.texture_codec.is_some()) .finish() } } impl ColladaLoader { pub fn new(texture_codec: Option>) -> Result { Ok(Self { texture_codec }) } pub fn load(&self, filename: String, load_images: bool) -> Result, Error> { let path = PathBuf::from(filename); let metadata = fs::metadata(&path).map_err(|_| Error::InvalidOperation)?; if metadata.len() as usize > MAX_COLLADA_BYTES { return Err(Error::Argument); } let xml = fs::read_to_string(&path).map_err(|_| model_error("Collada UTF-8"))?; let document = roxmltree::Document::parse(&xml).map_err(|_| model_error("Collada XML"))?; if document.descendants().count() > 1_000_000 { return Err(Error::Argument); } let root = path .parent() .unwrap_or_else(|| Path::new(".")) .canonicalize() .map_err(|_| Error::InvalidOperation)?; let materials = parse_materials(&document, &root, load_images, self.texture_codec.as_deref())?; let material_bindings: HashMap<_, _> = document .descendants() .filter(|node| node.has_tag_name("instance_material")) .filter_map(|node| { Some(( node.attribute("symbol")?.to_owned(), strip_hash(node.attribute("target")?).to_owned(), )) }) .collect(); let transform = AssetTransform::from_document(&document)?; let mut prims = Vec::new(); for geometry in document .descendants() .filter(|node| node.has_tag_name("geometry")) { if prims.len() >= MAX_FACES { return Err(Error::Argument); } let Some(mesh) = geometry.children().find(|node| node.has_tag_name("mesh")) else { continue; }; let mut sources = HashMap::new(); for source in mesh.children().filter(|node| node.has_tag_name("source")) { if let Some(id) = source.attribute("id") { sources.insert(id.to_owned(), parse_source(source)?); } } let mut vertices_sources = HashMap::new(); for vertices in mesh.children().filter(|node| node.has_tag_name("vertices")) { if let Some(id) = vertices.attribute("id") && let Some(input) = vertices.children().find(|n| { n.has_tag_name("input") && n.attribute("semantic") == Some("POSITION") }) { vertices_sources.insert( id.to_owned(), strip_hash(input.attribute("source").unwrap_or_default()).to_owned(), ); } } let Some(position_source) = vertices_sources .values() .next() .and_then(|id| sources.get(id)) else { return Err(model_error("Collada position source")); }; let mut prim = ModelPrim::new()?; prim.id = geometry.attribute("id").unwrap_or_default().to_owned(); prim.positions = position_source.vectors3()?; for position in &mut prim.positions { *position = transform.position(*position); } normalize_positions(&mut prim)?; for primitive in mesh .children() .filter(|n| n.has_tag_name("triangles") || n.has_tag_name("polylist")) { prim.faces.push(parse_faces( primitive, &sources, &vertices_sources, &prim.positions, primitive .attribute("material") .and_then(|symbol| { material_bindings .get(symbol) .and_then(|target| materials.get(target)) .or_else(|| materials.get(symbol)) }) .cloned(), transform, )?); } if prim.faces.is_empty() { continue; } prim.create_asset(UUID::zero())?; prims.push(prim); } Ok(prims) } } #[derive(Clone, Copy)] enum UpAxis { X, Y, Z, } #[derive(Clone, Copy)] struct AssetTransform { unit: f32, up: UpAxis, } impl AssetTransform { fn from_document(document: &roxmltree::Document<'_>) -> Result { let asset = document .descendants() .find(|node| node.has_tag_name("asset")); let unit = asset .and_then(|node| node.descendants().find(|child| child.has_tag_name("unit"))) .and_then(|node| node.attribute("meter")) .map(str::parse::) .transpose() .map_err(|_| model_error("Collada unit"))? .unwrap_or(1.0); if !unit.is_finite() || unit <= 0.0 || unit > 1_000_000.0 { return Err(model_error("Collada unit bounds")); } let up = match asset .and_then(|node| { node.descendants() .find(|child| child.has_tag_name("up_axis")) }) .and_then(|node| node.text()) .map(str::trim) .unwrap_or("Y_UP") { "X_UP" => UpAxis::X, "Y_UP" => UpAxis::Y, "Z_UP" => UpAxis::Z, _ => return Err(model_error("Collada up axis")), }; Ok(Self { unit, up }) } fn axis(self, value: Vector3) -> Vector3 { match self.up { UpAxis::X => Vector3 { x: -value.z, y: value.y, z: value.x, }, UpAxis::Y => Vector3 { x: value.x, y: -value.z, z: value.y, }, UpAxis::Z => value, } } fn position(self, value: Vector3) -> Vector3 { let value = self.axis(value); Vector3 { x: value.x * self.unit, y: value.y * self.unit, z: value.z * self.unit, } } } #[derive(Debug)] struct Source { values: Vec, stride: usize, } impl Source { fn vectors3(&self) -> Result, Error> { if self.stride < 3 { return Err(model_error("Collada VEC3 stride")); } self.values .chunks(self.stride) .map(|v| { if v.len() >= 3 { Ok(Vector3 { x: v[0], y: v[1], z: v[2], }) } else { Err(model_error("Collada VEC3 data")) } }) .collect() } } fn parse_source(node: roxmltree::Node<'_, '_>) -> Result { let array = node .descendants() .find(|n| n.has_tag_name("float_array")) .ok_or_else(|| model_error("Collada float array"))?; let values = parse_floats(array.text().unwrap_or_default())?; if values.len() > MAX_VERTICES * 4 { return Err(Error::Argument); } let stride = node .descendants() .find(|n| n.has_tag_name("accessor")) .and_then(|n| n.attribute("stride")) .and_then(|v| v.parse().ok()) .unwrap_or(1); if stride == 0 || stride > 16 || values.len() % stride != 0 { return Err(model_error("Collada source stride")); } Ok(Source { values, stride }) } fn parse_floats(text: &str) -> Result, Error> { text.split_whitespace() .map(|v| { v.parse::() .ok() .filter(|n| n.is_finite()) .ok_or_else(|| model_error("Collada finite float")) }) .collect() } fn parse_ints(text: &str) -> Result, Error> { text.split_whitespace() .map(|v| v.parse::().map_err(|_| model_error("Collada index"))) .collect() } fn strip_hash(value: &str) -> &str { value.strip_prefix('#').unwrap_or(value) } fn normalize_positions(prim: &mut ModelPrim) -> Result<(), Error> { if prim.positions.is_empty() || prim.positions.len() > MAX_VERTICES { return Err(model_error("Collada positions")); } for p in &prim.positions { prim.bound_min.x = prim.bound_min.x.min(p.x); prim.bound_min.y = prim.bound_min.y.min(p.y); prim.bound_min.z = prim.bound_min.z.min(p.z); prim.bound_max.x = prim.bound_max.x.max(p.x); prim.bound_max.y = prim.bound_max.y.max(p.y); prim.bound_max.z = prim.bound_max.z.max(p.z); } let scale = Vector3 { x: prim.bound_max.x - prim.bound_min.x, y: prim.bound_max.y - prim.bound_min.y, z: prim.bound_max.z - prim.bound_min.z, }; prim.scale = scale; prim.position = Vector3 { x: prim.bound_min.x + scale.x / 2.0, y: prim.bound_min.y + scale.y / 2.0, z: prim.bound_min.z + scale.z / 2.0, }; for p in &mut prim.positions { p.x = if scale.x == 0.0 { 0.0 } else { (p.x - prim.bound_min.x) / scale.x - 0.5 }; p.y = if scale.y == 0.0 { 0.0 } else { (p.y - prim.bound_min.y) / scale.y - 0.5 }; p.z = if scale.z == 0.0 { 0.0 } else { (p.z - prim.bound_min.z) / scale.z - 0.5 }; } Ok(()) } fn parse_faces( node: roxmltree::Node<'_, '_>, sources: &HashMap, vertices_sources: &HashMap, positions: &[Vector3], material: Option, transform: AssetTransform, ) -> Result { let mut inputs = Vec::new(); let mut stride = 0usize; for input in node.children().filter(|n| n.has_tag_name("input")) { let offset = input .attribute("offset") .unwrap_or("0") .parse::() .map_err(|_| model_error("Collada input offset"))?; stride = stride.max(offset + 1); inputs.push(( input.attribute("semantic").unwrap_or_default(), offset, strip_hash(input.attribute("source").unwrap_or_default()), )); } if stride == 0 || stride > 32 { return Err(model_error("Collada primitive stride")); } let indices = parse_ints( node.children() .find(|n| n.has_tag_name("p")) .and_then(|n| n.text()) .unwrap_or_default(), )?; let counts = if node.has_tag_name("triangles") { vec![ 3usize; node.attribute("count") .and_then(|v| v.parse().ok()) .unwrap_or(indices.len() / (stride * 3)) ] } else { parse_ints( node.children() .find(|n| n.has_tag_name("vcount")) .and_then(|n| n.text()) .unwrap_or_default(), )? }; let mut face = ModelFace::new()?; face.material_id = node.attribute("material").unwrap_or_default().to_owned(); if let Some(material) = material { face.material = material } let mut cursor = 0usize; for count in counts { if !(3..=4).contains(&count) { return Err(model_error("Collada supports triangles and quads")); } let mut polygon = Vec::with_capacity(count); for vertex in 0..count { let base = cursor.checked_add(vertex * stride).ok_or(Error::Argument)?; let mut out = Vertex { position: Vector3::zero(), normal: Vector3::zero(), tex_coord: Vector2::zero(), }; for (semantic, offset, source_id) in &inputs { let index = *indices .get(base + offset) .ok_or_else(|| model_error("Collada index bounds"))?; match *semantic { "VERTEX" => { let position_id = vertices_sources .get(*source_id) .ok_or_else(|| model_error("Collada vertices source"))?; let _ = position_id; out.position = *positions .get(index) .ok_or_else(|| model_error("Collada position index"))? } "NORMAL" => { let source = sources .get(*source_id) .ok_or_else(|| model_error("Collada normal source"))?; let at = index * source.stride; if at + 2 >= source.values.len() { return Err(model_error("Collada normal index")); } out.normal = transform.axis(Vector3 { x: source.values[at], y: source.values[at + 1], z: source.values[at + 2], }) } "TEXCOORD" => { let source = sources .get(*source_id) .ok_or_else(|| model_error("Collada UV source"))?; let at = index * source.stride; if at + 1 >= source.values.len() { return Err(model_error("Collada UV index")); } out.tex_coord = Vector2 { x: source.values[at], y: source.values[at + 1], } } _ => {} } } polygon.push(out); } cursor = cursor.checked_add(count * stride).ok_or(Error::Argument)?; let order: &[usize] = if count == 3 { &[0, 1, 2] } else { &[0, 1, 2, 0, 2, 3] }; for index in order { let vertex = polygon .get(*index) .ok_or_else(|| model_error("Collada polygon index"))?; face.add_vertex(Vertex { position: vertex.position, normal: vertex.normal, tex_coord: vertex.tex_coord, })?; } } Ok(face) } fn parse_materials( document: &roxmltree::Document<'_>, root: &Path, load_images: bool, codec: Option<&dyn ITextureCodec>, ) -> Result, Error> { let mut images = HashMap::new(); for image in document.descendants().filter(|n| n.has_tag_name("image")) { if let (Some(id), Some(init)) = ( image.attribute("id"), image .descendants() .find(|n| n.has_tag_name("init_from")) .and_then(|n| n.text()), ) { images.insert(id.to_owned(), init.to_owned()); } } let mut effects = HashMap::new(); for effect in document.descendants().filter(|n| n.has_tag_name("effect")) { let Some(effect_id) = effect.attribute("id") else { continue; }; let mut value = ModelMaterial::new()?; value.id = effect_id.to_owned(); let diffuse = effect .descendants() .find(|node| node.has_tag_name("diffuse")); if let Some(color) = diffuse .and_then(|node| node.descendants().find(|child| child.has_tag_name("color"))) .and_then(|node| node.text()) { apply_diffuse_color(&mut value, color)?; } if let Some(sampler) = diffuse .and_then(|node| { node.descendants() .find(|child| child.has_tag_name("texture")) }) .and_then(|node| node.attribute("texture")) { let surface = effect .descendants() .find(|node| { node.has_tag_name("newparam") && node.attribute("sid") == Some(sampler) }) .and_then(|node| { node.descendants() .find(|child| child.has_tag_name("sampler2D")) }) .and_then(|node| { node.descendants() .find(|child| child.has_tag_name("source")) }) .and_then(|node| node.text()) .unwrap_or(sampler); let image_id = effect .descendants() .find(|node| { node.has_tag_name("newparam") && node.attribute("sid") == Some(surface) }) .and_then(|node| { node.descendants() .find(|child| child.has_tag_name("surface")) }) .and_then(|node| { node.descendants() .find(|child| child.has_tag_name("init_from")) }) .and_then(|node| node.text()) .unwrap_or(surface); if let Some(texture) = images.get(image_id) { apply_material_texture(&mut value, texture, root, load_images, codec)?; } } effects.insert(effect_id.to_owned(), value); } let mut result = HashMap::new(); for material in document .descendants() .filter(|n| n.has_tag_name("material")) { let id = material.attribute("id").unwrap_or_default().to_owned(); let effect_id = material .children() .find(|node| node.has_tag_name("instance_effect")) .and_then(|node| node.attribute("url")) .map(strip_hash); let mut value = effect_id .and_then(|effect_id| effects.get(effect_id)) .cloned() .unwrap_or(ModelMaterial::new()?); value.id = id.clone(); if let Some(color) = material .descendants() .find(|n| n.has_tag_name("diffuse")) .and_then(|n| n.descendants().find(|c| c.has_tag_name("color"))) .and_then(|n| n.text()) { apply_diffuse_color(&mut value, color)?; } if let Some(texture) = material .descendants() .find(|n| n.has_tag_name("texture")) .and_then(|n| n.attribute("texture")) .and_then(|id| images.get(id)) { apply_material_texture(&mut value, texture, root, load_images, codec)?; } if let Some(name) = material.attribute("name") { result.insert(name.to_owned(), value.clone()); } result.insert(id, value); } Ok(result) } fn apply_diffuse_color(material: &mut ModelMaterial, color: &str) -> Result<(), Error> { let values = parse_floats(color)?; if values.len() < 3 { return Err(model_error("Collada diffuse color")); } material.diffuse_color = Color4 { r: values[0].clamp(0.0, 1.0), g: values[1].clamp(0.0, 1.0), b: values[2].clamp(0.0, 1.0), a: values.get(3).copied().unwrap_or(1.0).clamp(0.0, 1.0), }; Ok(()) } fn apply_material_texture( material: &mut ModelMaterial, texture: &str, root: &Path, load_images: bool, codec: Option<&dyn ITextureCodec>, ) -> Result<(), Error> { material.texture = texture.to_owned(); if load_images { material.texture_data = load_texture(root, texture, codec)?; } Ok(()) } fn load_texture( root: &Path, relative: &str, codec: Option<&dyn ITextureCodec>, ) -> Result, Error> { if relative.len() > 4096 || relative.contains('\\') { return Err(model_error("Collada texture path")); } for component in Path::new(relative).components() { if !matches!(component, Component::Normal(_) | Component::CurDir) { return Err(model_error("Collada texture traversal")); } } let path = root .join(relative) .canonicalize() .map_err(|_| Error::InvalidOperation)?; if !path.starts_with(root) { return Err(model_error("Collada texture extraction root")); } let bytes = fs::read(&path).map_err(|_| Error::InvalidOperation)?; if bytes.len() > MAX_IMAGE_BYTES { return Err(Error::Argument); } let extension = path .extension() .and_then(|v| v.to_str()) .unwrap_or_default() .to_ascii_lowercase(); if extension != "jp2" && extension != "j2c" { let Some(codec) = codec else { return Err(Error::InvalidOperation); }; let image = codec.decode(Box::new(std::io::Cursor::new(bytes)))?; #[cfg(feature = "jpeg2000")] { return libremetaverse_imaging::J2kCodec::encode( &image, libremetaverse_imaging::J2kEncodeOptions::default(), ); } #[cfg(not(feature = "jpeg2000"))] { let _ = image; return Err(Error::InvalidOperation); } } Ok(bytes) } pub struct ModelUploader { pub include_physics_stub: bool, pub use_model_as_physics: bool, client: GridClient, prims: Vec, name: String, description: String, live_uploads: bool, } impl std::fmt::Debug for ModelUploader { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ModelUploader") .field("prim_count", &self.prims.len()) .field("name", &self.name) .field("live_uploads", &self.live_uploads) .finish() } } impl ModelUploader { pub fn new( client: GridClient, prims: Vec, new_inv_name: String, new_inv_desc: String, ) -> Result { if prims.is_empty() || prims.len() > 10_000 || new_inv_name.len() > 255 || new_inv_desc.len() > 1024 { return Err(Error::Argument); } Ok(Self { include_physics_stub: true, use_model_as_physics: false, client, prims, name: new_inv_name, description: new_inv_desc, live_uploads: false, }) } pub fn enable_live_uploads(&mut self) { self.live_uploads = true; } pub fn asset_resources(&self, upload: bool) -> Result { let mut images = Vec::>::new(); let mut image_index = HashMap::::new(); let mut meshes = Vec::new(); let mut instances = Vec::new(); for prim in &self.prims { if prim.asset.is_empty() { return Err(Error::InvalidOperation); } let mut faces = Vec::new(); for face in &prim.faces { let mut map = HashMap::from([ ( "diffuse_color".into(), OSD::from_color4(face.material.diffuse_color)?, ), ("fullbright".into(), OSD::Boolean(false)), ]); if !face.material.texture_data.is_empty() { let index = if let Some(index) = image_index.get(&face.material.texture) { *index } else { let index = i32::try_from(images.len()).map_err(|_| Error::Argument)?; image_index.insert(face.material.texture.clone(), index); images.push(face.material.texture_data.clone()); index }; map.insert("image".into(), OSD::Integer(index)); map.insert("scales".into(), OSD::Real(1.0)); map.insert("scalet".into(), OSD::Real(1.0)); map.insert("offsets".into(), OSD::Real(0.0)); map.insert("offsett".into(), OSD::Real(0.0)); map.insert("imagerot".into(), OSD::Real(0.0)); } faces.push(OSD::Map(map)); } instances.push(OSD::Map(HashMap::from([ ("face_list".into(), OSD::Array(faces)), ("position".into(), OSD::from_vector3(prim.position)?), ("rotation".into(), OSD::from_quaternion(prim.rotation)?), ("scale".into(), OSD::from_vector3(prim.scale)?), ("material".into(), OSD::Integer(3)), ("physics_shape_type".into(), OSD::Integer(2)), ( "mesh".into(), OSD::Integer(i32::try_from(meshes.len()).map_err(|_| Error::Argument)?), ), ]))); meshes.push(OSD::Binary(prim.asset.clone())); } Ok(OSD::Map(HashMap::from([ ("instance_list".into(), OSD::Array(instances)), ("mesh_list".into(), OSD::Array(meshes)), ( "texture_list".into(), OSD::Array( images .into_iter() .map(|data| OSD::Binary(if upload { data } else { Vec::new() })) .collect(), ), ), ("metric".into(), OSD::String("MUT_Unspecified".into())), ]))) } pub async fn prepare_upload( &self, cancellation_token: CancellationToken, ) -> Result, Error> { cancellation_token.throw_if_cancellation_requested()?; if !self.live_uploads { return Err(Error::InvalidOperation); } let network = self.client.native_network()?; let Some(sim) = network.native_current_sim() else { return Ok(None); }; let Some(caps) = sim.caps else { return Ok(None); }; let Some(uri) = caps.capability_uri("NewFileAgentInventory".into())? else { return Ok(None); }; let inventory = self.client.native_inventory()?; let permission_mask = i32::try_from(crate::PermissionMask::ALL.0).map_err(|_| Error::InvalidOperation)?; let payload = OSD::Map(HashMap::from([ ("name".into(), OSD::String(self.name.clone())), ("description".into(), OSD::String(self.description.clone())), ("asset_resources".into(), self.asset_resources(false)?), ("asset_type".into(), OSD::String("mesh".into())), ("inventory_type".into(), OSD::String("object".into())), ( "folder_id".into(), OSD::UUID(inventory.native_find_folder_for_asset_type(AssetType::Object)), ), ( "texture_folder_id".into(), OSD::UUID(inventory.native_find_folder_for_asset_type(AssetType::Texture)), ), ("everyone_mask".into(), OSD::Integer(permission_mask)), ("group_mask".into(), OSD::Integer(permission_mask)), ("next_owner_mask".into(), OSD::Integer(permission_mask)), ( "expected_upload_cost".into(), OSD::Integer(self.client.settings_ref().upload_cost()), ), ])); let (_, bytes) = self .client .native_http_caps_client() .post_with_uri_osd_format_osd_cancellation_token_i_progress( uri, OSDFormat::Xml, payload, cancellation_token, None, ) .await?; let response = OSDParser::deserialize_with_bytes(bytes)?; match &response { OSD::Map(map) if map .get("state") .and_then(|state| state.as_string().ok()) .as_deref() == Some("upload") => { Ok(Some(response)) } _ => Ok(None), } } pub async fn perform_upload( &self, uploader: Uri, cancellation_token: CancellationToken, ) -> Result, Error> { cancellation_token.throw_if_cancellation_requested()?; if !self.live_uploads { return Err(Error::InvalidOperation); } let (_, bytes) = self .client .native_http_caps_client() .post_with_uri_osd_format_osd_cancellation_token_i_progress( uploader, OSDFormat::Xml, self.asset_resources(true)?, cancellation_token, None, ) .await?; Ok(Some(OSDParser::deserialize_with_bytes(bytes)?)) } pub async fn upload( &self, cancellation_token: CancellationToken, ) -> Result, Error> { let Some(prepared) = self.prepare_upload(cancellation_token.clone()).await? else { return Ok(None); }; let uploader = match &prepared { OSD::Map(map) => map .get("uploader") .and_then(|value| value.as_uri().ok()) .flatten(), _ => None, }; let Some(uploader) = uploader else { return Ok(None); }; self.perform_upload(uploader, cancellation_token).await } }