Files
MetaCrate/crates/libremetaverse/src/model_workflow.rs
Chili Palmer 55424dbd7f
Some checks failed
Native code generation / deterministic (push) Failing after 1m48s
Imaging and meshing gate / native (push) Successful in 5m26s
JPEG 2000 feature / linux (push) Successful in 2m51s
Native Rust workspace compile / compile (push) Successful in 5m28s
Skia feature / linux (push) Successful in 31m53s
Implement native archive and model workflows (#74)
2026-08-10 20:31:34 +00:00

1195 lines
41 KiB
Rust

//! 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<u8>,
}
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<Self, Error> {
Ok(Self {
diffuse_color: Color4::white(),
id: String::new(),
texture: String::new(),
texture_data: Vec::new(),
})
}
}
pub struct ModelFace {
pub indices: Vec<u32>,
pub material: ModelMaterial,
pub material_id: String,
pub vertices: Vec<Vertex>,
}
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<Self, Error> {
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<u8>,
pub bound_max: Vector3,
pub bound_min: Vector3,
pub faces: Vec<ModelFace>,
pub id: String,
pub position: Vector3,
pub positions: Vec<Vector3>,
pub rotation: Quaternion,
pub scale: Vector3,
}
impl ModelPrim {
pub fn new() -> Result<Self, Error> {
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<OSD, Error> {
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<Vec<u8>, 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<Box<dyn ITextureCodec>>,
}
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<Box<dyn ITextureCodec>>) -> Result<Self, Error> {
Ok(Self { texture_codec })
}
pub fn load(&self, filename: String, load_images: bool) -> Result<Vec<ModelPrim>, 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<Self, Error> {
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::<f32>)
.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<f32>,
stride: usize,
}
impl Source {
fn vectors3(&self) -> Result<Vec<Vector3>, 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<Source, Error> {
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<Vec<f32>, Error> {
text.split_whitespace()
.map(|v| {
v.parse::<f32>()
.ok()
.filter(|n| n.is_finite())
.ok_or_else(|| model_error("Collada finite float"))
})
.collect()
}
fn parse_ints(text: &str) -> Result<Vec<usize>, Error> {
text.split_whitespace()
.map(|v| v.parse::<usize>().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<String, Source>,
vertices_sources: &HashMap<String, String>,
positions: &[Vector3],
material: Option<ModelMaterial>,
transform: AssetTransform,
) -> Result<ModelFace, Error> {
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::<usize>()
.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<HashMap<String, ModelMaterial>, 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<Vec<u8>, 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<ModelPrim>,
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<ModelPrim>,
new_inv_name: String,
new_inv_desc: String,
) -> Result<Self, Error> {
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<OSD, Error> {
let mut images = Vec::<Vec<u8>>::new();
let mut image_index = HashMap::<String, i32>::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<Option<OSD>, 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<Option<OSD>, 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<Option<OSD>, 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
}
}