515 lines
19 KiB
Python
515 lines
19 KiB
Python
"""
|
|
http://wiki.secondlife.com/wiki/Mesh/Mesh_Asset_Format
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import datetime as dt
|
|
import logging
|
|
from typing import *
|
|
import zlib
|
|
from copy import deepcopy
|
|
|
|
import numpy as np
|
|
import recordclass
|
|
|
|
from hippolyzer.lib.base import serialization as se
|
|
from hippolyzer.lib.base.datatypes import Vector3, Vector2, UUID, TupleCoord
|
|
from hippolyzer.lib.base.llsd import zip_llsd, unzip_llsd
|
|
from hippolyzer.lib.base.serialization import ParseContext
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
def llsd_to_mat4(mat: Union[np.ndarray, Sequence[float]]) -> np.ndarray:
|
|
return np.array(mat).reshape((4, 4), order='F')
|
|
|
|
|
|
def mat4_to_llsd(mat: np.ndarray) -> List[float]:
|
|
return list(mat.flatten(order='F'))
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class MeshAsset:
|
|
header: MeshHeaderDict = dataclasses.field(default_factory=dict)
|
|
segments: MeshSegmentDict = dataclasses.field(default_factory=dict)
|
|
raw_segments: Dict[str, bytes] = dataclasses.field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def make_triangle(cls) -> MeshAsset:
|
|
"""Make an asset representing an un-rigged single-sided mesh triangle"""
|
|
inst = cls()
|
|
inst.header = {
|
|
"version": 1,
|
|
"high_lod": {"offset": 0, "size": 0},
|
|
"physics_mesh": {"offset": 0, "size": 0},
|
|
"physics_convex": {"offset": 0, "size": 0},
|
|
}
|
|
base_lod: LODSegmentDict = {
|
|
'Normal': [
|
|
Vector3(-0.0, -0.0, -1.0),
|
|
Vector3(-0.0, -0.0, -1.0),
|
|
Vector3(-0.0, -0.0, -1.0)
|
|
],
|
|
'PositionDomain': {'Max': [0.5, 0.5, 0.0], 'Min': [-0.5, -0.5, 0.0]},
|
|
'Position': [
|
|
Vector3(0.0, 0.0, 0.0),
|
|
Vector3(1.0, 0.0, 0.0),
|
|
Vector3(0.5, 1.0, 0.0)
|
|
],
|
|
'TexCoord0Domain': {'Max': [1.0, 1.0], 'Min': [0.0, 0.0]},
|
|
'TexCoord0': [
|
|
Vector2(0.0, 0.0),
|
|
Vector2(1.0, 0.0),
|
|
Vector2(0.5, 1.0)
|
|
],
|
|
'TriangleList': [[0, 1, 2]],
|
|
}
|
|
inst.segments['physics_mesh'] = [deepcopy(base_lod)]
|
|
inst.segments['high_lod'] = [deepcopy(base_lod)]
|
|
convex_segment: PhysicsConvexSegmentDict = {
|
|
'BoundingVerts': [
|
|
Vector3(-0.0, 1.0, -1.0),
|
|
Vector3(-1.0, -1.0, -1.0),
|
|
Vector3(1.0, -1.0, -1.0)
|
|
],
|
|
'Max': [0.5, 0.5, 0.0],
|
|
'Min': [-0.5, -0.5, 0.0]
|
|
}
|
|
inst.segments['physics_convex'] = convex_segment
|
|
return inst
|
|
|
|
def iter_lods(self) -> Generator[List[LODSegmentDict], None, None]:
|
|
for lod_name, lod_val in self.segments.items():
|
|
if lod_name.endswith("_lod"):
|
|
yield lod_val
|
|
|
|
def iter_lod_materials(self) -> Generator[LODSegmentDict, None, None]:
|
|
for lods in self.iter_lods():
|
|
yield from lods
|
|
|
|
|
|
# These TypedDicts describe the expected shape of the LLSD in the mesh
|
|
# header and various segments. They're mainly for type hinting.
|
|
class MeshHeaderDict(TypedDict, total=False):
|
|
"""Header of the mesh file, includes offsets & sizes for segments' LLSD"""
|
|
version: int
|
|
creator: UUID
|
|
date: dt.datetime
|
|
physics_cost_data: PhysicsCostDataHeaderDict
|
|
high_lod: LODSegmentHeaderDict
|
|
medium_lod: LODSegmentHeaderDict
|
|
low_lod: LODSegmentHeaderDict
|
|
lowest_lod: LODSegmentHeaderDict
|
|
physics_convex: PhysicsSegmentHeaderDict
|
|
physics_mesh: PhysicsSegmentHeaderDict
|
|
skin: SegmentHeaderDict
|
|
physics_havok: PhysicsHavokSegmentHeaderDict
|
|
|
|
|
|
class SegmentHeaderDict(TypedDict):
|
|
"""Standard shape for segment references within the header"""
|
|
offset: int
|
|
size: int
|
|
|
|
|
|
class LODSegmentHeaderDict(SegmentHeaderDict, total=False):
|
|
# Possibly only on lowest?
|
|
mesh_triangles: int
|
|
|
|
|
|
class PhysicsSegmentHeaderDict(SegmentHeaderDict, total=False):
|
|
# Populated by the server, not there on the client
|
|
hash: bytes
|
|
|
|
|
|
class PhysicsHavokSegmentHeaderDict(PhysicsSegmentHeaderDict, total=False):
|
|
version: int
|
|
|
|
|
|
class PhysicsCostDataHeaderDict(TypedDict, total=False):
|
|
"""Cost of physical representation, populated by server"""
|
|
decomposition: float
|
|
decomposition_discounted_vertices: int
|
|
decomposition_hulls: int
|
|
hull: float
|
|
hull_discounted_vertices: float
|
|
# Not sure what this is, always seems to be 9 floats -1 to 1 if present
|
|
# Mat3?
|
|
mesh: List[float]
|
|
mesh_triangles: int
|
|
|
|
|
|
class MeshSegmentDict(TypedDict, total=False):
|
|
"""Dict of segments unpacked using the MeshHeaderDict"""
|
|
high_lod: List[LODSegmentDict]
|
|
medium_lod: List[LODSegmentDict]
|
|
low_lod: List[LODSegmentDict]
|
|
lowest_lod: List[LODSegmentDict]
|
|
physics_convex: PhysicsConvexSegmentDict
|
|
physics_mesh: List[LODSegmentDict]
|
|
physics_havok: PhysicsHavokSegmentDict
|
|
skin: SkinSegmentDict
|
|
|
|
|
|
class LODSegmentDict(TypedDict, total=False):
|
|
"""Represents a single entry within the material list of a LOD segment"""
|
|
# Only present if True and no geometry
|
|
NoGeometry: bool
|
|
# -1.0 - 1.0
|
|
Position: List[Vector3]
|
|
PositionDomain: DomainDict
|
|
# 0.0 - 1.0
|
|
TexCoord0: List[Vector2]
|
|
TexCoord0Domain: DomainDict
|
|
# -1.0 - 1.0
|
|
Normal: List[Vector3]
|
|
# [[1,2,3], [1,3,4], ...]
|
|
TriangleList: List[List[int]]
|
|
# Only present if rigged
|
|
Weights: List[List[VertexWeight]]
|
|
|
|
|
|
class DomainDict(TypedDict):
|
|
"""Description of the real range for quantized coordinates"""
|
|
# number of elems depends on what the domain is for, Vec2 or Vec3
|
|
Max: List[float]
|
|
Min: List[float]
|
|
|
|
|
|
class VertexWeight(recordclass.RecordClass):
|
|
"""Vertex weight for a specific joint on a specific vertex"""
|
|
# index of the joint within the joint_names list in the skin segment
|
|
joint_idx: int
|
|
# 0.0 - 1.0
|
|
weight: float
|
|
|
|
|
|
class SkinSegmentDict(TypedDict, total=False):
|
|
"""Rigging information"""
|
|
joint_names: List[str]
|
|
# model -> world transform mat4 for model
|
|
bind_shape_matrix: List[float]
|
|
# world -> joint local transform mat4s
|
|
inverse_bind_matrix: List[List[float]]
|
|
# Transform mat4s for the joint nodes themselves.
|
|
# The matrices may have scale or other components, but only the
|
|
# translation component will be used by the viewer.
|
|
# All translations are relative to the joint's parent.
|
|
alt_inverse_bind_matrix: List[List[float]]
|
|
lock_scale_if_joint_position: bool
|
|
pelvis_offset: float
|
|
|
|
|
|
class PhysicsConvexSegmentDict(DomainDict, total=False):
|
|
"""
|
|
Data for convex hull collisions, populated by the client
|
|
|
|
Min / Max pos domain vals are inline, unlike for LODs, so this inherits from DomainDict
|
|
"""
|
|
# Indices into the Positions list
|
|
HullList: List[int]
|
|
# -1.0 - 1.0, dequantized from binary field of U16s
|
|
Positions: List[Vector3]
|
|
# -1.0 - 1.0, dequantized from binary field of U16s
|
|
BoundingVerts: List[Vector3]
|
|
|
|
|
|
class PhysicsHavokSegmentDict(TypedDict, total=False):
|
|
"""Cached data for Havok collisions, populated by sim and not used by client."""
|
|
HullMassProps: HavokMassPropsDict
|
|
MOPP: HavokMOPPDict
|
|
MeshDecompMassProps: HavokMassPropsDict
|
|
WeldingData: bytes
|
|
|
|
|
|
class HavokMassPropsDict(TypedDict, total=False):
|
|
# Vec, center of mass
|
|
CoM: List[float]
|
|
# 9 floats, Mat3?
|
|
inertia: List[float]
|
|
mass: float
|
|
volume: float
|
|
|
|
|
|
class HavokMOPPDict(TypedDict, total=False):
|
|
"""Memory Optimized Partial Polytope"""
|
|
BuildType: int
|
|
MoppData: bytes
|
|
# 4 floats, Vec4?
|
|
MoppInfo: List[float]
|
|
|
|
|
|
def positions_from_domain(positions: Iterable[TupleCoord], domain: DomainDict):
|
|
"""
|
|
Used for turning positions into their actual positions within the mesh / domain
|
|
|
|
for ex: positions_from_domain(lod["Position"], lod["PositionDomain])
|
|
"""
|
|
lower = domain['Min']
|
|
upper = domain['Max']
|
|
return [
|
|
x.interpolate(lower, upper) for x in positions
|
|
]
|
|
|
|
|
|
def positions_to_domain(positions: Iterable[TupleCoord], domain: DomainDict):
|
|
"""Used for turning positions into their actual positions within the mesh / domain"""
|
|
lower = domain['Min']
|
|
upper = domain['Max']
|
|
return [
|
|
x.within_domain(lower, upper) for x in positions
|
|
]
|
|
|
|
|
|
class VertexWeights(se.SerializableBase):
|
|
"""Serializer for a list of joint weights on a single vertex"""
|
|
INFLUENCE_LIMIT = 4
|
|
INFLUENCE_TERM = 0xFF
|
|
|
|
@classmethod
|
|
def serialize(cls, vals, writer: se.BufferWriter, ctx=None):
|
|
if len(vals) > cls.INFLUENCE_LIMIT:
|
|
raise ValueError(f"{vals!r} is too long, can only have {cls.INFLUENCE_LIMIT} influences!")
|
|
for val in vals:
|
|
joint_idx, influence = val
|
|
writer.write(se.U8, joint_idx)
|
|
writer.write(se.U16, round(influence * 0xFFff), ctx=ctx)
|
|
if len(vals) != cls.INFLUENCE_LIMIT:
|
|
writer.write(se.U8, cls.INFLUENCE_TERM)
|
|
|
|
@classmethod
|
|
def deserialize(cls, reader: se.Reader, ctx=None):
|
|
# NOTE: normally you'd want to do something like arrange this into a nicely
|
|
# aligned byte array with zero padding so that you could vectorize the decoding.
|
|
# In cases where having a vertex with no weights is semantically equivalent to
|
|
# having a vertex _with_ weights of a value of 0.0 that's fine. This isn't the case
|
|
# in LL's implementation of mesh:
|
|
#
|
|
# https://bitbucket.org/lindenlab/viewer/src/d31a83fb946c49a38376ea3b312b5380d0c8c065/indra/llmath/llvolume.cpp#lines-2560:2628
|
|
#
|
|
# Consider the difference between handling of b"\x00\x00\x00\xFF" and b"\xFF" with the above logic.
|
|
# To simplify round-tripping while preserving those semantics, we don't do a vectorized decode.
|
|
# I had a vectorized numpy version, but those requirements made everything a bit of a mess.
|
|
influence_list = []
|
|
for _ in range(cls.INFLUENCE_LIMIT):
|
|
joint_idx = reader.read_bytes(1)[0]
|
|
if joint_idx == cls.INFLUENCE_TERM:
|
|
break
|
|
weight = reader.read(se.U16, ctx=ctx) / 0xFFff
|
|
influence_list.append(VertexWeight(joint_idx, weight))
|
|
return influence_list
|
|
|
|
|
|
class SegmentSerializer:
|
|
"""Serializer for binary fields within an LLSD object"""
|
|
def __init__(self, templates):
|
|
self._templates: Dict[str, se.SerializableBase] = templates
|
|
|
|
def serialize(self, vals: Dict[str, Any]):
|
|
new_segment = {}
|
|
for key, val in vals.items():
|
|
if key in self._templates and not isinstance(val, bytes):
|
|
# Pretty much everything in mesh segments is little-endian other
|
|
# than the LLSD itself.
|
|
writer = se.BufferWriter("<")
|
|
writer.write(self._templates[key], val)
|
|
new_segment[key] = writer.copy_buffer()
|
|
else:
|
|
new_segment[key] = val
|
|
return new_segment
|
|
|
|
def deserialize(self, vals: Dict[str, Any]):
|
|
new_segment = {}
|
|
for key, val in vals.items():
|
|
if key in self._templates:
|
|
reader = se.BufferReader("<", val)
|
|
new_segment[key] = reader.read(self._templates[key])
|
|
if len(reader):
|
|
LOG.warning(f"{len(reader)} bytes left in reader on mesh key {key}")
|
|
else:
|
|
new_segment[key] = val
|
|
return new_segment
|
|
|
|
|
|
class VecListAdapter(se.Adapter):
|
|
def __init__(self, child_spec: se.SERIALIZABLE_TYPE, vec_type: Type):
|
|
super().__init__(child_spec)
|
|
self.vec_type = vec_type
|
|
|
|
def encode(self, val: Any, ctx: Optional[ParseContext]) -> Any:
|
|
return val
|
|
|
|
def decode(self, val: Any, ctx: Optional[ParseContext], pod: bool = False) -> Any:
|
|
new_vals = []
|
|
for elem in val:
|
|
new_vals.append(self.vec_type(*elem))
|
|
return new_vals
|
|
|
|
|
|
LE_U16: np.dtype = np.dtype(np.uint16).newbyteorder('<') # noqa
|
|
|
|
|
|
LOD_SEGMENT_SERIALIZER = SegmentSerializer({
|
|
# 16-bit indices to the verts making up the tri. Imposes a 16-bit
|
|
# upper limit on verts in any given material in the mesh.
|
|
"TriangleList": se.ExprAdapter(
|
|
se.NumPyArray(se.BytesGreedy(), LE_U16, 3),
|
|
decode_func=lambda x: x.tolist(),
|
|
),
|
|
# These are used to interpolate between values in their respective domains
|
|
# Each position represents a single vert.
|
|
"Position": VecListAdapter(
|
|
se.QuantizedNumPyArray(se.NumPyArray(se.BytesGreedy(), LE_U16, 3), 0.0, 1.0),
|
|
Vector3,
|
|
),
|
|
"TexCoord0": VecListAdapter(
|
|
se.QuantizedNumPyArray(se.NumPyArray(se.BytesGreedy(), LE_U16, 2), 0.0, 1.0),
|
|
Vector2,
|
|
),
|
|
# Normals have a static domain between -1 and 1, so we just use that rather than 0.0 - 1.0.
|
|
"Normal": VecListAdapter(
|
|
se.QuantizedNumPyArray(se.NumPyArray(se.BytesGreedy(), LE_U16, 3), -1.0, 1.0),
|
|
Vector3,
|
|
),
|
|
"Weights": se.Collection(None, VertexWeights)
|
|
})
|
|
|
|
|
|
class LLMeshSerializer(se.SerializableBase):
|
|
# Also used as serialization order for segments.
|
|
# Note that there's conflicting info about whether skin is supposed to
|
|
# come first or not but the viewer always puts it second last, so we will too.
|
|
KNOWN_SEGMENTS = ("lowest_lod", "low_lod", "medium_lod", "high_lod",
|
|
"physics_mesh", "physics_convex", "skin", "physics_havok")
|
|
|
|
# Define unpackers for specific binary fields within the parsed LLSD segments
|
|
SEGMENT_TEMPLATES: Dict[str, SegmentSerializer] = {
|
|
"lowest_lod": LOD_SEGMENT_SERIALIZER,
|
|
"low_lod": LOD_SEGMENT_SERIALIZER,
|
|
"medium_lod": LOD_SEGMENT_SERIALIZER,
|
|
"high_lod": LOD_SEGMENT_SERIALIZER,
|
|
"physics_mesh": LOD_SEGMENT_SERIALIZER,
|
|
"physics_convex": SegmentSerializer({
|
|
"BoundingVerts": se.Collection(None, se.Vector3U16(-1.0, 1.0)),
|
|
"HullList": se.Collection(None, se.U8),
|
|
"Positions": se.Collection(None, se.Vector3U16(-1.0, 1.0)),
|
|
}),
|
|
}
|
|
|
|
def __init__(
|
|
self,
|
|
parse_segment_contents: bool = True,
|
|
allow_invalid_segments: bool = False,
|
|
include_raw_segments: bool = False,
|
|
):
|
|
super().__init__()
|
|
self.parse_segment_contents = parse_segment_contents
|
|
self.allow_invalid_segments = allow_invalid_segments
|
|
self.include_raw_segments = include_raw_segments
|
|
|
|
@classmethod
|
|
def _segment_sort(cls, key):
|
|
if key in cls.KNOWN_SEGMENTS:
|
|
return cls.KNOWN_SEGMENTS.index(key)
|
|
# If we don't know what this is chuck it to the end
|
|
return 0xFFffFFff
|
|
|
|
def _is_segment_header(self, val):
|
|
if not isinstance(val, dict):
|
|
return False
|
|
if "offset" not in val:
|
|
return False
|
|
if "size" not in val:
|
|
return False
|
|
return True
|
|
|
|
def serialize(self, val: MeshAsset, writer: se.BufferWriter, ctx=None):
|
|
all_segs = set(val.segments.keys()) | set(val.raw_segments.keys())
|
|
missing_headers = all_segs - set(val.header.keys())
|
|
if missing_headers:
|
|
raise ValueError(f"Segments missing from header dict: {missing_headers}")
|
|
|
|
# We write the body first so we can get the offsets and sizes
|
|
# for each segment for the header.
|
|
inner_writer = se.BufferWriter(writer.endianness)
|
|
# Don't mutate the original header
|
|
new_header = deepcopy(val.header)
|
|
|
|
# Write the segments in their preferred order
|
|
for key in sorted(new_header.keys(), key=self._segment_sort):
|
|
segment_header = new_header[key] # type: ignore
|
|
if not self._is_segment_header(segment_header):
|
|
# Doesn't look like a segment header
|
|
continue
|
|
if key not in self.KNOWN_SEGMENTS:
|
|
# Serialize anyway, it's all LLSD.
|
|
LOG.warning(f"Serializing unknown mesh segment {key}")
|
|
|
|
# Try segments first, then raw_segments
|
|
segment_val = val.segments.get(key, val.raw_segments.get(key)) # type: ignore
|
|
if segment_val is None:
|
|
if self.allow_invalid_segments:
|
|
continue
|
|
raise ValueError(f"{key} segment in header missing from segments dict")
|
|
start_offset = len(inner_writer)
|
|
|
|
# Write the segment, updating the header with the new offsets and sizes
|
|
segment_header["offset"] = start_offset
|
|
if isinstance(segment_val, bytes):
|
|
inner_writer.write_bytes(segment_val)
|
|
else:
|
|
if key in self.SEGMENT_TEMPLATES:
|
|
if isinstance(segment_val, (list, tuple)):
|
|
segment_val = [self.SEGMENT_TEMPLATES[key].serialize(x) for x in segment_val]
|
|
else:
|
|
segment_val = self.SEGMENT_TEMPLATES[key].serialize(segment_val) # type: ignore
|
|
inner_writer.write_bytes(zip_llsd(segment_val))
|
|
segment_header["size"] = len(inner_writer) - start_offset
|
|
writer.write(se.BinaryLLSD, new_header, ctx=ctx)
|
|
writer.write_bytes(inner_writer.buffer)
|
|
|
|
def deserialize(self, reader: se.Reader, ctx=None):
|
|
mesh = MeshAsset()
|
|
mesh.header = reader.read(se.BinaryLLSD, ctx=ctx)
|
|
header_end = reader.tell()
|
|
bytes_after_header = len(reader)
|
|
for key, segment_header in mesh.header.items():
|
|
if not self._is_segment_header(segment_header):
|
|
# Doesn't look like a segment header
|
|
continue
|
|
if key not in self.KNOWN_SEGMENTS:
|
|
LOG.warning(f"Encountered unknown mesh segment on decode: {key}")
|
|
if segment_header['offset'] + segment_header['size'] > bytes_after_header:
|
|
err_msg = f"{segment_header!r} would pass EOF, refusing to parse"
|
|
if self.allow_invalid_segments:
|
|
LOG.debug(err_msg)
|
|
continue
|
|
else:
|
|
raise ValueError(err_msg)
|
|
reader.seek(header_end + segment_header['offset'])
|
|
seg_bytes = reader.read_bytes(segment_header['size'])
|
|
|
|
if self.allow_invalid_segments and all(b == 0x00 for b in seg_bytes):
|
|
LOG.debug("Encountered padding segment, skipping")
|
|
continue
|
|
try:
|
|
segment_llsd = unzip_llsd(seg_bytes)
|
|
except zlib.error:
|
|
if self.allow_invalid_segments:
|
|
LOG.debug(f"Failed to parse segment bytes for {key}")
|
|
continue
|
|
raise
|
|
if self.parse_segment_contents and key in self.SEGMENT_TEMPLATES:
|
|
if isinstance(segment_llsd, (list, tuple)):
|
|
segment_parsed = [self.SEGMENT_TEMPLATES[key].deserialize(x) for x in segment_llsd]
|
|
else:
|
|
segment_parsed = self.SEGMENT_TEMPLATES[key].deserialize(segment_llsd)
|
|
else:
|
|
segment_parsed = segment_llsd
|
|
mesh.segments[key] = segment_parsed # type: ignore
|
|
if self.include_raw_segments:
|
|
mesh.raw_segments[key] = seg_bytes
|
|
return mesh
|