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
1195 lines
41 KiB
Rust
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
|
|
}
|
|
}
|