Skip to main content

falcon_library/model3d/
threeds.rs

1//! Autodesk 3D Studio `.3ds` files (binary, little-endian chunks).
2//!
3//! Read: the editor chunk with its named triangle-mesh objects (vertex list,
4//! face list, per-material face groups, smoothing groups) and its materials
5//! (name, diffuse colour in the 8-bit or float, linear or gamma-corrected
6//! forms, transparency, and the diffuse texture map: its file name, scale, offset and rotation; when that image sits next to the model
7//! its average colour stands in for the diffuse colour and the mesh keeps
8//! the picture as its texture). The mapping coordinates (0x4140) are kept,
9//! with the material's map scale, offset and rotation baked in and v
10//! flipped into picture space. One mesh is made per (object, material) pair; faces
11//! in no material group go to a mesh without a material. With smoothing
12//! groups, each triangle gets its own three vertices and normals averaged
13//! over the faces that meet at the same place and share a smoothing bit;
14//! without them the vertices stay shared and the viewer shades flat.
15//!
16//! A material with transparency draws see-through. Only the diffuse map and
17//! the transparency are read, as in Eagle's viewer: the bump, opacity and
18//! other map chunks are skipped, so no mesh gets a bump or normal map.
19//!
20//! Skipped: the keyframer (animation), lights, cameras, the master scale,
21//! and everything else unknown (bump, opacity and other maps among it). The
22//! vertices of a 3DS mesh are already stored in world space, so
23//! the local axis matrix (0x4160) is parsed and ignored, not applied.
24//! Coordinates are kept as stored (Z up).
25
26use std::collections::HashMap;
27
28use super::geom::{self, face_normal};
29use super::math::{self, V3};
30use super::texture::{Loader, Texture};
31use super::{DEFAULT_COLOR, Material, Mesh, Model, ParseError, Result};
32
33const MAIN: u16 = 0x4D4D;
34const EDITOR: u16 = 0x3D3D;
35const OBJECT: u16 = 0x4000;
36const TRI_MESH: u16 = 0x4100;
37const VERTICES: u16 = 0x4110;
38const FACES: u16 = 0x4120;
39const FACE_MATERIAL: u16 = 0x4130;
40const TEX_COORDS: u16 = 0x4140;
41const SMOOTHING: u16 = 0x4150;
42const LOCAL_AXES: u16 = 0x4160;
43const MATERIAL: u16 = 0xAFFF;
44const MAT_NAME: u16 = 0xA000;
45const MAT_DIFFUSE: u16 = 0xA020;
46const MAT_TRANSPARENCY: u16 = 0xA050;
47const MAT_TEXTURE: u16 = 0xA200;
48const MAT_TEXTURE_FILE: u16 = 0xA300;
49/// The map's tiling flags, which falcon ignores: textures always repeat.
50#[cfg(test)]
51const MAP_TILING: u16 = 0xA351;
52const MAP_U_SCALE: u16 = 0xA354;
53const MAP_V_SCALE: u16 = 0xA356;
54const MAP_U_OFFSET: u16 = 0xA358;
55const MAP_V_OFFSET: u16 = 0xA35A;
56const MAP_ROTATION: u16 = 0xA35C;
57const COLOR_FLOAT: u16 = 0x0010;
58const COLOR_BYTE: u16 = 0x0011;
59const COLOR_BYTE_GAMMA: u16 = 0x0012;
60const COLOR_FLOAT_GAMMA: u16 = 0x0013;
61const PERCENT_INT: u16 = 0x0030;
62const PERCENT_FLOAT: u16 = 0x0031;
63
64/// The sub-chunks of a chunk body: (id, body). Every step moves on by at
65/// least the 6-byte header; a bad length ends the walk with an error, and a
66/// tail shorter than a header is ignored.
67struct Chunks<'a> {
68    data: &'a [u8],
69    pos: usize,
70}
71
72fn chunks(data: &[u8]) -> Chunks<'_> {
73    Chunks { data, pos: 0 }
74}
75
76impl<'a> Iterator for Chunks<'a> {
77    type Item = Result<(u16, &'a [u8])>;
78
79    fn next(&mut self) -> Option<Self::Item> {
80        let rest = self.data.get(self.pos..)?;
81        if rest.len() < 6 {
82            self.pos = self.data.len();
83            return None;
84        }
85        let id = u16::from_le_bytes([rest[0], rest[1]]);
86        let len = u32::from_le_bytes([rest[2], rest[3], rest[4], rest[5]]) as usize;
87        if len < 6 || len > rest.len() {
88            self.pos = self.data.len();
89            return Some(Err(ParseError::new("bad chunk length in 3DS file")));
90        }
91        self.pos += len;
92        Some(Ok((id, &rest[6..len])))
93    }
94}
95
96/// A cursor over a chunk body; every read is bounds-checked.
97struct Reader<'a> {
98    b: &'a [u8],
99}
100
101fn short() -> ParseError {
102    ParseError::new("truncated 3DS chunk")
103}
104
105impl<'a> Reader<'a> {
106    fn take(&mut self, n: usize) -> Result<&'a [u8]> {
107        if n > self.b.len() {
108            return Err(short());
109        }
110        let (head, tail) = self.b.split_at(n);
111        self.b = tail;
112        Ok(head)
113    }
114
115    /// `count` records of `size` bytes, checked against what is left before
116    /// anything is allocated for them.
117    fn records(&mut self, count: usize, size: usize) -> Result<&'a [u8]> {
118        let n = count.checked_mul(size).ok_or_else(short)?;
119        self.take(n)
120    }
121
122    fn u16(&mut self) -> Result<u16> {
123        let b = self.take(2)?;
124        Ok(u16::from_le_bytes([b[0], b[1]]))
125    }
126
127    fn f32(&mut self) -> Result<f32> {
128        let b = self.take(4)?;
129        Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
130    }
131
132    /// A NUL-terminated string (Latin-1 in old files; read lossily).
133    fn cstr(&mut self) -> Result<String> {
134        let end = self.b.iter().position(|&c| c == 0).ok_or_else(short)?;
135        let s = String::from_utf8_lossy(&self.b[..end]).into_owned();
136        self.b = &self.b[end + 1..];
137        Ok(s)
138    }
139
140    fn rest(self) -> &'a [u8] {
141        self.b
142    }
143}
144
145fn f32_at(b: &[u8], i: usize) -> f32 {
146    f32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]])
147}
148
149#[derive(Debug, Clone, Default)]
150struct Mat {
151    name: String,
152    color: Option<V3>,
153    /// The colour came from a gamma-corrected chunk, which wins over a
154    /// linear one.
155    gamma: bool,
156    opacity: f32,
157    /// Diffuse texture file name.
158    texture: Option<String>,
159    /// How the texture map is laid on.
160    map: MapTransform,
161    /// The decoded texture, once the picture was found.
162    loaded: Option<Texture>,
163}
164
165/// A texture map's transform, as the file stores them (v up).
166#[derive(Debug, Clone, Copy)]
167struct MapTransform {
168    scale: [f32; 2],
169    offset: [f32; 2],
170    /// Degrees.
171    rotation: f32,
172}
173
174impl Default for MapTransform {
175    fn default() -> Self {
176        MapTransform { scale: [1.0; 2], offset: [0.0; 2], rotation: 0.0 }
177    }
178}
179
180impl MapTransform {
181    /// A coordinate in the file's v-up space through the map, then v
182    /// flipped to picture space. The map is three.js's `texture.repeat`,
183    /// `rotation` and `offset` around the centre (0, 0), which Eagle's
184    /// viewer sets from these chunks: rotate by the angle, then scale, then
185    /// move (three.js composes its UV matrix as translate * scale * rotate).
186    /// A scale of 0 (a degenerate map) counts as 1.
187    fn apply(&self, uv: [f32; 2]) -> [f32; 2] {
188        let (s, c) = self.rotation.to_radians().sin_cos();
189        let (u, v) = (c * uv[0] + s * uv[1], -s * uv[0] + c * uv[1]);
190        let k = |x: f32| if x == 0.0 { 1.0 } else { x };
191        [u * k(self.scale[0]) + self.offset[0], 1.0 - (v * k(self.scale[1]) + self.offset[1])]
192    }
193}
194
195#[derive(Default)]
196struct Object {
197    name: String,
198    positions: Vec<V3>,
199    faces: Vec<[u16; 3]>,
200    /// (material name, face indices), in file order.
201    groups: Vec<(String, Vec<u16>)>,
202    /// One smoothing mask per face, when the file has them.
203    smoothing: Option<Vec<u32>>,
204    /// Mapping coordinates as stored (v up), one per vertex.
205    uvs: Option<Vec<[f32; 2]>>,
206}
207
208#[derive(Default)]
209struct Scene {
210    materials: Vec<Mat>,
211    objects: Vec<Object>,
212}
213
214/// Reads a 3DS file; `side` fetches a texture image next to it by name.
215pub fn parse(bytes: &[u8], side: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
216    let mut top = chunks(bytes);
217    let main = match top.next() {
218        Some(Ok((MAIN, body))) => body,
219        _ => return Err(ParseError::new("not a 3DS file")),
220    };
221    let mut scene = Scene::default();
222    for c in chunks(main) {
223        let (id, body) = c?;
224        if id == EDITOR {
225            read_editor(body, &mut scene)?;
226        }
227    }
228    let mut loader = Loader::new(side);
229    for m in &mut scene.materials {
230        if let Some(image) = m.texture.as_deref().and_then(|t| loader.file(t)) {
231            if let Some(c) = image.average() {
232                m.color = Some(c);
233            }
234            m.loaded = Some(Texture { image, tint: [1.0; 3], alpha_cutoff: None });
235        }
236    }
237    Model { meshes: build(&scene) }.finish()
238}
239
240fn read_editor(body: &[u8], scene: &mut Scene) -> Result<()> {
241    for c in chunks(body) {
242        let (id, body) = c?;
243        match id {
244            OBJECT => read_object(body, scene)?,
245            MATERIAL => scene.materials.push(read_material(body)?),
246            _ => {}
247        }
248    }
249    Ok(())
250}
251
252fn read_object(body: &[u8], scene: &mut Scene) -> Result<()> {
253    let mut r = Reader { b: body };
254    let name = r.cstr()?;
255    for c in chunks(r.rest()) {
256        let (id, body) = c?;
257        if id == TRI_MESH {
258            scene.objects.push(read_mesh(name.clone(), body)?);
259        }
260    }
261    Ok(())
262}
263
264fn read_mesh(name: String, body: &[u8]) -> Result<Object> {
265    let mut o = Object { name, ..Object::default() };
266    for c in chunks(body) {
267        let (id, body) = c?;
268        match id {
269            VERTICES => o.positions = read_vertices(body)?,
270            FACES => read_faces(body, &mut o)?,
271            TEX_COORDS => o.uvs = Some(read_tex_coords(body)?),
272            LOCAL_AXES => {
273                Reader { b: body }.take(48)?;
274            }
275            _ => {}
276        }
277    }
278    Ok(o)
279}
280
281fn read_vertices(body: &[u8]) -> Result<Vec<V3>> {
282    let mut r = Reader { b: body };
283    let n = r.u16()? as usize;
284    let data = r.records(n, 12)?;
285    Ok(data.as_chunks::<12>().0.iter().map(|v| [f32_at(v, 0), f32_at(v, 4), f32_at(v, 8)]).collect())
286}
287
288fn read_tex_coords(body: &[u8]) -> Result<Vec<[f32; 2]>> {
289    let mut r = Reader { b: body };
290    let n = r.u16()? as usize;
291    let data = r.records(n, 8)?;
292    Ok(data.as_chunks::<8>().0.iter().map(|v| [f32_at(v, 0), f32_at(v, 4)].map(|x| if x.is_finite() { x } else { 0.0 })).collect())
293}
294
295fn read_faces(body: &[u8], o: &mut Object) -> Result<()> {
296    let mut r = Reader { b: body };
297    let n = r.u16()? as usize;
298    let data = r.records(n, 8)?;
299    o.faces = data.as_chunks::<8>().0.iter().map(|f| [0, 2, 4].map(|k| u16::from_le_bytes([f[k], f[k + 1]]))).collect();
300    for c in chunks(r.rest()) {
301        let (id, body) = c?;
302        match id {
303            FACE_MATERIAL => {
304                let mut r = Reader { b: body };
305                let name = r.cstr()?;
306                let n = r.u16()? as usize;
307                let data = r.records(n, 2)?;
308                let faces = data.as_chunks::<2>().0.iter().map(|f| u16::from_le_bytes([f[0], f[1]])).collect();
309                o.groups.push((name, faces));
310            }
311            SMOOTHING => {
312                // One u32 per face; a short chunk leaves the rest at 0.
313                let mut masks: Vec<u32> = body.as_chunks::<4>().0.iter().take(o.faces.len()).map(|m| u32::from_le_bytes([m[0], m[1], m[2], m[3]])).collect();
314                masks.resize(o.faces.len(), 0);
315                o.smoothing = Some(masks);
316            }
317            _ => {}
318        }
319    }
320    Ok(())
321}
322
323fn read_material(body: &[u8]) -> Result<Mat> {
324    let mut m = Mat { opacity: 1.0, ..Mat::default() };
325    for c in chunks(body) {
326        let (id, body) = c?;
327        match id {
328            MAT_NAME => m.name = Reader { b: body }.cstr()?,
329            MAT_DIFFUSE => read_diffuse(body, &mut m)?,
330            MAT_TRANSPARENCY => read_transparency(body, &mut m)?,
331            MAT_TEXTURE => {
332                for c in chunks(body) {
333                    let (id, body) = c?;
334                    let mut r = Reader { b: body };
335                    match id {
336                        MAT_TEXTURE_FILE => m.texture = Some(r.cstr()?),
337                        MAP_U_SCALE => m.map.scale[0] = finite(r.f32()?, 1.0),
338                        MAP_V_SCALE => m.map.scale[1] = finite(r.f32()?, 1.0),
339                        MAP_U_OFFSET => m.map.offset[0] = finite(r.f32()?, 0.0),
340                        MAP_V_OFFSET => m.map.offset[1] = finite(r.f32()?, 0.0),
341                        MAP_ROTATION => m.map.rotation = finite(r.f32()?, 0.0),
342                        _ => {}
343                    }
344                }
345            }
346            _ => {}
347        }
348    }
349    Ok(m)
350}
351
352fn finite(x: f32, default: f32) -> f32 {
353    if x.is_finite() { x } else { default }
354}
355
356fn read_diffuse(body: &[u8], m: &mut Mat) -> Result<()> {
357    for c in chunks(body) {
358        let (id, body) = c?;
359        let mut r = Reader { b: body };
360        let (color, gamma) = match id {
361            COLOR_FLOAT | COLOR_FLOAT_GAMMA => ([r.f32()?, r.f32()?, r.f32()?], id == COLOR_FLOAT_GAMMA),
362            COLOR_BYTE | COLOR_BYTE_GAMMA => {
363                let b = r.take(3)?;
364                ([b[0], b[1], b[2]].map(|c| f32::from(c) / 255.0), id == COLOR_BYTE_GAMMA)
365            }
366            _ => continue,
367        };
368        if m.color.is_none() || (gamma && !m.gamma) {
369            m.color = Some(color.map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.0 }));
370            m.gamma = gamma;
371        }
372    }
373    Ok(())
374}
375
376fn read_transparency(body: &[u8], m: &mut Mat) -> Result<()> {
377    for c in chunks(body) {
378        let (id, body) = c?;
379        let mut r = Reader { b: body };
380        let percent = match id {
381            PERCENT_INT => f32::from(r.u16()?) / 100.0,
382            PERCENT_FLOAT => r.f32()?,
383            _ => continue,
384        };
385        if percent.is_finite() {
386            m.opacity = 1.0 - percent.clamp(0.0, 1.0);
387        }
388    }
389    Ok(())
390}
391
392fn build(scene: &Scene) -> Vec<Mesh> {
393    scene.objects.iter().flat_map(|o| build_object(o, &scene.materials)).collect()
394}
395
396/// A face with its corners in range of the vertex list.
397struct Face {
398    corners: [usize; 3],
399    smooth: u32,
400}
401
402fn build_object(o: &Object, materials: &[Mat]) -> Vec<Mesh> {
403    let n = o.positions.len();
404    let faces: Vec<Option<Face>> = o
405        .faces
406        .iter()
407        .enumerate()
408        .map(|(i, f)| {
409            let corners = f.map(usize::from);
410            let smooth = o.smoothing.as_ref().and_then(|s| s.get(i)).copied().unwrap_or(0);
411            corners.iter().all(|&c| c < n).then_some(Face { corners, smooth })
412        })
413        .collect();
414    // Which group each face is in: the first that lists it.
415    let mut group_of: Vec<Option<usize>> = vec![None; faces.len()];
416    let mut names: Vec<&str> = Vec::new();
417    for (name, list) in &o.groups {
418        let g = names.iter().position(|n| n == name).unwrap_or_else(|| {
419            names.push(name);
420            names.len() - 1
421        });
422        for &f in list {
423            if let Some(slot) = group_of.get_mut(usize::from(f)).filter(|s| s.is_none()) {
424                *slot = Some(g);
425            }
426        }
427    }
428    // One coordinate per vertex, or none.
429    let uvs = o.uvs.as_deref().filter(|u| u.len() == n);
430    let normals = o.smoothing.is_some().then(|| corner_normals(&o.positions, &faces));
431    let mut meshes = Vec::new();
432    // One mesh per group name, then the faces in none.
433    for g in (0..names.len()).map(Some).chain([None]) {
434        let members: Vec<usize> = (0..faces.len()).filter(|&i| group_of[i] == g && faces[i].is_some()).collect();
435        if members.is_empty() {
436            continue;
437        }
438        let mat = g.and_then(|g| materials.iter().find(|m| m.name == names[g]));
439        let material = mat.map(|m| Material {
440            name: m.name.clone(),
441            color: m.color.unwrap_or(DEFAULT_COLOR),
442            opacity: m.opacity,
443        });
444        let mut mesh = match &normals {
445            Some(nrm) => split_mesh(&o.positions, &faces, nrm, uvs, &members),
446            None => shared_mesh(&o.positions, &faces, uvs, &members),
447        };
448        mesh.name = o.name.clone();
449        mesh.material = material;
450        // The map moves the coordinates; a mesh whose picture couldn't be
451        // read gets them with just the flip.
452        let map = mat.map(|m| m.map).unwrap_or_default();
453        if let Some(uvs) = &mut mesh.uvs {
454            for uv in uvs {
455                *uv = map.apply(*uv);
456            }
457        }
458        mesh.texture = mat.and_then(|m| m.loaded.clone());
459        mesh.surface.blend = mat.is_some_and(|m| (m.opacity - 1.0).abs() > 1e-6);
460        meshes.push(mesh);
461    }
462    meshes
463}
464
465/// The listed faces over the vertices they use, as in the file.
466fn shared_mesh(positions: &[V3], faces: &[Option<Face>], uvs: Option<&[[f32; 2]]>, members: &[usize]) -> Mesh {
467    let mut remap: Vec<Option<u32>> = vec![None; positions.len()];
468    let mut mesh = Mesh::default();
469    let mut out_uvs = Vec::new();
470    for f in members.iter().filter_map(|&i| faces[i].as_ref()) {
471        for &c in &f.corners {
472            let slot = &mut remap[c];
473            let i = *slot.get_or_insert_with(|| {
474                mesh.positions.push(positions[c]);
475                out_uvs.extend(uvs.map(|u| u[c]));
476                (mesh.positions.len() - 1) as u32
477            });
478            mesh.indices.push(i);
479        }
480    }
481    mesh.uvs = uvs.map(|_| out_uvs);
482    mesh
483}
484
485/// The listed faces with three own vertices each, carrying `normals`
486/// (three per face, in face order).
487fn split_mesh(positions: &[V3], faces: &[Option<Face>], normals: &[[V3; 3]], uvs: Option<&[[f32; 2]]>, members: &[usize]) -> Mesh {
488    let mut mesh = Mesh::default();
489    let mut out_uvs = Vec::with_capacity(if uvs.is_some() { members.len() * 3 } else { 0 });
490    let mut out_normals = Vec::with_capacity(members.len() * 3);
491    for &i in members {
492        let Some(f) = &faces[i] else { continue };
493        for k in 0..3 {
494            mesh.indices.push(mesh.positions.len() as u32);
495            mesh.positions.push(positions[f.corners[k]]);
496            out_normals.push(normals[i][k]);
497            out_uvs.extend(uvs.map(|u| u[f.corners[k]]));
498        }
499    }
500    mesh.normals = Some(out_normals);
501    mesh.uvs = uvs.map(|_| out_uvs);
502    mesh
503}
504
505/// The normal at each corner of each face (zeros for dropped faces): the
506/// normalised sum of the area-weighted normals of the faces that meet at
507/// the same place and share a smoothing bit; a face in no smoothing group
508/// keeps its own normal.
509fn corner_normals(positions: &[V3], faces: &[Option<Face>]) -> Vec<[V3; 3]> {
510    let weld = geom::weld(positions);
511    let tri = |f: &Face| f.corners.map(|c| positions[c]);
512    // Per welded place: the summed (unnormalised) normals by smoothing mask.
513    // At most MAX_MASKS distinct masks are kept per place, so a file with
514    // thousands of masks at one vertex costs linear time, not quadratic.
515    const MAX_MASKS: usize = 256;
516    let mut at: HashMap<u32, Vec<(u32, V3)>> = HashMap::new();
517    let mut slot: HashMap<(u32, u32), usize> = HashMap::new();
518    for f in faces.iter().flatten() {
519        let [a, b, c] = tri(f);
520        let area_normal = math::cross(math::sub(b, a), math::sub(c, a));
521        for &corner in &f.corners {
522            let place = weld[corner];
523            let list = at.entry(place).or_default();
524            match slot.get(&(place, f.smooth)) {
525                Some(&k) => list[k].1 = math::add(list[k].1, area_normal),
526                None if list.len() < MAX_MASKS => {
527                    slot.insert((place, f.smooth), list.len());
528                    list.push((f.smooth, area_normal));
529                }
530                None => {}
531            }
532        }
533    }
534    let mut cache: HashMap<(u32, u32), V3> = HashMap::new();
535    faces
536        .iter()
537        .map(|f| {
538            let Some(f) = f else { return [[0.0; 3]; 3] };
539            let [a, b, c] = tri(f);
540            if f.smooth == 0 {
541                return [face_normal(a, b, c); 3];
542            }
543            f.corners.map(|corner| {
544                let place = weld[corner];
545                *cache.entry((place, f.smooth)).or_insert_with(|| {
546                    let sum = at.get(&place).into_iter().flatten().filter(|(m, _)| m & f.smooth != 0).fold([0.0; 3], |s, (_, n)| math::add(s, *n));
547                    let n = math::normalize(sum);
548                    if n == [0.0; 3] { face_normal(a, b, c) } else { n }
549                })
550            })
551        })
552        .collect()
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    fn parse(bytes: &[u8]) -> Result<Model> {
560        super::parse(bytes, &|_| None)
561    }
562
563    #[test]
564    fn texture_next_to_the_file_gives_the_colour() {
565        let mut png = Vec::new();
566        image::RgbImage::from_pixel(2, 2, image::Rgb([0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
567        let mat = cat(&[chunk(MAT_NAME, &cstr("t")), red_byte(), chunk(MAT_TEXTURE, &chunk(MAT_TEXTURE_FILE, &cstr("BLUE.PNG")))]);
568        let f = file(&[mat], &[("t", all(12))], None);
569        let side = |n: &str| (n == "BLUE.PNG").then(|| png.clone());
570        assert_eq!(super::parse(&f, &side).unwrap().meshes[0].material.as_ref().unwrap().color, [0.0, 0.0, 1.0]);
571        // Missing image: the diffuse colour.
572        assert_eq!(parse(&f).unwrap().meshes[0].material.as_ref().unwrap().color, [1.0, 0.0, 0.0]);
573    }
574
575    /// Cube corner `i` maps to this, in the file's v-up space.
576    fn corner_uv(i: usize) -> [f32; 2] {
577        [i as f32 * 0.1, i as f32 * 0.05]
578    }
579
580    fn cube_uvs() -> Vec<[f32; 2]> {
581        (0..8).map(corner_uv).collect()
582    }
583
584    fn near(a: [f32; 2], b: [f32; 2]) -> bool {
585        (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5
586    }
587
588    /// Every vertex of the model has the coordinate `want` gives for its
589    /// corner of the cube.
590    fn uvs_match(m: &Model, want: impl Fn(usize) -> [f32; 2]) -> bool {
591        m.meshes.iter().all(|mesh| {
592            let uvs = mesh.uvs.as_ref().unwrap();
593            uvs.len() == mesh.positions.len() && mesh.positions.iter().zip(uvs).all(|(p, uv)| near(*uv, want((0..8).find(|&i| corner(i) == *p).unwrap())))
594        })
595    }
596
597    /// A texture map chunk: file name, then the sub-chunks given.
598    fn map_chunk(name: &str, subs: &[Vec<u8>]) -> Vec<u8> {
599        chunk(MAT_TEXTURE, &cat(&[chunk(MAT_TEXTURE_FILE, &cstr(name)), subs.concat()]))
600    }
601
602    fn float_chunk(id: u16, v: f32) -> Vec<u8> {
603        chunk(id, &v.to_le_bytes())
604    }
605
606    #[test]
607    fn mapping_coordinates_are_read_and_flipped() {
608        // Shared vertices (no smoothing groups) and split ones (with).
609        for smooth in [None, Some(&[1u32; 12][..])] {
610            let m = parse(&file_uv(&[], &[], smooth, &cube_uvs())).unwrap();
611            assert_eq!(m.meshes[0].positions.len(), if smooth.is_some() { 36 } else { 8 });
612            assert!(uvs_match(&m, |i| [corner_uv(i)[0], 1.0 - corner_uv(i)[1]]));
613            // No texture without a material that has one.
614            assert!(m.meshes[0].texture.is_none());
615        }
616        // A list that is not one per vertex is dropped.
617        let m = parse(&file_uv(&[], &[], None, &cube_uvs()[..5])).unwrap();
618        assert!(m.meshes[0].uvs.is_none());
619        // Groups split a mesh; each part keeps its own vertices' coordinates.
620        let mats = [material("a", red_byte())];
621        let m = parse(&file_uv(&mats, &[("a", all(5))], None, &cube_uvs())).unwrap();
622        assert_eq!(m.meshes.len(), 2);
623        assert!(uvs_match(&m, |i| [corner_uv(i)[0], 1.0 - corner_uv(i)[1]]));
624    }
625
626    #[test]
627    fn texture_map_gives_texture_and_transform() {
628        let png = crate::model3d::texture::tests::png_2x2();
629        let side = |n: &str| (n == "tex.png").then(|| png.clone());
630        let flags = |f: u16| chunk(MAP_TILING, &f.to_le_bytes());
631        let with = |subs: &[Vec<u8>]| {
632            let mat = cat(&[chunk(MAT_NAME, &cstr("t")), red_byte(), map_chunk("tex.png", subs)]);
633            super::parse(&file_uv(&[mat], &[("t", all(12))], None, &cube_uvs()), &side).unwrap()
634        };
635        let m = with(&[flags(0x0010)]);
636        let mesh = &m.meshes[0];
637        let t = mesh.texture.as_ref().unwrap();
638        assert_eq!((t.image.width, t.image.height, t.tint, t.alpha_cutoff), (2, 2, [1.0; 3], None));
639        // The material colour is the picture's average, not the diffuse red.
640        let avg = mesh.material.as_ref().unwrap().color;
641        assert!((avg[1] - 0.5).abs() < 1e-3 && (avg[0] - 0.5).abs() < 1e-3);
642        assert!(mesh.textured().is_some());
643        // Scale then offset (no rotation), then v flipped.
644        let m = with(&[float_chunk(MAP_U_SCALE, 2.0), float_chunk(MAP_V_SCALE, 3.0), float_chunk(MAP_U_OFFSET, 0.25), float_chunk(MAP_V_OFFSET, 0.5)]);
645        assert!(uvs_match(&m, |i| [corner_uv(i)[0] * 2.0 + 0.25, 1.0 - (corner_uv(i)[1] * 3.0 + 0.5)]));
646        // Rotation of 90 degrees: (u, v) -> (v, -u), as three.js does about (0, 0).
647        let m = with(&[float_chunk(MAP_ROTATION, 90.0)]);
648        assert!(uvs_match(&m, |i| [corner_uv(i)[1], 1.0 + corner_uv(i)[0]]));
649    }
650
651    #[test]
652    fn missing_texture_keeps_the_diffuse_colour_and_the_coordinates() {
653        let mat = cat(&[chunk(MAT_NAME, &cstr("t")), red_byte(), map_chunk("gone.png", &[chunk(MAP_TILING, &0x10u16.to_le_bytes())])]);
654        let m = parse(&file_uv(&[mat], &[("t", all(12))], None, &cube_uvs())).unwrap();
655        let mesh = &m.meshes[0];
656        assert!(mesh.texture.is_none());
657        assert_eq!(mesh.material.as_ref().unwrap().color, [1.0, 0.0, 0.0]);
658        assert!(uvs_match(&m, |i| [corner_uv(i)[0], 1.0 - corner_uv(i)[1]]));
659    }
660
661    #[test]
662    fn texture_outside_the_model_folder_is_not_read() {
663        let png = crate::model3d::texture::tests::png_2x2();
664        let asked = std::cell::RefCell::new(Vec::new());
665        let side = |n: &str| {
666            asked.borrow_mut().push(n.to_string());
667            (!n.contains("..")).then(|| png.clone())
668        };
669        let mat = cat(&[chunk(MAT_NAME, &cstr("t")), red_byte(), map_chunk("../up/tex.png", &[])]);
670        super::parse(&file(&[mat], &[("t", all(12))], None), &side).unwrap();
671        assert!(asked.borrow().iter().all(|n| !n.contains("..")));
672    }
673
674    #[test]
675    fn import_thumbnail_draws_the_red_cube() {
676        let dir = tempfile::tempdir().unwrap();
677        let path = dir.path().join("cube.3ds");
678        std::fs::write(&path, red_cube()).unwrap();
679        let img = super::super::raster::thumbnail(&path, "3ds").unwrap();
680        assert!(img.pixels().any(|p| p[3] == 255 && p[0] > 100 && p[1] < 40 && p[2] < 40));
681    }
682
683    #[test]
684    fn many_smoothing_masks_at_one_vertex_stay_fast() {
685        let faces: Vec<[u16; 3]> = (0..65535u32).map(|i| [0, (i % 6 + 1) as u16, (i % 6 + 2) as u16]).collect();
686        let smooth: Vec<u32> = (0..65535u32).map(|i| i + 1).collect();
687        let mut obj = cstr("fan");
688        obj.extend(chunk(TRI_MESH, &mesh_chunk(&faces, &[], Some(&smooth))));
689        let f = chunk(MAIN, &chunk(EDITOR, &chunk(OBJECT, &obj)));
690        let t = std::time::Instant::now();
691        assert_eq!(parse(&f).unwrap().triangle_count(), 65535);
692        assert!(t.elapsed().as_secs() < 20);
693    }
694
695    fn chunk(id: u16, body: &[u8]) -> Vec<u8> {
696        let mut out = id.to_le_bytes().to_vec();
697        out.extend(((body.len() + 6) as u32).to_le_bytes());
698        out.extend(body);
699        out
700    }
701
702    fn cat(parts: &[Vec<u8>]) -> Vec<u8> {
703        parts.concat()
704    }
705
706    fn cstr(s: &str) -> Vec<u8> {
707        let mut v = s.as_bytes().to_vec();
708        v.push(0);
709        v
710    }
711
712    /// Cube corner `i` has x, y, z from bits 0, 1, 2.
713    fn corner(i: usize) -> V3 {
714        [(i & 1) as f32, (i >> 1 & 1) as f32, (i >> 2 & 1) as f32]
715    }
716
717    fn index(axis: usize, s: usize, u: usize, v: usize) -> usize {
718        s << axis | u << ((axis + 1) % 3) | v << ((axis + 2) % 3)
719    }
720
721    /// 12 counter-clockwise triangles, two per side; side = axis * 2 + s.
722    /// The diagonal avoids the (0, 0) corner of each side.
723    fn cube_faces() -> Vec<[u16; 3]> {
724        let mut out = Vec::new();
725        for axis in 0..3 {
726            for s in 0..2 {
727                let p = |u, v| index(axis, s, u, v) as u16;
728                if s == 1 {
729                    out.push([p(0, 0), p(1, 0), p(0, 1)]);
730                    out.push([p(1, 0), p(1, 1), p(0, 1)]);
731                } else {
732                    out.push([p(0, 0), p(0, 1), p(1, 0)]);
733                    out.push([p(1, 0), p(0, 1), p(1, 1)]);
734                }
735            }
736        }
737        out
738    }
739
740    fn color_chunk(id: u16, body: &[u8]) -> Vec<u8> {
741        chunk(MAT_DIFFUSE, &chunk(id, body))
742    }
743
744    fn red_byte() -> Vec<u8> {
745        color_chunk(COLOR_BYTE, &[255, 0, 0])
746    }
747
748    fn mesh_chunk(faces: &[[u16; 3]], groups: &[(&str, Vec<u16>)], smooth: Option<&[u32]>) -> Vec<u8> {
749        mesh_chunk_uv(faces, groups, smooth, &[[0.0; 2]; 8])
750    }
751
752    fn mesh_chunk_uv(faces: &[[u16; 3]], groups: &[(&str, Vec<u16>)], smooth: Option<&[u32]>, uvs: &[[f32; 2]]) -> Vec<u8> {
753        let mut verts = 8u16.to_le_bytes().to_vec();
754        for i in 0..8 {
755            verts.extend(corner(i).iter().flat_map(|c| c.to_le_bytes()));
756        }
757        let mut fl = (faces.len() as u16).to_le_bytes().to_vec();
758        for f in faces {
759            fl.extend(f.iter().flat_map(|i| i.to_le_bytes()));
760            fl.extend([0u8; 2]);
761        }
762        for (name, list) in groups {
763            let mut g = cstr(name);
764            g.extend((list.len() as u16).to_le_bytes());
765            g.extend(list.iter().flat_map(|i| i.to_le_bytes()));
766            fl.extend(chunk(FACE_MATERIAL, &g));
767        }
768        if let Some(s) = smooth {
769            fl.extend(chunk(SMOOTHING, &s.iter().flat_map(|m| m.to_le_bytes()).collect::<Vec<_>>()));
770        }
771        let mut uv = (uvs.len() as u16).to_le_bytes().to_vec();
772        uv.extend(uvs.iter().flatten().flat_map(|c| c.to_le_bytes()));
773        cat(&[chunk(VERTICES, &verts), chunk(FACES, &fl), chunk(TEX_COORDS, &uv), chunk(LOCAL_AXES, &[0u8; 48])])
774    }
775
776    fn file(materials: &[Vec<u8>], groups: &[(&str, Vec<u16>)], smooth: Option<&[u32]>) -> Vec<u8> {
777        file_uv(materials, groups, smooth, &[[0.0; 2]; 8])
778    }
779
780    fn file_uv(materials: &[Vec<u8>], groups: &[(&str, Vec<u16>)], smooth: Option<&[u32]>, uvs: &[[f32; 2]]) -> Vec<u8> {
781        let mut obj = cstr("cube");
782        obj.extend(chunk(TRI_MESH, &mesh_chunk_uv(&cube_faces(), groups, smooth, uvs)));
783        let mut editor = chunk(OBJECT, &obj);
784        for m in materials {
785            editor.extend(chunk(MATERIAL, m));
786        }
787        chunk(MAIN, &chunk(EDITOR, &editor))
788    }
789
790    fn material(name: &str, color: Vec<u8>) -> Vec<u8> {
791        cat(&[chunk(MAT_NAME, &cstr(name)), color])
792    }
793
794    fn all(n: u16) -> Vec<u16> {
795        (0..n).collect()
796    }
797
798    fn red_cube() -> Vec<u8> {
799        file(&[material("red", red_byte())], &[("red", all(12))], None)
800    }
801
802    #[test]
803    fn coloured_cube() {
804        let m = parse(&red_cube()).unwrap();
805        assert_eq!(m.meshes.len(), 1);
806        assert_eq!(m.triangle_count(), 12);
807        assert_eq!(m.bounds(), Some(([0.0; 3], [1.0; 3])));
808        let mat = m.meshes[0].material.as_ref().unwrap();
809        assert_eq!((mat.name.as_str(), mat.color, mat.opacity), ("red", [1.0, 0.0, 0.0], 1.0));
810        assert_eq!(m.meshes[0].name, "cube");
811        assert_eq!(m.meshes[0].positions.len(), 8);
812        assert!(m.meshes[0].normals.is_none());
813    }
814
815    #[test]
816    fn colour_forms() {
817        let f32s = |c: [f32; 3]| c.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
818        for (c, want) in [
819            (color_chunk(COLOR_FLOAT, &f32s([0.0, 0.5, 1.0])), [0.0, 0.5, 1.0]),
820            (color_chunk(COLOR_FLOAT_GAMMA, &f32s([1.0, 0.0, 0.25])), [1.0, 0.0, 0.25]),
821            (color_chunk(COLOR_BYTE_GAMMA, &[0, 255, 0]), [0.0, 1.0, 0.0]),
822        ] {
823            let m = parse(&file(&[material("c", c)], &[("c", all(12))], None)).unwrap();
824            assert_eq!(m.meshes[0].material.as_ref().unwrap().color, want);
825        }
826        // The gamma-corrected form wins over the linear one.
827        let both = chunk(MAT_DIFFUSE, &cat(&[chunk(COLOR_BYTE, &[255, 0, 0]), chunk(COLOR_BYTE_GAMMA, &[0, 0, 255])]));
828        let m = parse(&file(&[material("c", both)], &[("c", all(12))], None)).unwrap();
829        assert_eq!(m.meshes[0].material.as_ref().unwrap().color, [0.0, 0.0, 1.0]);
830    }
831
832    #[test]
833    fn transparency_and_texture() {
834        let mut mat = material("glass", red_byte());
835        mat.extend(chunk(MAT_TRANSPARENCY, &chunk(PERCENT_INT, &25u16.to_le_bytes())));
836        let m = parse(&file(&[mat], &[("glass", all(12))], None)).unwrap();
837        assert_eq!(m.meshes[0].material.as_ref().unwrap().opacity, 0.75);
838        assert!(m.meshes[0].surface.blend);
839        let mut mat = material("glass", red_byte());
840        mat.extend(chunk(MAT_TRANSPARENCY, &chunk(PERCENT_FLOAT, &0.5f32.to_le_bytes())));
841        let m = parse(&file(&[mat], &[("glass", all(12))], None)).unwrap();
842        assert_eq!(m.meshes[0].material.as_ref().unwrap().opacity, 0.5);
843        let mat = read_material(&cat(&[chunk(MAT_NAME, &cstr("t")), chunk(MAT_TEXTURE, &chunk(MAT_TEXTURE_FILE, &cstr("wood.jpg")))])).unwrap();
844        assert_eq!(mat.texture.as_deref(), Some("wood.jpg"));
845    }
846
847    #[test]
848    fn blend_follows_transparency() {
849        let transparent = |bytes: Vec<u8>| {
850            let mut mat = material("glass", red_byte());
851            mat.extend(chunk(MAT_TRANSPARENCY, &bytes));
852            mat
853        };
854        // Shared vertices, and split ones with smoothing groups.
855        for smooth in [None, Some(&[1u32; 12][..])] {
856            let m = parse(&file(&[transparent(chunk(PERCENT_INT, &50u16.to_le_bytes()))], &[("glass", all(12))], smooth)).unwrap();
857            assert!(m.meshes[0].surface.blend);
858            assert!(m.meshes[0].surface.bump_map.is_none() && m.meshes[0].surface.normal_map.is_none());
859            let m = parse(&file(&[transparent(chunk(PERCENT_INT, &0u16.to_le_bytes()))], &[("glass", all(12))], smooth)).unwrap();
860            assert!(!m.meshes[0].surface.blend);
861            let m = parse(&file(&[transparent(chunk(PERCENT_FLOAT, &f32::NAN.to_le_bytes()))], &[("glass", all(12))], smooth)).unwrap();
862            assert!(!m.meshes[0].surface.blend);
863        }
864        assert!(!parse(&red_cube()).unwrap().meshes[0].surface.blend);
865    }
866
867    #[test]
868    fn faces_split_across_materials() {
869        let mats = [material("red", red_byte()), material("blue", color_chunk(COLOR_BYTE, &[0, 0, 255]))];
870        // Faces 0..4 red, 4..9 blue, 11 in a group without a material, the
871        // rest (9, 10) in none.
872        let groups = [("red", all(4)), ("blue", (4..9).collect()), ("ghost", vec![11])];
873        let m = parse(&file(&mats, &groups, None)).unwrap();
874        let counts: Vec<_> = m.meshes.iter().map(|m| (m.material.as_ref().map(|m| m.name.clone()), m.triangle_count())).collect();
875        assert_eq!(counts, [(Some("red".into()), 4), (Some("blue".into()), 5), (None, 1), (None, 2)]);
876    }
877
878    #[test]
879    fn ungrouped_faces_make_a_mesh_without_material() {
880        let m = parse(&file(&[material("red", red_byte())], &[("red", all(4))], None)).unwrap();
881        let counts: Vec<_> = m.meshes.iter().map(|m| (m.material.is_some(), m.triangle_count())).collect();
882        assert_eq!(counts, [(true, 4), (false, 8)]);
883    }
884
885    #[test]
886    fn one_smoothing_group_rounds_the_corners() {
887        let m = parse(&file(&[], &[], Some(&[1; 12]))).unwrap();
888        assert_eq!(m.triangle_count(), 12);
889        assert_eq!(m.meshes[0].positions.len(), 36, "triangles are un-shared");
890        let ns = normals_at(&m, [0.0; 3]);
891        assert_eq!(ns.len(), 3);
892        let d = 1.0 / 3f32.sqrt();
893        for n in ns {
894            for c in n {
895                assert!((c + d).abs() < 1e-4, "{n:?}");
896            }
897        }
898    }
899
900    fn normals_at(m: &Model, p: V3) -> Vec<V3> {
901        let mesh = &m.meshes[0];
902        let n = mesh.normals.as_ref().unwrap();
903        mesh.indices.iter().filter(|&&i| mesh.positions[i as usize] == p).map(|&i| n[i as usize]).collect()
904    }
905
906    #[test]
907    fn distinct_groups_keep_faces_flat() {
908        let smooth: Vec<u32> = (0..12).map(|i| 1 << (i / 2)).collect();
909        check_flat(&parse(&file(&[], &[], Some(&smooth))).unwrap());
910    }
911
912    #[test]
913    fn smoothing_group_zero_is_flat() {
914        check_flat(&parse(&file(&[], &[], Some(&[0; 12]))).unwrap());
915    }
916
917    fn check_flat(m: &Model) {
918        let mesh = &m.meshes[0];
919        let n = mesh.normals.as_ref().unwrap();
920        for t in mesh.indices.as_chunks::<3>().0 {
921            let [a, b, c] = t.map(|i| mesh.positions[i as usize]);
922            let want = face_normal(a, b, c);
923            for &i in t {
924                assert_eq!(n[i as usize], want);
925            }
926        }
927    }
928
929    #[test]
930    fn smoothing_only_joins_faces_sharing_a_bit() {
931        // Sides 2 (y = 0) and 4 (z = 0) share bit 1 on top of their own, the
932        // others are alone: their common edge is rounded, the rest stays flat.
933        let smooth: Vec<u32> = (0..12).map(|i| (1 << (i / 2 + 1)) | if i / 2 == 2 || i / 2 == 4 { 1 } else { 0 }).collect();
934        let m = parse(&file(&[], &[], Some(&smooth))).unwrap();
935        let at = normals_at(&m, [0.0; 3]);
936        let d = 1.0 / 2f32.sqrt();
937        let rounded = at.iter().filter(|n| n[0].abs() < 1e-4 && (n[1] + d).abs() < 1e-4 && (n[2] + d).abs() < 1e-4).count();
938        assert_eq!(rounded, 2, "{at:?}");
939        assert!(at.iter().any(|n| (n[0] + 1.0).abs() < 1e-4), "the x = 0 side stays flat");
940    }
941
942    #[test]
943    fn truncations_and_corruption_do_not_panic() {
944        let full = file(&[material("red", red_byte())], &[("red", all(12))], Some(&[1; 12]));
945        assert!(parse(&full).is_ok());
946        for n in 0..full.len() {
947            let _ = parse(&full[..n]);
948        }
949        for i in 0..full.len() {
950            let mut f = full.clone();
951            f[i] ^= 0xFF;
952            let _ = parse(&f);
953        }
954    }
955
956    #[test]
957    fn bad_chunk_lengths_are_errors() {
958        for len in [0u32, 1, 5, u32::MAX] {
959            let mut f = red_cube();
960            f[2..6].copy_from_slice(&len.to_le_bytes());
961            assert!(parse(&f).is_err(), "main length {len}");
962            // The editor chunk right inside it.
963            let mut f = red_cube();
964            f[8..12].copy_from_slice(&len.to_le_bytes());
965            assert!(parse(&f).is_err(), "editor length {len}");
966        }
967    }
968
969    #[test]
970    fn huge_counts_are_refused() {
971        let mut verts = 65535u16.to_le_bytes().to_vec();
972        verts.extend([0u8; 24]);
973        assert!(read_vertices(&verts).is_err());
974        let mut o = Object::default();
975        let mut faces = 65535u16.to_le_bytes().to_vec();
976        faces.extend([0u8; 16]);
977        assert!(read_faces(&faces, &mut o).is_err());
978        let mut group = cstr("m");
979        group.extend(65535u16.to_le_bytes());
980        let mut faces = 0u16.to_le_bytes().to_vec();
981        faces.extend(chunk(FACE_MATERIAL, &group));
982        assert!(read_faces(&faces, &mut o).is_err());
983        // Through the whole reader.
984        let mut obj = cstr("x");
985        obj.extend(chunk(TRI_MESH, &chunk(VERTICES, &verts)));
986        let f = chunk(MAIN, &chunk(EDITOR, &chunk(OBJECT, &obj)));
987        assert!(parse(&f).is_err());
988    }
989
990    #[test]
991    fn out_of_range_faces_are_dropped() {
992        let mut o = Object {
993            positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
994            faces: vec![[0, 1, 2], [0, 1, 9]],
995            smoothing: Some(vec![1, 1]),
996            ..Object::default()
997        };
998        let meshes = build_object(&o, &[]);
999        assert_eq!(meshes.len(), 1);
1000        assert_eq!(meshes[0].triangle_count(), 1);
1001        o.smoothing = None;
1002        assert_eq!(build_object(&o, &[])[0].triangle_count(), 1);
1003    }
1004
1005    #[test]
1006    fn not_a_3ds_file() {
1007        assert!(parse(b"hello").is_err());
1008        assert!(parse(b"").is_err());
1009        assert!(parse(&chunk(EDITOR, &[])).is_err());
1010        assert!(parse(&chunk(MAIN, &[])).is_err(), "no triangles");
1011    }
1012
1013    #[test]
1014    fn keyframer_is_skipped() {
1015        let f = red_cube();
1016        let junk = chunk(0xB000, &[0xFF; 40]);
1017        let with = chunk(MAIN, &cat(&[junk.clone(), f[6..].to_vec(), junk]));
1018        assert_eq!(parse(&with).unwrap().triangle_count(), 12);
1019    }
1020}