git.lucas.co / cce-mesh-io
mesh files in: STL, OBJ, glTF and PLY
git clone https://git.lucas.co/cce-mesh-io.git

commit40032c7deb28dee609d963c8c694ce15cd84922c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 10:27
cce-mesh-io: STL, OBJ, glTF and PLY into one scene type

The readers cce-model shipped with, lifted out so cce-designer can import
what it exports, plus glTF/GLB and PLY (milestone 2 of the viewer's design
doc). No renderer and no cce-ui dependency.

A reader returns the file as it is: parts in the file's own coordinates,
and the up axis its format conventionally uses, reported rather than
applied, because cce-designer writes its Y-up world into STL as is and an
importer of those files must not turn them. load() welds each part.

- glTF: node tree walked with transforms (mirroring nodes keep outward
  faces), triangle lists/strips/fans, base-colour factor and COLOR_0;
  buffers loaded by hand from the GLB chunk, data URIs or files, so the
  crate's image decoders stay out. Textures wait for milestone 5.
- PLY: ASCII and both binary byte orders, any element layout (unknown
  elements and lists read past), vertex colours sRGB-decoded onto corners;
  a point cloud is an error that says so.
- Colours ride on corners, so welding never picks between two at a point.

tests/designer_round_trip.rs reads the designer's default project as its
own exporter wrote it: the STL and the OBJ come back as the same 362 points
and 720 triangles, with the same winding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 .cargo/config.toml                  |    7 +
 .gitignore                          |    2 +
 CLAUDE.md                           |   43 ++
 Cargo.toml                          |   17 +
 src/gltf.rs                         |  278 +++++++++
 src/lib.rs                          |   93 +++
 src/mesh.rs                         |  328 +++++++++++
 src/obj.rs                          |  133 +++++
 src/ply.rs                          |  345 +++++++++++
 src/stl.rs                          |  142 +++++
 tests/designer_round_trip.rs        |   63 ++
 tests/fixtures/designer-default.obj | 1110 +++++++++++++++++++++++++++++++++++
 tests/fixtures/designer-default.stl |  Bin 0 -> 36084 bytes
 13 files changed, 2561 insertions(+)

diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..93c6cb4
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,7 @@
+# git.lucas.co is static hosting, so it speaks git's dumb HTTP protocol only.
+# Cargo's built-in git client tries smart HTTP first and fails on the response
+# content-type; the git CLI handles dumb HTTP the same way `git clone` does.
+# Needed by anyone building this crate on its own, so it is committed here
+# rather than left to the installing machine's environment.
+[net]
+git-fetch-with-cli = true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..7642584
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,43 @@
+# cce-mesh-io
+
+Mesh files in, one scene type out: STL, OBJ (+ MTL colours), glTF/GLB and
+PLY. A library with no renderer and no cce-ui dependency, shared by
+cce-model (shows files) and, from milestone 6 of the viewer's design doc,
+cce-designer (imports them). Read the workspace guide
+(`../cce-compositor/WORKSPACE.md`) first; this covers only what is
+particular to this crate.
+
+## The contract
+
+- A reader returns a `Scene` **as the file has it**: parts in the file's own
+  coordinates and units, and `up` saying which way its format calls up
+  (`UpAxis::Z` for STL, `Y` for the rest). Readers never rotate, scale or
+  weld. `load()` welds each part, drops empty parts and names unnamed ones
+  after the file stem.
+- **Callers decide orientation.** cce-model turns a Z-up scene upright
+  (`Mesh::z_up_to_y_up`); an importer of the designer's own exports must
+  not, because cce-designer writes its Y-up world into STL as is (its STL
+  export is not Z-up — a slicer will lay those models on their side).
+- Colours are linear RGB. A palette colour per triangle (`tri_color` →
+  `colors`: MTL `Kd`, glTF base-colour factor) and, when the file colours
+  its points, `corner_colors` (three per triangle) which win. They ride on
+  corners so welding never has to pick between two colours at one point.
+  8-bit PLY colours are sRGB and decoded; float colours are taken as linear.
+- Errors are `String`s meant for a person: they name the line, the element
+  or the buffer that was wrong.
+
+## Not yet
+
+glTF base-colour textures (milestone 5: the viewer has no textured
+pipeline), PLY point clouds without faces (an error that says so), 3MF.
+
+## Tests
+
+`cargo test -p cce-mesh-io`. Unit tests sit beside each reader; glTF and
+GLB fixtures are built in the test (base64 buffer, hand-assembled GLB
+chunks). `tests/designer_round_trip.rs` reads the designer's default project
+as its own exporter wrote it (`tests/fixtures/designer-default.{stl,obj}`,
+made with `cce-designer --export default_project.json …`) and requires the
+STL and the OBJ to come back as the same 362 points and 720 triangles with
+the same winding. Regenerate the fixtures the same way if the exporter's
+format changes, and keep the counts in the test's doc in step.
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..5c21b2b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "cce-mesh-io"
+version = "0.1.0"
+edition = "2021"
+description = "Mesh files in and out for cce apps: STL, OBJ, glTF and PLY read into one scene type"
+
+[lib]
+name = "cce_mesh_io"
+path = "src/lib.rs"
+
+[dependencies]
+glam = "0.29"
+log = "0.4"
+# Without its default `import` feature: that pulls in the image decoders for
+# textures nothing here draws yet. Buffers are loaded by hand (`gltf.rs`).
+gltf = { version = "1.4", default-features = false, features = ["utils", "names"] }
+base64 = "0.22"
diff --git a/src/gltf.rs b/src/gltf.rs
new file mode 100644
index 0000000..4b0982c
--- /dev/null
+++ b/src/gltf.rs
@@ -0,0 +1,278 @@
+//! glTF 2.0, both forms: `.gltf` (JSON, buffers beside it or inline as
+//! base64 data URIs) and `.glb` (one binary file).
+//!
+//! The scene's node tree is walked and every mesh placed where its node's
+//! transforms put it, one [`Part`] per node with a mesh, named by the node
+//! (else the mesh). Triangle lists, strips and fans are read; points and
+//! lines are skipped. Each primitive's material contributes its base colour
+//! factor, and a `COLOR_0` attribute (multiplied by that factor, as the spec
+//! does) becomes corner colours. Base-colour TEXTURES are not read yet — a
+//! textured model draws in its factor alone, often plain white — which is
+//! milestone 5's work. A node whose transform mirrors (negative determinant)
+//! has its winding reversed, so its faces still point out. glTF is Y-up by
+//! its spec, in metres.
+//!
+//! Buffers are loaded here rather than by the crate's `import` feature,
+//! which would also decode every texture image.
+
+use std::path::Path;
+
+use base64::Engine as _;
+use glam::{Mat4, Vec3};
+use gltf::buffer::Source;
+use gltf::mesh::Mode;
+
+use crate::mesh::Mesh;
+use crate::{Part, Scene, UpAxis};
+
+/// Deeper than any real node tree; a guard against a cycle the parser let by.
+const MAX_DEPTH: usize = 128;
+
+pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
+    let file = gltf::Gltf::from_slice(bytes).map_err(|e| format!("not a readable glTF: {e}"))?;
+    let buffers = load_buffers(&file, dir)?;
+    let mut parts = Vec::new();
+    let mut skipped_modes = 0usize;
+    let mut textured = false;
+
+    let roots: Vec<gltf::Node> = match file.default_scene().or_else(|| file.scenes().next()) {
+        Some(scene) => scene.nodes().collect(),
+        None => {
+            // No scene: every node that is nobody's child is a root.
+            let children: std::collections::HashSet<usize> =
+                file.nodes().flat_map(|n| n.children().map(|c| c.index())).collect();
+            file.nodes().filter(|n| !children.contains(&n.index())).collect()
+        }
+    };
+    // Pushed in reverse, so the stack pops in pre-order: parts come out in
+    // the order the file lists them, parents before children.
+    let mut stack: Vec<(gltf::Node, Mat4, usize)> = roots.into_iter().rev().map(|n| (n, Mat4::IDENTITY, 0)).collect();
+    while let Some((node, parent, depth)) = stack.pop() {
+        if depth > MAX_DEPTH {
+            return Err("the node tree is too deep (a cycle?)".into());
+        }
+        let world = parent * Mat4::from_cols_array_2d(&node.transform().matrix());
+        let children: Vec<gltf::Node> = node.children().collect();
+        for child in children.into_iter().rev() {
+            stack.push((child, world, depth + 1));
+        }
+        let Some(gmesh) = node.mesh() else { continue };
+        let mirrored = world.determinant() < 0.0;
+        let mut mesh = Mesh::default();
+        let mut corner_colors: Vec<[f32; 3]> = Vec::new();
+        let mut any_colors = false;
+        for prim in gmesh.primitives() {
+            let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
+            let Some(positions) = reader.read_positions() else { continue };
+            let base = mesh.positions.len() as u32;
+            mesh.positions.extend(positions.map(|p| world.transform_point3(Vec3::from(p))));
+            let count = mesh.positions.len() as u32 - base;
+            let indices: Vec<u32> = match reader.read_indices() {
+                Some(i) => i.into_u32().collect(),
+                None => (0..count).collect(),
+            };
+            if let Some(&bad) = indices.iter().find(|&&i| i >= count) {
+                return Err(format!("mesh '{}' indexes point {bad} of {count}", gmesh.name().unwrap_or("?")));
+            }
+            let tris = triangles(prim.mode(), &indices);
+            let Some(tris) = tris else {
+                skipped_modes += 1;
+                continue;
+            };
+            let material = prim.material();
+            let pbr = material.pbr_metallic_roughness();
+            textured |= pbr.base_color_texture().is_some();
+            let [r, g, b, _] = pbr.base_color_factor();
+            let slot = mesh.colors.len() as u32;
+            mesh.colors.push([r, g, b]);
+            let point_colors: Option<Vec<[f32; 3]>> = reader.read_colors(0).map(|c| c.into_rgb_f32().collect());
+            for t in tris {
+                let t = if mirrored { [t[0], t[2], t[1]] } else { t };
+                for k in t {
+                    corner_colors.push(match &point_colors {
+                        Some(pc) => {
+                            any_colors = true;
+                            let c = pc.get(k as usize).copied().unwrap_or([1.0; 3]);
+                            [c[0] * r, c[1] * g, c[2] * b]
+                        }
+                        None => [r, g, b],
+                    });
+                }
+                mesh.triangles.push(t.map(|i| i + base));
+                mesh.tri_color.push(slot);
+            }
+        }
+        if any_colors {
+            mesh.corner_colors = Some(corner_colors);
+        }
+        let name = node.name().or(gmesh.name()).map(str::to_string).unwrap_or_else(|| format!("node {}", node.index()));
+        parts.push(Part { name, mesh });
+    }
+
+    if skipped_modes > 0 {
+        log::info!("[mesh-io] glTF: skipped {skipped_modes} primitives of points or lines");
+    }
+    if textured {
+        log::info!("[mesh-io] glTF: base-colour textures are not drawn yet; textured materials show their factor");
+    }
+    Ok(Scene { parts, up: UpAxis::Y })
+}
+
+/// A primitive's index list as triangles, or `None` for points and lines.
+fn triangles(mode: Mode, i: &[u32]) -> Option<Vec<[u32; 3]>> {
+    Some(match mode {
+        Mode::Triangles => i.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect(),
+        // Every other triangle of a strip is reversed to keep the winding.
+        Mode::TriangleStrip => (0..i.len().saturating_sub(2))
+            .map(|k| if k % 2 == 0 { [i[k], i[k + 1], i[k + 2]] } else { [i[k + 1], i[k], i[k + 2]] })
+            .collect(),
+        Mode::TriangleFan => (1..i.len().saturating_sub(1)).map(|k| [i[0], i[k], i[k + 1]]).collect(),
+        Mode::Points | Mode::Lines | Mode::LineLoop | Mode::LineStrip => return None,
+    })
+}
+
+fn load_buffers(file: &gltf::Gltf, dir: Option<&Path>) -> Result<Vec<Vec<u8>>, String> {
+    file.buffers()
+        .map(|b| {
+            let data = match b.source() {
+                Source::Bin => file.blob.clone().ok_or("the file names a binary chunk it does not have")?,
+                Source::Uri(uri) if uri.starts_with("data:") => {
+                    let (_, payload) = uri.split_once(";base64,").ok_or("a data URI that is not base64")?;
+                    base64::engine::general_purpose::STANDARD
+                        .decode(payload)
+                        .map_err(|e| format!("a bad base64 buffer: {e}"))?
+                }
+                Source::Uri(uri) => {
+                    let path = dir.unwrap_or(Path::new(".")).join(percent_decode(uri));
+                    std::fs::read(&path).map_err(|e| format!("buffer {}: {e}", path.display()))?
+                }
+            };
+            if data.len() < b.length() {
+                return Err(format!("buffer {} is {} bytes, shorter than the {} it declares", b.index(), data.len(), b.length()));
+            }
+            Ok(data)
+        })
+        .collect()
+}
+
+/// `my%20model.bin` → `my model.bin`: URIs in a glTF are percent-encoded.
+fn percent_decode(s: &str) -> String {
+    let b = s.as_bytes();
+    let mut out = Vec::with_capacity(b.len());
+    let mut i = 0;
+    while i < b.len() {
+        if b[i] == b'%' && i + 2 < b.len() {
+            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
+                out.push(v);
+                i += 3;
+                continue;
+            }
+        }
+        out.push(b[i]);
+        i += 1;
+    }
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// One triangle's points and indices as a base64 buffer, and the JSON
+    /// that describes it, with `nodes` and `extra` spliced in.
+    fn gltf_json(nodes: &str, mode: u32, extra: &str) -> (String, Vec<u8>) {
+        let mut bin = Vec::new();
+        for p in [[0f32, 0., 0.], [1., 0., 0.], [0., 1., 0.], [1., 1., 0.]] {
+            for c in p {
+                bin.extend(c.to_le_bytes());
+            }
+        }
+        for i in [0u16, 1, 2, 3] {
+            bin.extend(i.to_le_bytes());
+        }
+        let json = format!(
+            r#"{{"asset":{{"version":"2.0"}},"scene":0,"scenes":[{{"nodes":[0]}}],"nodes":{nodes},
+            "meshes":[{{"name":"tri","primitives":[{{"attributes":{{"POSITION":0}},"indices":1,"mode":{mode},"material":0}}]}}],
+            "materials":[{{"pbrMetallicRoughness":{{"baseColorFactor":[0.8,0.2,0.1,1.0]}}}}],
+            "accessors":[{{"bufferView":0,"componentType":5126,"count":4,"type":"VEC3","min":[0,0,0],"max":[1,1,0]}},
+                         {{"bufferView":1,"componentType":5123,"count":4,"type":"SCALAR"}}],
+            "bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":48}},{{"buffer":0,"byteOffset":48,"byteLength":8}}],
+            "buffers":[{{"byteLength":56{extra}}}]}}"#
+        );
+        (json, bin)
+    }
+
+    fn inline(nodes: &str, mode: u32) -> Vec<u8> {
+        let (_, bin) = gltf_json(nodes, mode, "");
+        let uri = format!(r#","uri":"data:application/octet-stream;base64,{}""#, base64::engine::general_purpose::STANDARD.encode(&bin));
+        gltf_json(nodes, mode, &uri).0.into_bytes()
+    }
+
+    #[test]
+    fn a_node_transform_places_the_mesh_and_the_material_colours_it() {
+        let s = read(&inline(r#"[{"name":"moved","mesh":0,"translation":[10,0,0],"scale":[2,2,2]}]"#, 5), None).unwrap();
+        assert_eq!(s.up, UpAxis::Y);
+        let part = &s.parts[0];
+        assert_eq!(part.name, "moved");
+        assert_eq!(part.mesh.positions[1], Vec3::new(12.0, 0.0, 0.0));
+        assert_eq!(part.mesh.triangles.len(), 2, "a strip of four points is two triangles");
+        assert_eq!(part.mesh.corner_color(0, 0), [0.8, 0.2, 0.1]);
+    }
+
+    #[test]
+    fn children_inherit_their_parents_transform() {
+        let nodes = r#"[{"translation":[0,5,0],"children":[1]},{"mesh":0,"translation":[1,0,0]}]"#;
+        let s = read(&inline(nodes, 4), None).unwrap();
+        assert_eq!(s.parts.len(), 1);
+        assert_eq!(s.parts[0].name, "tri", "an unnamed node takes its mesh's name");
+        assert_eq!(s.parts[0].mesh.positions[0], Vec3::new(1.0, 5.0, 0.0));
+    }
+
+    #[test]
+    fn a_mirroring_node_keeps_faces_pointing_out() {
+        let plain = read(&inline(r#"[{"mesh":0}]"#, 4), None).unwrap();
+        let mirrored = read(&inline(r#"[{"mesh":0,"scale":[-1,1,1]}]"#, 4), None).unwrap();
+        let normal = |m: &Mesh| {
+            let [a, b, c] = m.triangles[0].map(|i| m.positions[i as usize]);
+            (b - a).cross(c - a).normalize()
+        };
+        assert_eq!(normal(&plain.parts[0].mesh), Vec3::Z);
+        assert_eq!(normal(&mirrored.parts[0].mesh), Vec3::Z, "the mirror flipped the winding back");
+    }
+
+    #[test]
+    fn a_glb_is_read_from_its_binary_chunk() {
+        let (json, bin) = gltf_json(r#"[{"mesh":0}]"#, 4, "");
+        let mut json = json.into_bytes();
+        while json.len() % 4 != 0 {
+            json.push(b' ');
+        }
+        let mut glb = Vec::new();
+        glb.extend(b"glTF");
+        glb.extend(2u32.to_le_bytes());
+        glb.extend(((12 + 8 + json.len() + 8 + bin.len()) as u32).to_le_bytes());
+        glb.extend((json.len() as u32).to_le_bytes());
+        glb.extend(b"JSON");
+        glb.extend(&json);
+        glb.extend((bin.len() as u32).to_le_bytes());
+        glb.extend(b"BIN\0");
+        glb.extend(&bin);
+        let s = read(&glb, None).unwrap();
+        assert_eq!(s.parts[0].mesh.triangles, vec![[0, 1, 2]]);
+    }
+
+    #[test]
+    fn lines_are_skipped_and_a_missing_buffer_file_is_an_error() {
+        let s = read(&inline(r#"[{"mesh":0}]"#, 1), None).unwrap();
+        assert!(s.parts[0].mesh.triangles.is_empty());
+        let (json, _) = gltf_json(r#"[{"mesh":0}]"#, 4, r#","uri":"nowhere.bin""#);
+        let e = read(json.as_bytes(), Some(Path::new("/nonexistent"))).unwrap_err();
+        assert!(e.contains("nowhere.bin"), "{e}");
+    }
+
+    #[test]
+    fn percent_escapes_are_decoded() {
+        assert_eq!(percent_decode("my%20model.bin"), "my model.bin");
+        assert_eq!(percent_decode("100%"), "100%");
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..668b9e4
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,93 @@
+//! Mesh files in, one scene type out, for every cce app that reads geometry:
+//! cce-model shows it, cce-designer imports it.
+//!
+//! Each reader returns a [`Scene`] as the file has it: named parts, in the
+//! file's own coordinates and units, with the up axis its format conventionally
+//! uses. Nothing is rotated, scaled or welded by a reader; [`load`] welds, and
+//! what to do about the up axis is the caller's choice ([`Mesh::z_up_to_y_up`]).
+//! A viewer turns a Z-up STL upright; an editor importing its own Y-up export
+//! must not.
+//!
+//! This crate draws nothing and knows no renderer, so a headless tool can use
+//! it as freely as an app.
+
+mod gltf;
+mod mesh;
+mod obj;
+mod ply;
+mod stl;
+
+use std::path::Path;
+
+pub use mesh::{srgb_to_linear, Mesh, CLAY};
+
+/// File extensions [`load`] reads, lowercase.
+pub const EXTENSIONS: &[&str] = &["stl", "obj", "gltf", "glb", "ply"];
+
+/// Which way is up in a file's coordinates, by its format's convention.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UpAxis {
+    /// OBJ, glTF (by its spec) and PLY.
+    Y,
+    /// STL: it comes from CAD and goes to printers, which build along Z.
+    Z,
+}
+
+/// One named piece of a scene: a glTF node's mesh, a whole STL.
+#[derive(Debug, Clone, Default)]
+pub struct Part {
+    pub name: String,
+    pub mesh: Mesh,
+}
+
+/// What a file holds: its parts, already placed where the file's node tree
+/// puts them, and its up axis.
+#[derive(Debug, Clone)]
+pub struct Scene {
+    pub parts: Vec<Part>,
+    pub up: UpAxis,
+}
+
+impl Scene {
+    pub fn triangle_count(&self) -> usize {
+        self.parts.iter().map(|p| p.mesh.triangles.len()).sum()
+    }
+
+    /// Every part in one mesh. Parts are not welded to each other, so two
+    /// parts that touch keep separate normals where they meet.
+    pub fn merged(&self) -> Mesh {
+        let mut out = Mesh::default();
+        for part in &self.parts {
+            out.append(&part.mesh);
+        }
+        out
+    }
+}
+
+/// Read `path` by its extension, weld each part, and name an unnamed part
+/// after the file.
+pub fn load(path: &Path) -> Result<Scene, String> {
+    let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
+    let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
+    let dir = path.parent();
+    let mut scene = match ext.as_str() {
+        "stl" => stl::read(&bytes)?,
+        "obj" => obj::read(&bytes, dir)?,
+        "gltf" | "glb" => gltf::read(&bytes, dir)?,
+        "ply" => ply::read(&bytes)?,
+        "" => return Err("no file extension, so no way to tell the format".into()),
+        other => return Err(format!("cannot read .{other} files")),
+    };
+    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("model");
+    for part in &mut scene.parts {
+        part.mesh.weld();
+        if part.name.is_empty() {
+            part.name = stem.to_string();
+        }
+    }
+    scene.parts.retain(|p| !p.mesh.triangles.is_empty());
+    if scene.parts.is_empty() {
+        return Err("the file holds no triangles".into());
+    }
+    Ok(scene)
+}
diff --git a/src/mesh.rs b/src/mesh.rs
new file mode 100644
index 0000000..dfc8fec
--- /dev/null
+++ b/src/mesh.rs
@@ -0,0 +1,328 @@
+//! The geometry every reader produces: a triangle mesh with a colour per
+//! triangle and, when the file has them, a colour per corner.
+//!
+//! ## Why meshes are welded
+//!
+//! An STL has no shared points: each triangle carries its own corners. A
+//! smooth normal is the average of the faces meeting at a point, which needs
+//! those faces to name the same point, so [`crate::load`] welds every part by
+//! position. Colours ride on CORNERS, not points, so welding never has to
+//! choose between two colours meeting at one place.
+
+use std::collections::HashMap;
+
+use glam::Vec3;
+
+/// The colour of a surface that names none (an STL; an OBJ without a
+/// material), linear RGB: a warm clay.
+pub const CLAY: [f32; 3] = [0.42, 0.40, 0.36];
+
+#[derive(Debug, Clone, Default)]
+pub struct Mesh {
+    pub positions: Vec<Vec3>,
+    /// Counter-clockwise seen from outside.
+    pub triangles: Vec<[u32; 3]>,
+    /// One per triangle: an index into `colors`.
+    pub tri_color: Vec<u32>,
+    /// The palette `tri_color` indexes, linear RGB: a material's colour each.
+    pub colors: Vec<[f32; 3]>,
+    /// Three per triangle, in `triangles` order, linear RGB, when the file
+    /// colours its points (PLY, glTF `COLOR_0`). They replace the palette
+    /// colour where present.
+    pub corner_colors: Option<Vec<[f32; 3]>>,
+}
+
+impl Mesh {
+    /// The axis-aligned box around every point, or `None` for no points.
+    pub fn bounds(&self) -> Option<(Vec3, Vec3)> {
+        let first = *self.positions.first()?;
+        Some(self.positions.iter().fold((first, first), |(lo, hi), p| (lo.min(*p), hi.max(*p))))
+    }
+
+    /// The colour of corner `k` (0..3) of triangle `t`.
+    pub fn corner_color(&self, t: usize, k: usize) -> [f32; 3] {
+        if let Some(c) = &self.corner_colors {
+            return c[t * 3 + k];
+        }
+        self.colors.get(self.tri_color[t] as usize).copied().unwrap_or(CLAY)
+    }
+
+    /// Add `other`'s triangles to this mesh, its points and palette after
+    /// this one's. Corner colours survive: if either side has them, the
+    /// other's triangles are given their palette colours as corner colours.
+    pub fn append(&mut self, other: &Mesh) {
+        let base = self.positions.len() as u32;
+        let palette = self.colors.len() as u32;
+        if self.corner_colors.is_some() || other.corner_colors.is_some() {
+            let mine = self.expanded_corner_colors();
+            let theirs = other.expanded_corner_colors();
+            self.corner_colors = Some(mine.into_iter().chain(theirs).collect());
+        }
+        self.positions.extend_from_slice(&other.positions);
+        self.triangles.extend(other.triangles.iter().map(|t| t.map(|i| i + base)));
+        self.tri_color.extend(other.tri_color.iter().map(|c| c + palette));
+        self.colors.extend_from_slice(&other.colors);
+    }
+
+    fn expanded_corner_colors(&self) -> Vec<[f32; 3]> {
+        if let Some(c) = &self.corner_colors {
+            return c.clone();
+        }
+        (0..self.triangles.len()).flat_map(|t| [0, 1, 2].map(|k| self.corner_color(t, k))).collect()
+    }
+
+    /// Turn a Z-up mesh Y-up: (x, y, z) → (x, z, −y). A rotation, not a
+    /// mirror, so the winding and with it the outward side of every face are
+    /// kept.
+    pub fn z_up_to_y_up(&mut self) {
+        for p in &mut self.positions {
+            *p = Vec3::new(p.x, p.z, -p.y);
+        }
+    }
+
+    /// Move and scale the mesh so its bounding box is centred on the origin
+    /// and its farthest point is 1 from it, and return the (centre, radius)
+    /// it had, so its real size can still be reported. The radius is the
+    /// farthest point's, not the box's half-diagonal: a sphere's box corners
+    /// sit 1.7 times farther out than any of its points.
+    pub fn fit_to_unit(&mut self) -> (Vec3, f32) {
+        let Some((lo, hi)) = self.bounds() else { return (Vec3::ZERO, 1.0) };
+        let centre = (lo + hi) / 2.0;
+        let radius = self.positions.iter().map(|p| p.distance(centre)).fold(0.0, f32::max).max(f32::MIN_POSITIVE);
+        for p in &mut self.positions {
+            *p = (*p - centre) / radius;
+        }
+        (centre, radius)
+    }
+
+    /// Merge points that sit at the same place (to a millionth of the
+    /// mesh's size) and drop the triangles that collapse doing so.
+    pub fn weld(&mut self) {
+        let Some((lo, hi)) = self.bounds() else { return };
+        let cell = ((hi - lo).length() * 1e-6).max(f32::MIN_POSITIVE);
+        let key = |p: Vec3| {
+            let q = (p - lo) / cell;
+            [q.x.round() as i64, q.y.round() as i64, q.z.round() as i64]
+        };
+        // Sized for a closed mesh's usual one point per six corners: an STL
+        // of millions of triangles would otherwise rehash its way up.
+        let mut index: HashMap<[i64; 3], u32> = HashMap::with_capacity(self.positions.len() / 4);
+        let mut positions = Vec::with_capacity(self.positions.len() / 4);
+        let remap: Vec<u32> = self
+            .positions
+            .iter()
+            .map(|p| {
+                *index.entry(key(*p)).or_insert_with(|| {
+                    positions.push(*p);
+                    (positions.len() - 1) as u32
+                })
+            })
+            .collect();
+        drop(index);
+        let mut triangles = Vec::with_capacity(self.triangles.len());
+        let mut tri_color = Vec::with_capacity(self.triangles.len());
+        let mut corner_colors = self.corner_colors.as_ref().map(|c| Vec::with_capacity(c.len()));
+        for (t, tri) in self.triangles.iter().enumerate() {
+            let [a, b, c] = tri.map(|i| remap[i as usize]);
+            if a != b && b != c && a != c {
+                triangles.push([a, b, c]);
+                tri_color.push(self.tri_color[t]);
+                if let (Some(out), Some(src)) = (&mut corner_colors, &self.corner_colors) {
+                    out.extend_from_slice(&src[t * 3..t * 3 + 3]);
+                }
+            }
+        }
+        positions.shrink_to_fit();
+        self.positions = positions;
+        self.triangles = triangles;
+        self.tri_color = tri_color;
+        self.corner_colors = corner_colors;
+    }
+
+    /// A normal for each triangle corner (three per triangle, in order):
+    /// the area-weighted average of the faces at that point which meet this
+    /// triangle within `crease_degrees`, so a cube keeps its edges and a
+    /// sphere's facets blend.
+    pub fn corner_normals(&self, crease_degrees: f32) -> Vec<Vec3> {
+        let crease_cos = crease_degrees.to_radians().cos();
+        // The cross product's length is twice the area, so summing raw
+        // crosses weights each face by its area.
+        let cross: Vec<Vec3> = self
+            .triangles
+            .iter()
+            .map(|t| {
+                let [a, b, c] = t.map(|i| self.positions[i as usize]);
+                (b - a).cross(c - a)
+            })
+            .collect();
+        let unit: Vec<Vec3> = cross.iter().map(|n| n.normalize_or_zero()).collect();
+
+        // Faces at each point, as one flat list with offsets.
+        let mut start = vec![0u32; self.positions.len() + 1];
+        for t in &self.triangles {
+            for &v in t {
+                start[v as usize + 1] += 1;
+            }
+        }
+        for i in 1..start.len() {
+            start[i] += start[i - 1];
+        }
+        let mut fill = start.clone();
+        let mut faces = vec![0u32; self.triangles.len() * 3];
+        for (f, t) in self.triangles.iter().enumerate() {
+            for &v in t {
+                faces[fill[v as usize] as usize] = f as u32;
+                fill[v as usize] += 1;
+            }
+        }
+
+        let mut out = Vec::with_capacity(self.triangles.len() * 3);
+        for (f, t) in self.triangles.iter().enumerate() {
+            for &v in t {
+                let around = &faces[start[v as usize] as usize..start[v as usize + 1] as usize];
+                let sum: Vec3 = around
+                    .iter()
+                    .filter(|&&g| unit[g as usize].dot(unit[f]) >= crease_cos)
+                    .map(|&g| cross[g as usize])
+                    .sum();
+                out.push(sum.try_normalize().unwrap_or(unit[f]));
+            }
+        }
+        out
+    }
+}
+
+/// An 8-bit sRGB channel (as PLY colours are stored) as linear 0..1.
+pub fn srgb_to_linear(c: u8) -> f32 {
+    let c = c as f32 / 255.0;
+    if c <= 0.04045 {
+        c / 12.92
+    } else {
+        ((c + 0.055) / 1.055).powf(2.4)
+    }
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+    use super::*;
+
+    /// A unit cube as an STL would carry it: twelve triangles, every corner
+    /// its own point.
+    pub(crate) fn soup_cube() -> Mesh {
+        let c = |x: f32, y: f32, z: f32| Vec3::new(x, y, z);
+        let quads = [
+            [c(1., 0., 0.), c(1., 1., 0.), c(1., 1., 1.), c(1., 0., 1.)],
+            [c(0., 0., 1.), c(0., 1., 1.), c(0., 1., 0.), c(0., 0., 0.)],
+            [c(0., 1., 0.), c(0., 1., 1.), c(1., 1., 1.), c(1., 1., 0.)],
+            [c(0., 0., 1.), c(0., 0., 0.), c(1., 0., 0.), c(1., 0., 1.)],
+            [c(0., 0., 1.), c(1., 0., 1.), c(1., 1., 1.), c(0., 1., 1.)],
+            [c(1., 0., 0.), c(0., 0., 0.), c(0., 1., 0.), c(1., 1., 0.)],
+        ];
+        let mut m = Mesh::default();
+        for q in quads {
+            for i in [0, 1, 2, 0, 2, 3] {
+                m.positions.push(q[i]);
+            }
+        }
+        m.triangles = (0..12).map(|t| [t * 3, t * 3 + 1, t * 3 + 2]).collect();
+        m.tri_color = vec![0; 12];
+        m.colors = vec![CLAY];
+        m
+    }
+
+    #[test]
+    fn welding_a_cube_leaves_its_eight_corners() {
+        let mut m = soup_cube();
+        assert_eq!(m.positions.len(), 36);
+        m.weld();
+        assert_eq!(m.positions.len(), 8);
+        assert_eq!(m.triangles.len(), 12);
+    }
+
+    #[test]
+    fn a_cube_keeps_its_edges_and_its_faces_point_out() {
+        let mut m = soup_cube();
+        m.weld();
+        let normals = m.corner_normals(40.0);
+        let (lo, hi) = m.bounds().unwrap();
+        let center = (lo + hi) / 2.0;
+        for (f, t) in m.triangles.iter().enumerate() {
+            let [a, b, c] = t.map(|i| m.positions[i as usize]);
+            let face = (b - a).cross(c - a).normalize();
+            assert!(face.dot((a + b + c) / 3.0 - center) > 0.0, "triangle {f} faces inward");
+            for k in 0..3 {
+                assert!(normals[f * 3 + k].dot(face) > 0.999, "triangle {f} corner {k} was smoothed");
+            }
+        }
+    }
+
+    #[test]
+    fn a_shallow_fold_is_smoothed() {
+        let mut m = Mesh::default();
+        let lift = 20f32.to_radians().tan();
+        m.positions = vec![Vec3::ZERO, Vec3::Z, Vec3::new(-1.0, 0.0, 0.5), Vec3::new(1.0, lift, 0.5)];
+        m.triangles = vec![[0, 1, 2], [0, 3, 1]];
+        m.tri_color = vec![0, 0];
+        let n = m.corner_normals(40.0);
+        assert!(n[0].dot(n[3]) > 0.9999, "the shared point has two normals");
+    }
+
+    #[test]
+    fn welding_drops_a_collapsed_triangle_and_its_corner_colours() {
+        let mut m = Mesh::default();
+        m.positions = vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::X, Vec3::X * (1.0 + 1e-9), Vec3::Y];
+        m.triangles = vec![[0, 1, 2], [3, 4, 5]];
+        m.tri_color = vec![0, 0];
+        m.corner_colors = Some(vec![[1.0, 0.0, 0.0]; 3].into_iter().chain(vec![[0.0, 1.0, 0.0]; 3]).collect());
+        m.weld();
+        assert_eq!(m.triangles, vec![[0, 1, 2]]);
+        assert_eq!(m.corner_colors.unwrap(), vec![[1.0, 0.0, 0.0]; 3]);
+    }
+
+    #[test]
+    fn a_fitted_mesh_lies_in_the_unit_sphere() {
+        let mut m = soup_cube();
+        for p in &mut m.positions {
+            *p = *p * 25.0 + Vec3::new(-12.5, -12.5, 0.0);
+        }
+        let (centre, radius) = m.fit_to_unit();
+        assert_eq!(centre, Vec3::new(0.0, 0.0, 12.5));
+        assert!((radius - 25.0 * 3f32.sqrt() / 2.0).abs() < 1e-3, "a cube's corners are its farthest points");
+        assert!(m.positions.iter().all(|p| p.length() <= 1.0 + 1e-5));
+    }
+
+    #[test]
+    fn z_up_becomes_y_up_without_turning_faces_inside_out() {
+        let mut m = soup_cube();
+        m.z_up_to_y_up();
+        let mut w = m.clone();
+        w.weld();
+        let (lo, hi) = w.bounds().unwrap();
+        assert_eq!((lo, hi), (Vec3::new(0.0, 0.0, -1.0), Vec3::new(1.0, 1.0, 0.0)));
+        let centre = (lo + hi) / 2.0;
+        for t in &w.triangles {
+            let [a, b, c] = t.map(|i| w.positions[i as usize]);
+            assert!((b - a).cross(c - a).dot((a + b + c) / 3.0 - centre) > 0.0);
+        }
+    }
+
+    #[test]
+    fn appending_keeps_colours_on_both_sides() {
+        let mut a = soup_cube();
+        let mut b = soup_cube();
+        b.colors = vec![[0.0, 0.0, 1.0]];
+        b.corner_colors = Some(vec![[0.0, 1.0, 0.0]; 36]);
+        a.append(&b);
+        assert_eq!(a.triangles.len(), 24);
+        assert_eq!(a.triangles[12], [36, 37, 38]);
+        assert_eq!(a.corner_color(0, 0), CLAY, "the first mesh's palette colour, as a corner colour");
+        assert_eq!(a.corner_color(12, 0), [0.0, 1.0, 0.0]);
+    }
+
+    #[test]
+    fn srgb_round_numbers() {
+        assert_eq!(srgb_to_linear(0), 0.0);
+        assert!((srgb_to_linear(255) - 1.0).abs() < 1e-6);
+        assert!((srgb_to_linear(188) - 0.5).abs() < 0.01);
+    }
+}
diff --git a/src/obj.rs b/src/obj.rs
new file mode 100644
index 0000000..f614ef6
--- /dev/null
+++ b/src/obj.rs
@@ -0,0 +1,133 @@
+//! Wavefront OBJ, with its MTL colours.
+//!
+//! What is read: `v` points, `f` faces (any polygon, fanned into triangles;
+//! `i`, `i/t`, `i//n` and `i/t/n` corners; negative indices counting back
+//! from the newest point), `usemtl`, and `mtllib` for each material's `Kd`
+//! diffuse colour. Texture coordinates and the file's normals are skipped:
+//! the viewer derives normals itself and draws no textures yet. A missing
+//! MTL file is not an error; its materials fall back to clay. One part for
+//! the whole file, named by its first `o`; the scene is [`UpAxis::Y`].
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use glam::Vec3;
+
+use crate::mesh::{Mesh, CLAY};
+use crate::{Part, Scene, UpAxis};
+
+pub fn read(bytes: &[u8], dir: Option<&Path>) -> Result<Scene, String> {
+    let text = String::from_utf8_lossy(bytes);
+    let mut mesh = Mesh { colors: vec![CLAY], ..Mesh::default() };
+    // Material name → index into `mesh.colors`, filled as `mtllib`s are read.
+    let mut library: HashMap<String, [f32; 3]> = HashMap::new();
+    let mut slot: HashMap<String, u32> = HashMap::new();
+    let mut current = 0u32;
+    let mut corners: Vec<u32> = Vec::new();
+    let mut name = String::new();
+
+    for (line_no, line) in text.lines().enumerate() {
+        let line = line.split('#').next().unwrap_or("");
+        let mut words = line.split_whitespace();
+        let err = |what: &str| format!("line {}: {what}", line_no + 1);
+        match words.next() {
+            Some("v") => {
+                let mut xyz = [0f32; 3];
+                for v in &mut xyz {
+                    *v = words.next().and_then(|w| w.parse().ok()).ok_or_else(|| err("a point needs three numbers"))?;
+                }
+                mesh.positions.push(Vec3::from(xyz));
+            }
+            Some("f") => {
+                corners.clear();
+                for w in words {
+                    let i: i64 = w.split('/').next().and_then(|s| s.parse().ok()).ok_or_else(|| err("a bad face corner"))?;
+                    let n = mesh.positions.len() as i64;
+                    let index = if i > 0 { i - 1 } else { n + i };
+                    if i == 0 || !(0..n).contains(&index) {
+                        return Err(err(&format!("face corner {i} names no point (there are {n} so far)")));
+                    }
+                    corners.push(index as u32);
+                }
+                if corners.len() < 3 {
+                    return Err(err("a face needs three corners"));
+                }
+                for k in 1..corners.len() - 1 {
+                    mesh.triangles.push([corners[0], corners[k], corners[k + 1]]);
+                    mesh.tri_color.push(current);
+                }
+            }
+            Some("o") if name.is_empty() => name = words.collect::<Vec<_>>().join(" "),
+            Some("mtllib") => {
+                // A library name may hold spaces, so take the rest of the line.
+                let name = line.trim_start().strip_prefix("mtllib").unwrap_or("").trim();
+                if let Some(dir) = dir {
+                    match std::fs::read_to_string(dir.join(name)) {
+                        Ok(mtl) => library.extend(read_mtl(&mtl)),
+                        Err(e) => log::warn!("[model] material library {name}: {e}"),
+                    }
+                }
+            }
+            Some("usemtl") => {
+                let name = words.next().unwrap_or("").to_string();
+                current = *slot.entry(name.clone()).or_insert_with(|| {
+                    mesh.colors.push(library.get(&name).copied().unwrap_or(CLAY));
+                    (mesh.colors.len() - 1) as u32
+                });
+            }
+            _ => {}
+        }
+    }
+    Ok(Scene { parts: vec![Part { name, mesh }], up: UpAxis::Y })
+}
+
+/// Each `newmtl` in an MTL file and its `Kd`.
+fn read_mtl(text: &str) -> HashMap<String, [f32; 3]> {
+    let mut out = HashMap::new();
+    let mut name: Option<String> = None;
+    for line in text.lines() {
+        let mut words = line.split_whitespace();
+        match words.next() {
+            Some("newmtl") => name = words.next().map(str::to_string),
+            Some("Kd") => {
+                let rgb: Vec<f32> = words.take(3).filter_map(|w| w.parse().ok()).collect();
+                if let (Some(n), [r, g, b]) = (&name, rgb.as_slice()) {
+                    out.insert(n.clone(), [*r, *g, *b]);
+                }
+            }
+            _ => {}
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_quad_is_two_triangles_and_negative_indices_count_back() {
+        let s = read(b"o quad\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf -4/1/1 -3/2/1 -2/3/1 -1/4/1\n", None).unwrap();
+        assert_eq!(s.parts[0].name, "quad");
+        assert_eq!(s.parts[0].mesh.triangles, vec![[0, 1, 2], [0, 2, 3]]);
+    }
+
+    #[test]
+    fn materials_colour_their_faces() {
+        let dir = std::env::temp_dir().join(format!("cce-model-obj-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        std::fs::write(dir.join("m.mtl"), "newmtl red\nKd 0.8 0.1 0.1\n").unwrap();
+        let obj = b"mtllib m.mtl\nv 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\nusemtl red\nf 1 3 2\nusemtl missing\nf 2 1 3\n";
+        let m = read(obj, Some(&dir)).unwrap().parts.remove(0).mesh;
+        std::fs::remove_dir_all(&dir).ok();
+        assert_eq!(m.colors[m.tri_color[0] as usize], CLAY, "before any usemtl");
+        assert_eq!(m.colors[m.tri_color[1] as usize], [0.8, 0.1, 0.1]);
+        assert_eq!(m.colors[m.tri_color[2] as usize], CLAY, "a material the library lacks");
+    }
+
+    #[test]
+    fn a_face_past_the_points_is_an_error() {
+        let e = read(b"v 0 0 0\nv 1 0 0\nf 1 2 3\n", None).unwrap_err();
+        assert!(e.contains("line 3") && e.contains("names no point"), "{e}");
+    }
+}
diff --git a/src/ply.rs b/src/ply.rs
new file mode 100644
index 0000000..fb3039f
--- /dev/null
+++ b/src/ply.rs
@@ -0,0 +1,345 @@
+//! PLY (Stanford polygon file), ASCII and binary of either byte order.
+//!
+//! A PLY file declares its own layout: a header lists elements (`vertex`,
+//! `face`, and anything else a tool chose to add), each with a count and
+//! typed properties, and the body follows in that order. So the reader
+//! parses the header into that layout and walks the body by it, keeping
+//! `x y z` and any `red green blue` of each vertex and the index list of each
+//! face, and reading past everything else — an unknown element still has to
+//! be read to find where the next one starts.
+//!
+//! Colours stored as 8-bit (the usual) are sRGB and are decoded to linear;
+//! colours stored as floats are taken as linear already. They ride on
+//! corners. A file of points with no faces is an error for now: the viewer
+//! draws no point clouds yet. The scene is [`UpAxis::Y`].
+
+use glam::Vec3;
+
+use crate::mesh::{srgb_to_linear, Mesh, CLAY};
+use crate::{Part, Scene, UpAxis};
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum Scalar {
+    I8,
+    U8,
+    I16,
+    U16,
+    I32,
+    U32,
+    F32,
+    F64,
+}
+
+impl Scalar {
+    fn parse(name: &str) -> Option<Scalar> {
+        Some(match name {
+            "char" | "int8" => Scalar::I8,
+            "uchar" | "uint8" => Scalar::U8,
+            "short" | "int16" => Scalar::I16,
+            "ushort" | "uint16" => Scalar::U16,
+            "int" | "int32" => Scalar::I32,
+            "uint" | "uint32" => Scalar::U32,
+            "float" | "float32" => Scalar::F32,
+            "double" | "float64" => Scalar::F64,
+            _ => return None,
+        })
+    }
+
+    fn size(self) -> usize {
+        match self {
+            Scalar::I8 | Scalar::U8 => 1,
+            Scalar::I16 | Scalar::U16 => 2,
+            Scalar::I32 | Scalar::U32 | Scalar::F32 => 4,
+            Scalar::F64 => 8,
+        }
+    }
+}
+
+#[derive(Debug)]
+enum Property {
+    Scalar { name: String, ty: Scalar },
+    List { name: String, count: Scalar, item: Scalar },
+}
+
+#[derive(Debug)]
+struct Element {
+    name: String,
+    count: usize,
+    properties: Vec<Property>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum Encoding {
+    Ascii,
+    Little,
+    Big,
+}
+
+/// The body, read one value at a time in whichever encoding the file uses.
+struct Body<'a> {
+    bytes: &'a [u8],
+    at: usize,
+    encoding: Encoding,
+}
+
+impl Body<'_> {
+    fn value(&mut self, ty: Scalar) -> Result<f64, String> {
+        if self.encoding == Encoding::Ascii {
+            return self.word().and_then(|w| w.parse::<f64>().map_err(|_| format!("'{w}' is not a number")));
+        }
+        let n = ty.size();
+        let raw = self.bytes.get(self.at..self.at + n).ok_or("the file ends in the middle of its data")?;
+        self.at += n;
+        let mut b = [0u8; 8];
+        b[..n].copy_from_slice(raw);
+        if self.encoding == Encoding::Big {
+            b[..n].reverse();
+        }
+        Ok(match ty {
+            Scalar::I8 => b[0] as i8 as f64,
+            Scalar::U8 => b[0] as f64,
+            Scalar::I16 => i16::from_le_bytes([b[0], b[1]]) as f64,
+            Scalar::U16 => u16::from_le_bytes([b[0], b[1]]) as f64,
+            Scalar::I32 => i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64,
+            Scalar::U32 => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64,
+            Scalar::F32 => f32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f64,
+            Scalar::F64 => f64::from_le_bytes(b),
+        })
+    }
+
+    fn word(&mut self) -> Result<&str, String> {
+        let rest = &self.bytes[self.at..];
+        let start = rest.iter().position(|c| !c.is_ascii_whitespace()).ok_or("the file ends in the middle of its data")?;
+        let len = rest[start..].iter().position(|c| c.is_ascii_whitespace()).unwrap_or(rest.len() - start);
+        self.at += start + len;
+        std::str::from_utf8(&rest[start..start + len]).map_err(|_| "text that is not UTF-8".to_string())
+    }
+}
+
+pub fn read(bytes: &[u8]) -> Result<Scene, String> {
+    let (elements, encoding, body_at) = header(bytes)?;
+    let mut body = Body { bytes, at: body_at, encoding };
+    let mut positions: Vec<Vec3> = Vec::new();
+    let mut point_colors: Vec<[f32; 3]> = Vec::new();
+    let mut faces: Vec<[u32; 3]> = Vec::new();
+    let mut corner: Vec<u32> = Vec::new();
+
+    for el in &elements {
+        match el.name.as_str() {
+            "vertex" => {
+                let at = |n: &str| {
+                    el.properties.iter().position(|p| matches!(p, Property::Scalar { name, .. } if name == n))
+                };
+                let (Some(xi), Some(yi), Some(zi)) = (at("x"), at("y"), at("z")) else {
+                    return Err("vertices without x, y and z".into());
+                };
+                let rgb = match (at("red"), at("green"), at("blue")) {
+                    (Some(r), Some(g), Some(b)) => Some([r, g, b]),
+                    _ => match (at("diffuse_red"), at("diffuse_green"), at("diffuse_blue")) {
+                        (Some(r), Some(g), Some(b)) => Some([r, g, b]),
+                        _ => None,
+                    },
+                };
+                positions.reserve(el.count);
+                let mut row = vec![0f64; el.properties.len()];
+                for _ in 0..el.count {
+                    for (i, p) in el.properties.iter().enumerate() {
+                        row[i] = match p {
+                            Property::Scalar { ty, .. } => body.value(*ty)?,
+                            Property::List { count, item, .. } => {
+                                skip_list(&mut body, *count, *item)?;
+                                0.0
+                            }
+                        };
+                    }
+                    positions.push(Vec3::new(row[xi] as f32, row[yi] as f32, row[zi] as f32));
+                    if let Some(idx) = rgb {
+                        point_colors.push(idx.map(|i| match &el.properties[i] {
+                            Property::Scalar { ty: Scalar::F32 | Scalar::F64, .. } => row[i] as f32,
+                            _ => srgb_to_linear(row[i].clamp(0.0, 255.0) as u8),
+                        }));
+                    }
+                }
+            }
+            "face" => {
+                faces.reserve(el.count);
+                for _ in 0..el.count {
+                    for p in &el.properties {
+                        match p {
+                            Property::List { name, count, item } if name == "vertex_indices" || name == "vertex_index" => {
+                                let n = body.value(*count)? as usize;
+                                corner.clear();
+                                for _ in 0..n {
+                                    corner.push(body.value(*item)? as u32);
+                                }
+                                for k in 1..n.saturating_sub(1) {
+                                    faces.push([corner[0], corner[k], corner[k + 1]]);
+                                }
+                            }
+                            Property::List { count, item, .. } => skip_list(&mut body, *count, *item)?,
+                            Property::Scalar { ty, .. } => {
+                                body.value(*ty)?;
+                            }
+                        }
+                    }
+                }
+            }
+            _ => {
+                for _ in 0..el.count {
+                    for p in &el.properties {
+                        match p {
+                            Property::Scalar { ty, .. } => {
+                                body.value(*ty)?;
+                            }
+                            Property::List { count, item, .. } => skip_list(&mut body, *count, *item)?,
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    if faces.is_empty() {
+        return Err(if positions.is_empty() {
+            "no vertices and no faces".into()
+        } else {
+            format!("{} points and no faces: point clouds are not drawn yet", positions.len())
+        });
+    }
+    let n = positions.len() as u32;
+    if let Some(bad) = faces.iter().flatten().find(|&&i| i >= n) {
+        return Err(format!("a face names vertex {bad}, but there are {n}"));
+    }
+    let corner_colors = (!point_colors.is_empty())
+        .then(|| faces.iter().flat_map(|f| f.map(|i| point_colors[i as usize])).collect());
+    let mesh = Mesh { tri_color: vec![0; faces.len()], positions, triangles: faces, colors: vec![CLAY], corner_colors };
+    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Y })
+}
+
+fn skip_list(body: &mut Body, count: Scalar, item: Scalar) -> Result<(), String> {
+    let n = body.value(count)? as usize;
+    for _ in 0..n {
+        body.value(item)?;
+    }
+    Ok(())
+}
+
+/// The elements, the encoding, and where the body starts.
+fn header(bytes: &[u8]) -> Result<(Vec<Element>, Encoding, usize), String> {
+    if !bytes.starts_with(b"ply") {
+        return Err("not a PLY file (it does not begin with 'ply')".into());
+    }
+    let end = bytes
+        .windows(10)
+        .position(|w| w == b"end_header")
+        .ok_or("the header never ends (no 'end_header')")?;
+    // The body starts after the line end that follows end_header (\n or \r\n).
+    let mut body_at = end + 10;
+    while bytes.get(body_at).is_some_and(|&c| c == b' ' || c == b'\r') {
+        body_at += 1;
+    }
+    if bytes.get(body_at) == Some(&b'\n') {
+        body_at += 1;
+    }
+    let text = std::str::from_utf8(&bytes[..end]).map_err(|_| "a header that is not text".to_string())?;
+    let mut encoding = None;
+    let mut elements: Vec<Element> = Vec::new();
+    for line in text.lines().skip(1) {
+        let w: Vec<&str> = line.split_whitespace().collect();
+        match w.as_slice() {
+            ["format", "ascii", _] => encoding = Some(Encoding::Ascii),
+            ["format", "binary_little_endian", _] => encoding = Some(Encoding::Little),
+            ["format", "binary_big_endian", _] => encoding = Some(Encoding::Big),
+            ["format", other, ..] => return Err(format!("unknown PLY format '{other}'")),
+            ["element", name, count] => elements.push(Element {
+                name: name.to_string(),
+                count: count.parse().map_err(|_| format!("element {name} has count '{count}'"))?,
+                properties: Vec::new(),
+            }),
+            ["property", "list", count, item, name] => {
+                let el = elements.last_mut().ok_or("a property before any element")?;
+                let ty = |t: &str| Scalar::parse(t).ok_or_else(|| format!("unknown property type '{t}'"));
+                el.properties.push(Property::List { name: name.to_string(), count: ty(count)?, item: ty(item)? });
+            }
+            ["property", ty, name] => {
+                let el = elements.last_mut().ok_or("a property before any element")?;
+                let ty = Scalar::parse(ty).ok_or_else(|| format!("unknown property type '{ty}'"))?;
+                el.properties.push(Property::Scalar { name: name.to_string(), ty });
+            }
+            ["comment", ..] | ["obj_info", ..] | [] => {}
+            _ => log::debug!("[mesh-io] ply: skipped header line {line:?}"),
+        }
+    }
+    Ok((elements, encoding.ok_or("no 'format' line")?, body_at))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const ASCII_QUAD: &str = "ply\nformat ascii 1.0\ncomment made by hand\nelement vertex 4\nproperty float x\nproperty float y\nproperty float z\nproperty uchar red\nproperty uchar green\nproperty uchar blue\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0 255 0 0\n1 0 0 255 0 0\n1 1 0 0 0 255\n0 1 0 0 0 255\n4 0 1 2 3\n";
+
+    fn mesh(bytes: &[u8]) -> Mesh {
+        read(bytes).unwrap().parts.remove(0).mesh
+    }
+
+    #[test]
+    fn an_ascii_quad_is_two_coloured_triangles() {
+        let m = mesh(ASCII_QUAD.as_bytes());
+        assert_eq!(m.triangles, vec![[0, 1, 2], [0, 2, 3]]);
+        let c = m.corner_colors.unwrap();
+        assert_eq!(c[0], [1.0, 0.0, 0.0]);
+        assert_eq!(c[2], [0.0, 0.0, 1.0], "corner 2 is vertex 2, blue");
+    }
+
+    /// The quad again, binary, with an extra element in the way and a
+    /// per-face list it must read past.
+    fn binary_quad(big: bool) -> Vec<u8> {
+        let fmt = if big { "binary_big_endian" } else { "binary_little_endian" };
+        let mut b = format!(
+            "ply\r\nformat {fmt} 1.0\r\nelement vertex 4\r\nproperty double x\r\nproperty double y\r\nproperty double z\r\nelement face 1\r\nproperty list uchar uint vertex_indices\r\nproperty list uchar float texcoord\r\nelement extra 1\r\nproperty short junk\r\nend_header\r\n"
+        )
+        .into_bytes();
+        let f64b = |v: f64| if big { v.to_be_bytes() } else { v.to_le_bytes() };
+        let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
+        for p in [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]] {
+            for c in p {
+                b.extend(f64b(c));
+            }
+        }
+        b.push(4);
+        for i in [0, 1, 2, 3] {
+            b.extend(u32b(i));
+        }
+        b.push(2);
+        b.extend([0u8; 8]);
+        b.extend([7u8, 7]);
+        b
+    }
+
+    #[test]
+    fn binary_of_either_byte_order() {
+        for big in [false, true] {
+            let m = mesh(&binary_quad(big));
+            assert_eq!(m.triangles, vec![[0, 1, 2], [0, 2, 3]], "big endian: {big}");
+            assert_eq!(m.positions[2], Vec3::new(1.0, 1.0, 0.0));
+            assert!(m.corner_colors.is_none());
+        }
+    }
+
+    #[test]
+    fn a_point_cloud_says_so() {
+        let text = "ply\nformat ascii 1.0\nelement vertex 2\nproperty float x\nproperty float y\nproperty float z\nend_header\n0 0 0\n1 1 1\n";
+        let e = read(text.as_bytes()).unwrap_err();
+        assert!(e.contains("point clouds"), "{e}");
+    }
+
+    #[test]
+    fn a_short_file_and_a_bad_index_are_errors() {
+        let mut b = binary_quad(false);
+        b.truncate(b.len() - 20);
+        assert!(read(&b).unwrap_err().contains("ends in the middle"));
+        let bad = ASCII_QUAD.replace("4 0 1 2 3", "3 0 1 9");
+        assert!(read(bad.as_bytes()).unwrap_err().contains("names vertex 9"));
+    }
+}
diff --git a/src/stl.rs b/src/stl.rs
new file mode 100644
index 0000000..28d5246
--- /dev/null
+++ b/src/stl.rs
@@ -0,0 +1,142 @@
+//! STL, binary and ASCII.
+//!
+//! Binary is told from ASCII by its size, not by its first word: the format
+//! leaves the 80-byte header free, and plenty of binary files begin it with
+//! `solid`, the word that opens an ASCII one. A binary file is exactly
+//! 84 + 50 × its triangle count bytes long.
+//!
+//! Points are returned as written, with [`UpAxis::Z`], STL's convention;
+//! turning them upright is the caller's call. The file's own facet normals
+//! are ignored: normals come from the winding. Nor is the attribute word
+//! read as a colour — the two vendor schemes for that disagree.
+
+use glam::Vec3;
+
+use crate::mesh::{Mesh, CLAY};
+use crate::{Part, Scene, UpAxis};
+
+pub fn read(bytes: &[u8]) -> Result<Scene, String> {
+    let corners = if is_binary(bytes) { binary(bytes)? } else { ascii(bytes)? };
+    let n = corners.len() / 3;
+    let mesh = Mesh {
+        positions: corners,
+        triangles: (0..n as u32).map(|t| [t * 3, t * 3 + 1, t * 3 + 2]).collect(),
+        tri_color: vec![0; n],
+        colors: vec![CLAY],
+        corner_colors: None,
+    };
+    Ok(Scene { parts: vec![Part { name: String::new(), mesh }], up: UpAxis::Z })
+}
+
+fn is_binary(bytes: &[u8]) -> bool {
+    if bytes.len() < 84 {
+        return false;
+    }
+    let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as u64;
+    84 + 50 * count == bytes.len() as u64 || !bytes.starts_with(b"solid")
+}
+
+fn binary(bytes: &[u8]) -> Result<Vec<Vec3>, String> {
+    let count = u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize;
+    let need = 84 + 50 * count;
+    if bytes.len() < need {
+        return Err(format!(
+            "binary STL says {count} triangles ({need} bytes) but the file is {} bytes; it is cut short",
+            bytes.len()
+        ));
+    }
+    let f = |o: usize| f32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
+    let mut out = Vec::with_capacity(count * 3);
+    for t in 0..count {
+        // 12 bytes of facet normal, three 12-byte corners, 2 bytes attribute.
+        let base = 84 + 50 * t + 12;
+        for k in 0..3 {
+            let o = base + 12 * k;
+            out.push(Vec3::new(f(o), f(o + 4), f(o + 8)));
+        }
+    }
+    Ok(out)
+}
+
+fn ascii(bytes: &[u8]) -> Result<Vec<Vec3>, String> {
+    let text = std::str::from_utf8(bytes).map_err(|_| "not a binary STL, and not text either".to_string())?;
+    let mut out = Vec::new();
+    for (line_no, line) in text.lines().enumerate() {
+        let mut words = line.split_whitespace();
+        if words.next() != Some("vertex") {
+            continue;
+        }
+        let mut xyz = [0f32; 3];
+        for v in &mut xyz {
+            *v = words
+                .next()
+                .and_then(|w| w.parse().ok())
+                .ok_or_else(|| format!("line {}: a vertex needs three numbers", line_no + 1))?;
+        }
+        out.push(Vec3::from(xyz));
+    }
+    if out.len() % 3 != 0 {
+        return Err(format!("{} vertices is not a whole number of triangles", out.len()));
+    }
+    Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn binary_of(tris: &[[[f32; 3]; 3]]) -> Vec<u8> {
+        let mut b = vec![0u8; 80];
+        b[..5].copy_from_slice(b"solid"); // the header may say anything, this included
+        b.extend((tris.len() as u32).to_le_bytes());
+        for t in tris {
+            b.extend([0u8; 12]);
+            for p in t {
+                for c in p {
+                    b.extend(c.to_le_bytes());
+                }
+            }
+            b.extend([0u8; 2]);
+        }
+        b
+    }
+
+    fn mesh(bytes: &[u8]) -> Mesh {
+        read(bytes).unwrap().parts.remove(0).mesh
+    }
+
+    #[test]
+    fn a_binary_file_headed_solid_is_read_as_binary() {
+        let m = mesh(&binary_of(&[[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]]));
+        assert_eq!(m.triangles.len(), 1);
+        assert_eq!(m.positions[1], Vec3::new(1.0, 0.0, 0.0));
+    }
+
+    #[test]
+    fn points_are_as_written_and_the_scene_says_z_is_up() {
+        let s = read(&binary_of(&[[[0., 0., 2.], [1., 0., 0.], [0., 3., 0.]]])).unwrap();
+        assert_eq!(s.up, UpAxis::Z);
+        assert_eq!(s.parts[0].mesh.positions[0], Vec3::new(0.0, 0.0, 2.0));
+    }
+
+    #[test]
+    fn ascii_is_read() {
+        let text = "solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n";
+        assert_eq!(mesh(text.as_bytes()).triangles.len(), 1);
+    }
+
+    #[test]
+    fn a_short_binary_file_is_an_error() {
+        let mut b = binary_of(&[[[0.; 3], [1., 0., 0.], [0., 1., 0.]]; 2]);
+        b.truncate(b.len() - 30);
+        b[..5].copy_from_slice(b"model"); // not "solid": nothing to try as text
+        let e = read(&b).unwrap_err();
+        assert!(e.contains("cut short"), "{e}");
+    }
+
+    #[test]
+    fn a_bad_ascii_vertex_is_an_error() {
+        let e = read(b"solid t\nvertex 0 0\n").unwrap_err();
+        assert!(e.contains("line 2"), "{e}");
+    }
+}
diff --git a/tests/designer_round_trip.rs b/tests/designer_round_trip.rs
new file mode 100644
index 0000000..dd42bd6
--- /dev/null
+++ b/tests/designer_round_trip.rs
@@ -0,0 +1,63 @@
+//! What cce-designer exports, this crate reads back as the same mesh.
+//!
+//! The fixtures are the designer's default project written by its own
+//! exporter (`cce-designer --export default_project.json out.stl|out.obj`,
+//! 2026-10-07): 362 points and 384 primitives, 48 triangles and 336 quads, so
+//! 720 triangles. STL keeps only triangles and no shared points; OBJ keeps
+//! both. Read and welded, the two must agree on every point and triangle.
+
+use std::path::Path;
+
+use cce_mesh_io::{load, Mesh, UpAxis};
+use glam::Vec3;
+
+fn fixture(name: &str) -> cce_mesh_io::Scene {
+    load(&Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)).unwrap()
+}
+
+/// Every triangle as its three corners, starting from the smallest so the
+/// same triangle written from another corner compares equal; sorted.
+fn triangle_set(m: &Mesh) -> Vec<[[i64; 3]; 3]> {
+    // Exact-enough keys: the exporter writes shortest round-trip floats.
+    let key = |p: Vec3| [(p.x * 1e5).round() as i64, (p.y * 1e5).round() as i64, (p.z * 1e5).round() as i64];
+    let mut out: Vec<[[i64; 3]; 3]> = m
+        .triangles
+        .iter()
+        .map(|t| {
+            let k = t.map(|i| key(m.positions[i as usize]));
+            let first = (0..3).min_by_key(|&i| k[i]).unwrap();
+            [k[first], k[(first + 1) % 3], k[(first + 2) % 3]]
+        })
+        .collect();
+    out.sort();
+    out
+}
+
+#[test]
+fn the_stl_export_reads_back_as_the_designer_wrote_it() {
+    let s = fixture("designer-default.stl");
+    assert_eq!(s.up, UpAxis::Z, "STL's convention, reported, not applied");
+    assert_eq!(s.parts.len(), 1);
+    assert_eq!(s.parts[0].name, "designer-default");
+    let m = &s.parts[0].mesh;
+    assert_eq!(m.triangles.len(), 720);
+    assert_eq!(m.positions.len(), 362, "welding recovers the designer's shared points");
+}
+
+#[test]
+fn the_obj_export_reads_back_as_the_designer_wrote_it() {
+    let s = fixture("designer-default.obj");
+    assert_eq!(s.up, UpAxis::Y);
+    assert_eq!(s.parts[0].name, "default", "the exporter's `o` line");
+    let m = &s.parts[0].mesh;
+    assert_eq!(m.triangles.len(), 720, "48 triangles and 336 quads");
+    assert_eq!(m.positions.len(), 362);
+}
+
+#[test]
+fn both_exports_are_the_same_mesh() {
+    let stl = fixture("designer-default.stl").merged();
+    let obj = fixture("designer-default.obj").merged();
+    assert_eq!(stl.bounds(), obj.bounds());
+    assert_eq!(triangle_set(&stl), triangle_set(&obj), "same triangles, same winding");
+}
diff --git a/tests/fixtures/designer-default.obj b/tests/fixtures/designer-default.obj
new file mode 100644
index 0000000..85cf7d8
--- /dev/null
+++ b/tests/fixtures/designer-default.obj
@@ -0,0 +1,1110 @@
+# cce-designer default
+o default
+v 0e0 1.05e0 0e0
+v 9.754516e-2 1.0403926e0 0e0
+v 9.422139e-2 1.0403926e0 2.5246546e-2
+v 8.447659e-2 1.0403926e0 4.877258e-2
+v 6.8974845e-2 1.0403926e0 6.8974845e-2
+v 4.8772577e-2 1.0403926e0 8.447659e-2
+v 2.5246536e-2 1.0403926e0 9.42214e-2
+v -4.2638346e-9 1.0403926e0 9.754516e-2
+v -2.5246557e-2 1.0403926e0 9.422139e-2
+v -4.877259e-2 1.0403926e0 8.447659e-2
+v -6.8974845e-2 1.0403926e0 6.8974845e-2
+v -8.44766e-2 1.0403926e0 4.8772562e-2
+v -9.42214e-2 1.0403926e0 2.5246534e-2
+v -9.754516e-2 1.0403926e0 -8.527669e-9
+v -9.422139e-2 1.0403926e0 -2.5246548e-2
+v -8.4476575e-2 1.0403926e0 -4.87726e-2
+v -6.897485e-2 1.0403926e0 -6.897484e-2
+v -4.8772573e-2 1.0403926e0 -8.447659e-2
+v -2.524654e-2 1.0403926e0 -9.42214e-2
+v 1.1632144e-9 1.0403926e0 -9.754516e-2
+v 2.5246542e-2 1.0403926e0 -9.422139e-2
+v 4.8772614e-2 1.0403926e0 -8.447657e-2
+v 6.897487e-2 1.0403926e0 -6.897482e-2
+v 8.4476605e-2 1.0403926e0 -4.877256e-2
+v 9.422138e-2 1.0403926e0 -2.5246568e-2
+v 1.9134173e-1 1.0119398e0 0e0
+v 1.8482192e-1 1.0119398e0 4.9522884e-2
+v 1.657068e-1 1.0119398e0 9.5670864e-2
+v 1.3529903e-1 1.0119398e0 1.3529903e-1
+v 9.567086e-2 1.0119398e0 1.657068e-1
+v 4.9522866e-2 1.0119398e0 1.8482192e-1
+v -8.363813e-9 1.0119398e0 1.9134173e-1
+v -4.9522907e-2 1.0119398e0 1.8482192e-1
+v -9.567088e-2 1.0119398e0 1.657068e-1
+v -1.3529903e-1 1.0119398e0 1.3529903e-1
+v -1.6570681e-1 1.0119398e0 9.567083e-2
+v -1.8482192e-1 1.0119398e0 4.952286e-2
+v -1.9134173e-1 1.0119398e0 -1.6727626e-8
+v -1.8482192e-1 1.0119398e0 -4.9522888e-2
+v -1.6570677e-1 1.0119398e0 -9.56709e-2
+v -1.3529904e-1 1.0119398e0 -1.3529903e-1
+v -9.567085e-2 1.0119398e0 -1.657068e-1
+v -4.9522873e-2 1.0119398e0 -1.8482192e-1
+v 2.2817273e-9 1.0119398e0 -1.9134173e-1
+v 4.9522877e-2 1.0119398e0 -1.8482192e-1
+v 9.567093e-2 1.0119398e0 -1.6570675e-1
+v 1.3529907e-1 1.0119398e0 -1.3529898e-1
+v 1.6570683e-1 1.0119398e0 -9.567082e-2
+v 1.848219e-1 1.0119398e0 -4.952293e-2
+v 2.7778512e-1 9.6573484e-1 0e0
+v 2.6831982e-1 9.6573484e-1 7.1896076e-2
+v 2.4056897e-1 9.6573484e-1 1.3889256e-1
+v 1.9642374e-1 9.6573484e-1 1.9642374e-1
+v 1.3889255e-1 9.6573484e-1 2.4056898e-1
+v 7.1896054e-2 9.6573484e-1 2.6831985e-1
+v -1.2142373e-8 9.6573484e-1 2.7778512e-1
+v -7.189611e-2 9.6573484e-1 2.6831982e-1
+v -1.3889258e-1 9.6573484e-1 2.4056897e-1
+v -1.9642374e-1 9.6573484e-1 1.9642374e-1
+v -2.40569e-1 9.6573484e-1 1.3889252e-1
+v -2.6831985e-1 9.6573484e-1 7.189605e-2
+v -2.7778512e-1 9.6573484e-1 -2.4284747e-8
+v -2.6831982e-1 9.6573484e-1 -7.189609e-2
+v -2.4056894e-1 9.6573484e-1 -1.388926e-1
+v -1.9642375e-1 9.6573484e-1 -1.9642372e-1
+v -1.3889253e-1 9.6573484e-1 -2.4056898e-1
+v -7.189606e-2 9.6573484e-1 -2.6831985e-1
+v 3.3125545e-9 9.6573484e-1 -2.7778512e-1
+v 7.189607e-2 9.6573484e-1 -2.6831982e-1
+v 1.3889267e-1 9.6573484e-1 -2.4056892e-1
+v 1.9642381e-1 9.6573484e-1 -1.9642368e-1
+v 2.4056903e-1 9.6573484e-1 -1.388925e-1
+v 2.6831982e-1 9.6573484e-1 -7.189614e-2
+v 3.5355338e-1 9.0355337e-1 0e0
+v 3.4150633e-1 9.0355337e-1 9.150635e-2
+v 3.061862e-1 9.0355337e-1 1.7677669e-1
+v 2.4999999e-1 9.0355337e-1 2.4999999e-1
+v 1.7677668e-1 9.0355337e-1 3.0618623e-1
+v 9.150632e-2 9.0355337e-1 3.4150636e-1
+v -1.545431e-8 9.0355337e-1 3.5355338e-1
+v -9.150639e-2 9.0355337e-1 3.4150633e-1
+v -1.767767e-1 9.0355337e-1 3.061862e-1
+v -2.4999999e-1 9.0355337e-1 2.4999999e-1
+v -3.0618626e-1 9.0355337e-1 1.7677663e-1
+v -3.4150636e-1 9.0355337e-1 9.150631e-2
+v -3.5355338e-1 9.0355337e-1 -3.090862e-8
+v -3.4150633e-1 9.0355337e-1 -9.150636e-2
+v -3.0618617e-1 9.0355337e-1 -1.7677675e-1
+v -2.5e-1 9.0355337e-1 -2.4999997e-1
+v -1.7677666e-1 9.0355337e-1 -3.0618623e-1
+v -9.1506325e-2 9.0355337e-1 -3.4150636e-1
+v 4.2160817e-9 9.0355337e-1 -3.5355338e-1
+v 9.150634e-2 9.0355337e-1 -3.4150633e-1
+v 1.7677681e-1 9.0355337e-1 -3.0618614e-1
+v 2.500001e-1 9.0355337e-1 -2.4999991e-1
+v 3.0618626e-1 9.0355337e-1 -1.767766e-1
+v 3.4150633e-1 9.0355337e-1 -9.150644e-2
+v 4.1573483e-1 8.2778513e-1 0e0
+v 4.01569e-1 8.2778513e-1 1.0760009e-1
+v 3.600369e-1 8.2778513e-1 2.0786741e-1
+v 2.9396892e-1 8.2778513e-1 2.9396892e-1
+v 2.078674e-1 8.2778513e-1 3.6003694e-1
+v 1.07600056e-1 8.2778513e-1 4.0156904e-1
+v -1.8172347e-8 8.2778513e-1 4.1573483e-1
+v -1.0760014e-1 8.2778513e-1 4.01569e-1
+v -2.0786744e-1 8.2778513e-1 3.600369e-1
+v -2.9396892e-1 8.2778513e-1 2.9396892e-1
+v -3.6003697e-1 8.2778513e-1 2.0786734e-1
+v -4.0156904e-1 8.2778513e-1 1.0760004e-1
+v -4.1573483e-1 8.2778513e-1 -3.6344694e-8
+v -4.01569e-1 8.2778513e-1 -1.076001e-1
+v -3.6003688e-1 8.2778513e-1 -2.0786749e-1
+v -2.9396895e-1 8.2778513e-1 -2.939689e-1
+v -2.0786738e-1 8.2778513e-1 -3.6003694e-1
+v -1.0760006e-1 8.2778513e-1 -4.0156904e-1
+v 4.957588e-9 8.2778513e-1 -4.1573483e-1
+v 1.0760008e-1 8.2778513e-1 -4.01569e-1
+v 2.0786756e-1 8.2778513e-1 -3.6003685e-1
+v 2.93969e-1 8.2778513e-1 -2.939688e-1
+v 3.60037e-1 8.2778513e-1 -2.0786731e-1
+v 4.0156898e-1 8.2778513e-1 -1.0760019e-1
+v 4.6193975e-1 7.413417e-1 0e0
+v 4.4619954e-1 7.413417e-1 1.19558804e-1
+v 4.0005156e-1 7.413417e-1 2.3096988e-1
+v 3.2664073e-1 7.413417e-1 3.2664073e-1
+v 2.3096986e-1 7.413417e-1 4.000516e-1
+v 1.1955877e-1 7.413417e-1 4.4619957e-1
+v -2.0192028e-8 7.413417e-1 4.6193975e-1
+v -1.1955886e-1 7.413417e-1 4.4619954e-1
+v -2.309699e-1 7.413417e-1 4.0005156e-1
+v -3.2664073e-1 7.413417e-1 3.2664073e-1
+v -4.000516e-1 7.413417e-1 2.3096979e-1
+v -4.4619957e-1 7.413417e-1 1.1955875e-1
+v -4.6193975e-1 7.413417e-1 -4.0384055e-8
+v -4.4619954e-1 7.413417e-1 -1.1955882e-1
+v -4.000515e-1 7.413417e-1 -2.3096997e-1
+v -3.2664075e-1 7.413417e-1 -3.266407e-1
+v -2.3096983e-1 7.413417e-1 -4.000516e-1
+v -1.19558774e-1 7.413417e-1 -4.4619957e-1
+v 5.5085763e-9 7.413417e-1 -4.6193975e-1
+v 1.1955879e-1 7.413417e-1 -4.4619954e-1
+v 2.3097004e-1 7.413417e-1 -4.0005147e-1
+v 3.2664084e-1 7.413417e-1 -3.266406e-1
+v 4.0005162e-1 7.413417e-1 -2.3096977e-1
+v 4.461995e-1 7.413417e-1 -1.19558915e-1
+v 4.9039266e-1 6.475451e-1 0e0
+v 4.736829e-1 6.475451e-1 1.2692297e-1
+v 4.2469248e-1 6.475451e-1 2.4519633e-1
+v 3.4675997e-1 6.475451e-1 3.4675997e-1
+v 2.4519631e-1 6.475451e-1 4.246925e-1
+v 1.2692292e-1 6.475451e-1 4.7368294e-1
+v -2.1435744e-8 6.475451e-1 4.9039266e-1
+v -1.2692301e-1 6.475451e-1 4.736829e-1
+v -2.4519636e-1 6.475451e-1 4.2469248e-1
+v -3.4675997e-1 6.475451e-1 3.4675997e-1
+v -4.2469254e-1 6.475451e-1 2.4519624e-1
+v -4.7368294e-1 6.475451e-1 1.269229e-1
+v -4.9039266e-1 6.475451e-1 -4.2871488e-8
+v -4.736829e-1 6.475451e-1 -1.2692298e-1
+v -4.2469242e-1 6.475451e-1 -2.4519642e-1
+v -3.4676e-1 6.475451e-1 -3.4675995e-1
+v -2.4519628e-1 6.475451e-1 -4.246925e-1
+v -1.2692294e-1 6.475451e-1 -4.7368294e-1
+v 5.8478737e-9 6.475451e-1 -4.9039266e-1
+v 1.2692295e-1 6.475451e-1 -4.736829e-1
+v 2.451965e-1 6.475451e-1 -4.246924e-1
+v 3.467601e-1 6.475451e-1 -3.4675986e-1
+v 4.2469257e-1 6.475451e-1 -2.4519621e-1
+v 4.7368288e-1 6.475451e-1 -1.2692307e-1
+v 5e-1 5.5e-1 0e0
+v 4.829629e-1 5.5e-1 1.2940952e-1
+v 4.330127e-1 5.5e-1 2.5e-1
+v 3.5355338e-1 5.5e-1 3.5355338e-1
+v 2.4999999e-1 5.5e-1 4.3301272e-1
+v 1.2940948e-1 5.5e-1 4.8296294e-1
+v -2.1855694e-8 5.5e-1 5e-1
+v -1.2940958e-1 5.5e-1 4.829629e-1
+v -2.5000003e-1 5.5e-1 4.330127e-1
+v -3.5355338e-1 5.5e-1 3.5355338e-1
+v -4.3301275e-1 5.5e-1 2.4999991e-1
+v -4.8296294e-1 5.5e-1 1.2940946e-1
+v -5e-1 5.5e-1 -4.371139e-8
+v -4.829629e-1 5.5e-1 -1.2940954e-1
+v -4.3301263e-1 5.5e-1 -2.500001e-1
+v -3.535534e-1 5.5e-1 -3.5355335e-1
+v -2.4999996e-1 5.5e-1 -4.3301272e-1
+v -1.2940949e-1 5.5e-1 -4.8296294e-1
+v 5.9624403e-9 5.5e-1 -5e-1
+v 1.294095e-1 5.5e-1 -4.829629e-1
+v 2.5000018e-1 5.5e-1 -4.330126e-1
+v 3.535535e-1 5.5e-1 -3.5355327e-1
+v 4.3301278e-1 5.5e-1 -2.4999988e-1
+v 4.8296288e-1 5.5e-1 -1.2940964e-1
+v 4.9039263e-1 4.5245486e-1 0e0
+v 4.7368288e-1 4.5245486e-1 1.2692295e-1
+v 4.2469245e-1 4.5245486e-1 2.4519631e-1
+v 3.4675995e-1 4.5245486e-1 3.4675995e-1
+v 2.451963e-1 4.5245486e-1 4.2469248e-1
+v 1.269229e-1 4.5245486e-1 4.736829e-1
+v -2.1435742e-8 4.5245486e-1 4.9039263e-1
+v -1.2692301e-1 4.5245486e-1 4.7368288e-1
+v -2.4519634e-1 4.5245486e-1 4.2469245e-1
+v -3.4675995e-1 4.5245486e-1 3.4675995e-1
+v -4.246925e-1 4.5245486e-1 2.4519622e-1
+v -4.736829e-1 4.5245486e-1 1.2692289e-1
+v -4.9039263e-1 4.5245486e-1 -4.2871484e-8
+v -4.7368288e-1 4.5245486e-1 -1.2692297e-1
+v -4.246924e-1 4.5245486e-1 -2.451964e-1
+v -3.4675997e-1 4.5245486e-1 -3.4675992e-1
+v -2.4519627e-1 4.5245486e-1 -4.2469248e-1
+v -1.2692292e-1 4.5245486e-1 -4.736829e-1
+v 5.8478737e-9 4.5245486e-1 -4.9039263e-1
+v 1.2692294e-1 4.5245486e-1 -4.7368288e-1
+v 2.4519649e-1 4.5245486e-1 -4.2469236e-1
+v 3.4676006e-1 4.5245486e-1 -3.4675983e-1
+v 4.2469254e-1 4.5245486e-1 -2.451962e-1
+v 4.7368288e-1 4.5245486e-1 -1.2692307e-1
+v 4.6193975e-1 3.5865825e-1 0e0
+v 4.4619954e-1 3.5865825e-1 1.19558804e-1
+v 4.0005156e-1 3.5865825e-1 2.3096988e-1
+v 3.2664073e-1 3.5865825e-1 3.2664073e-1
+v 2.3096986e-1 3.5865825e-1 4.000516e-1
+v 1.1955877e-1 3.5865825e-1 4.4619957e-1
+v -2.0192028e-8 3.5865825e-1 4.6193975e-1
+v -1.1955886e-1 3.5865825e-1 4.4619954e-1
+v -2.309699e-1 3.5865825e-1 4.0005156e-1
+v -3.2664073e-1 3.5865825e-1 3.2664073e-1
+v -4.000516e-1 3.5865825e-1 2.3096979e-1
+v -4.4619957e-1 3.5865825e-1 1.1955875e-1
+v -4.6193975e-1 3.5865825e-1 -4.0384055e-8
+v -4.4619954e-1 3.5865825e-1 -1.1955882e-1
+v -4.000515e-1 3.5865825e-1 -2.3096997e-1
+v -3.2664075e-1 3.5865825e-1 -3.266407e-1
+v -2.3096983e-1 3.5865825e-1 -4.000516e-1
+v -1.19558774e-1 3.5865825e-1 -4.4619957e-1
+v 5.5085763e-9 3.5865825e-1 -4.6193975e-1
+v 1.1955879e-1 3.5865825e-1 -4.4619954e-1
+v 2.3097004e-1 3.5865825e-1 -4.0005147e-1
+v 3.2664084e-1 3.5865825e-1 -3.266406e-1
+v 4.0005162e-1 3.5865825e-1 -2.3096977e-1
+v 4.461995e-1 3.5865825e-1 -1.19558915e-1
+v 4.1573477e-1 2.7221483e-1 0e0
+v 4.0156895e-1 2.7221483e-1 1.0760008e-1
+v 3.6003685e-1 2.7221483e-1 2.0786738e-1
+v 2.9396886e-1 2.7221483e-1 2.9396886e-1
+v 2.0786737e-1 2.7221483e-1 3.6003688e-1
+v 1.0760004e-1 2.7221483e-1 4.0156898e-1
+v -1.8172344e-8 2.7221483e-1 4.1573477e-1
+v -1.0760012e-1 2.7221483e-1 4.0156895e-1
+v -2.0786741e-1 2.7221483e-1 3.6003685e-1
+v -2.9396886e-1 2.7221483e-1 2.9396886e-1
+v -3.600369e-1 2.7221483e-1 2.0786731e-1
+v -4.0156898e-1 2.7221483e-1 1.07600026e-1
+v -4.1573477e-1 2.7221483e-1 -3.6344687e-8
+v -4.0156895e-1 2.7221483e-1 -1.07600085e-1
+v -3.6003682e-1 2.7221483e-1 -2.0786746e-1
+v -2.939689e-1 2.7221483e-1 -2.9396886e-1
+v -2.0786735e-1 2.7221483e-1 -3.6003688e-1
+v -1.0760005e-1 2.7221483e-1 -4.0156898e-1
+v 4.9575877e-9 2.7221483e-1 -4.1573477e-1
+v 1.0760006e-1 2.7221483e-1 -4.0156895e-1
+v 2.0786753e-1 2.7221483e-1 -3.600368e-1
+v 2.9396898e-1 2.7221483e-1 -2.9396877e-1
+v 3.6003694e-1 2.7221483e-1 -2.0786728e-1
+v 4.0156892e-1 2.7221483e-1 -1.07600175e-1
+v 3.5355338e-1 1.9644663e-1 0e0
+v 3.4150633e-1 1.9644663e-1 9.150635e-2
+v 3.061862e-1 1.9644663e-1 1.7677669e-1
+v 2.4999999e-1 1.9644663e-1 2.4999999e-1
+v 1.7677668e-1 1.9644663e-1 3.0618623e-1
+v 9.150632e-2 1.9644663e-1 3.4150636e-1
+v -1.545431e-8 1.9644663e-1 3.5355338e-1
+v -9.150639e-2 1.9644663e-1 3.4150633e-1
+v -1.767767e-1 1.9644663e-1 3.061862e-1
+v -2.4999999e-1 1.9644663e-1 2.4999999e-1
+v -3.0618626e-1 1.9644663e-1 1.7677663e-1
+v -3.4150636e-1 1.9644663e-1 9.150631e-2
+v -3.5355338e-1 1.9644663e-1 -3.090862e-8
+v -3.4150633e-1 1.9644663e-1 -9.150636e-2
+v -3.0618617e-1 1.9644663e-1 -1.7677675e-1
+v -2.5e-1 1.9644663e-1 -2.4999997e-1
+v -1.7677666e-1 1.9644663e-1 -3.0618623e-1
+v -9.1506325e-2 1.9644663e-1 -3.4150636e-1
+v 4.2160817e-9 1.9644663e-1 -3.5355338e-1
+v 9.150634e-2 1.9644663e-1 -3.4150633e-1
+v 1.7677681e-1 1.9644663e-1 -3.0618614e-1
+v 2.500001e-1 1.9644663e-1 -2.4999991e-1
+v 3.0618626e-1 1.9644663e-1 -1.767766e-1
+v 3.4150633e-1 1.9644663e-1 -9.150644e-2
+v 2.777851e-1 1.3426518e-1 0e0
+v 2.683198e-1 1.3426518e-1 7.189607e-2
+v 2.4056894e-1 1.3426518e-1 1.3889255e-1
+v 1.9642372e-1 1.3426518e-1 1.9642372e-1
+v 1.3889253e-1 1.3426518e-1 2.4056897e-1
+v 7.189605e-2 1.3426518e-1 2.6831982e-1
+v -1.2142372e-8 1.3426518e-1 2.777851e-1
+v -7.1896106e-2 1.3426518e-1 2.683198e-1
+v -1.3889256e-1 1.3426518e-1 2.4056894e-1
+v -1.9642372e-1 1.3426518e-1 1.9642372e-1
+v -2.4056898e-1 1.3426518e-1 1.388925e-1
+v -2.6831982e-1 1.3426518e-1 7.189604e-2
+v -2.777851e-1 1.3426518e-1 -2.4284745e-8
+v -2.683198e-1 1.3426518e-1 -7.189608e-2
+v -2.405689e-1 1.3426518e-1 -1.3889259e-1
+v -1.9642374e-1 1.3426518e-1 -1.9642371e-1
+v -1.3889252e-1 1.3426518e-1 -2.4056897e-1
+v -7.1896054e-2 1.3426518e-1 -2.6831982e-1
+v 3.312554e-9 1.3426518e-1 -2.777851e-1
+v 7.189606e-2 1.3426518e-1 -2.683198e-1
+v 1.3889265e-1 1.3426518e-1 -2.4056889e-1
+v 1.9642378e-1 1.3426518e-1 -1.9642365e-1
+v 2.40569e-1 1.3426518e-1 -1.3889249e-1
+v 2.683198e-1 1.3426518e-1 -7.1896136e-2
+v 1.9134164e-1 8.80602e-2 0e0
+v 1.8482183e-1 8.80602e-2 4.9522858e-2
+v 1.6570672e-1 8.80602e-2 9.567082e-2
+v 1.3529897e-1 8.80602e-2 1.3529897e-1
+v 9.567081e-2 8.80602e-2 1.6570672e-1
+v 4.9522843e-2 8.80602e-2 1.8482184e-1
+v -8.363808e-9 8.80602e-2 1.9134164e-1
+v -4.9522884e-2 8.80602e-2 1.8482183e-1
+v -9.5670834e-2 8.80602e-2 1.6570672e-1
+v -1.3529897e-1 8.80602e-2 1.3529897e-1
+v -1.6570674e-1 8.80602e-2 9.567078e-2
+v -1.8482184e-1 8.80602e-2 4.9522836e-2
+v -1.9134164e-1 8.80602e-2 -1.6727617e-8
+v -1.8482183e-1 8.80602e-2 -4.9522866e-2
+v -1.657067e-1 8.80602e-2 -9.567086e-2
+v -1.3529898e-1 8.80602e-2 -1.3529895e-1
+v -9.5670804e-2 8.80602e-2 -1.6570672e-1
+v -4.9522847e-2 8.80602e-2 -1.8482184e-1
+v 2.2817261e-9 8.80602e-2 -1.9134164e-1
+v 4.9522854e-2 8.80602e-2 -1.8482183e-1
+v 9.567089e-2 8.80602e-2 -1.6570668e-1
+v 1.3529901e-1 8.80602e-2 -1.3529892e-1
+v 1.6570675e-1 8.80602e-2 -9.5670775e-2
+v 1.8482181e-1 8.80602e-2 -4.9522907e-2
+v 9.7545154e-2 5.9607357e-2 0e0
+v 9.422138e-2 5.9607357e-2 2.5246544e-2
+v 8.447658e-2 5.9607357e-2 4.8772577e-2
+v 6.897484e-2 5.9607357e-2 6.897484e-2
+v 4.8772573e-2 5.9607357e-2 8.447658e-2
+v 2.5246534e-2 5.9607357e-2 9.422139e-2
+v -4.263834e-9 5.9607357e-2 9.7545154e-2
+v -2.5246555e-2 5.9607357e-2 9.422138e-2
+v -4.8772585e-2 5.9607357e-2 8.447658e-2
+v -6.897484e-2 5.9607357e-2 6.897484e-2
+v -8.447659e-2 5.9607357e-2 4.877256e-2
+v -9.422139e-2 5.9607357e-2 2.5246533e-2
+v -9.7545154e-2 5.9607357e-2 -8.527668e-9
+v -9.422138e-2 5.9607357e-2 -2.5246546e-2
+v -8.447657e-2 5.9607357e-2 -4.8772596e-2
+v -6.8974845e-2 5.9607357e-2 -6.897483e-2
+v -4.877257e-2 5.9607357e-2 -8.447658e-2
+v -2.5246538e-2 5.9607357e-2 -9.422139e-2
+v 1.1632143e-9 5.9607357e-2 -9.7545154e-2
+v 2.524654e-2 5.9607357e-2 -9.422138e-2
+v 4.877261e-2 5.9607357e-2 -8.447656e-2
+v 6.897486e-2 5.9607357e-2 -6.8974815e-2
+v 8.44766e-2 5.9607357e-2 -4.8772555e-2
+v 9.4221376e-2 5.9607357e-2 -2.5246566e-2
+v -4.371139e-8 5.0000012e-2 0e0
+vn 0e0 1e0 0e0
+vn 1.9509035e-1 9.8078537e-1 0e0
+vn 1.8844281e-1 9.8078537e-1 5.04931e-2
+vn 1.689532e-1 9.8078537e-1 9.754518e-2
+vn 1.379497e-1 9.8078537e-1 1.379497e-1
+vn 9.754517e-2 9.8078537e-1 1.689532e-1
+vn 5.049308e-2 9.8078537e-1 1.8844283e-1
+vn -8.52767e-9 9.8078537e-1 1.9509035e-1
+vn -5.049312e-2 9.8078537e-1 1.8844281e-1
+vn -9.754519e-2 9.8078537e-1 1.689532e-1
+vn -1.379497e-1 9.8078537e-1 1.379497e-1
+vn -1.6895321e-1 9.8078537e-1 9.754514e-2
+vn -1.8844283e-1 9.8078537e-1 5.0493076e-2
+vn -1.9509035e-1 9.8078537e-1 -1.705534e-8
+vn -1.8844281e-1 9.8078537e-1 -5.0493103e-2
+vn -1.6895317e-1 9.8078537e-1 -9.7545214e-2
+vn -1.3794972e-1 9.8078537e-1 -1.3794969e-1
+vn -9.754516e-2 9.8078537e-1 -1.689532e-1
+vn -5.0493088e-2 9.8078537e-1 -1.8844283e-1
+vn 2.326429e-9 9.8078537e-1 -1.9509035e-1
+vn 5.049309e-2 9.8078537e-1 -1.8844281e-1
+vn 9.7545244e-2 9.8078537e-1 -1.6895315e-1
+vn 1.3794975e-1 9.8078537e-1 -1.3794966e-1
+vn 1.6895323e-1 9.8078537e-1 -9.754513e-2
+vn 1.884428e-1 9.8078537e-1 -5.0493143e-2
+vn 3.8268346e-1 9.238795e-1 0e0
+vn 3.6964384e-1 9.238795e-1 9.904577e-2
+vn 3.314136e-1 9.238795e-1 1.9134173e-1
+vn 2.7059808e-1 9.238796e-1 2.7059808e-1
+vn 1.9134171e-1 9.238795e-1 3.314136e-1
+vn 9.9045746e-2 9.238796e-1 3.6964387e-1
+vn -1.6727626e-8 9.238795e-1 3.8268346e-1
+vn -9.904581e-2 9.238795e-1 3.6964384e-1
+vn -1.9134176e-1 9.238795e-1 3.314136e-1
+vn -2.7059808e-1 9.238796e-1 2.7059808e-1
+vn -3.3141366e-1 9.238796e-1 1.9134168e-1
+vn -3.6964387e-1 9.238796e-1 9.904574e-2
+vn -3.8268346e-1 9.238795e-1 -3.345525e-8
+vn -3.6964384e-1 9.238795e-1 -9.9045776e-2
+vn -3.3141357e-1 9.238796e-1 -1.9134183e-1
+vn -2.705981e-1 9.238796e-1 -2.7059808e-1
+vn -1.9134173e-1 9.238796e-1 -3.3141363e-1
+vn -9.904576e-2 9.238796e-1 -3.6964387e-1
+vn 4.5634545e-9 9.238795e-1 -3.8268346e-1
+vn 9.904575e-2 9.238795e-1 -3.6964384e-1
+vn 1.9134186e-1 9.238795e-1 -3.314135e-1
+vn 2.7059817e-1 9.238796e-1 -2.70598e-1
+vn 3.3141366e-1 9.238795e-1 -1.9134164e-1
+vn 3.696438e-1 9.238795e-1 -9.904586e-2
+vn 5.5557024e-1 8.3146966e-1 0e0
+vn 5.3663963e-1 8.3146966e-1 1.4379215e-1
+vn 4.8113793e-1 8.3146966e-1 2.7778512e-1
+vn 3.9284748e-1 8.3146966e-1 3.9284748e-1
+vn 2.777851e-1 8.3146966e-1 4.8113796e-1
+vn 1.4379211e-1 8.3146966e-1 5.366397e-1
+vn -2.4284747e-8 8.3146966e-1 5.5557024e-1
+vn -1.4379223e-1 8.3146966e-1 5.3663963e-1
+vn -2.7778515e-1 8.3146966e-1 4.8113793e-1
+vn -3.9284748e-1 8.3146966e-1 3.9284748e-1
+vn -4.81138e-1 8.3146966e-1 2.7778503e-1
+vn -5.366397e-1 8.3146966e-1 1.437921e-1
+vn -5.5557024e-1 8.3146966e-1 -4.8569493e-8
+vn -5.3663963e-1 8.3146966e-1 -1.4379218e-1
+vn -4.8113787e-1 8.3146966e-1 -2.777852e-1
+vn -3.928475e-1 8.3146966e-1 -3.9284745e-1
+vn -2.7778506e-1 8.3146966e-1 -4.8113796e-1
+vn -1.4379212e-1 8.3146966e-1 -5.366397e-1
+vn 6.625109e-9 8.3146966e-1 -5.5557024e-1
+vn 1.4379214e-1 8.3146966e-1 -5.3663963e-1
+vn 2.7778533e-1 8.3146966e-1 -4.8113784e-1
+vn 3.9284763e-1 8.3146966e-1 -3.9284736e-1
+vn 4.8113805e-1 8.3146966e-1 -2.77785e-1
+vn 5.3663963e-1 8.3146966e-1 -1.4379229e-1
+vn 7.071068e-1 7.0710677e-1 0e0
+vn 6.830127e-1 7.0710677e-1 1.8301271e-1
+vn 6.1237246e-1 7.0710677e-1 3.535534e-1
+vn 5e-1 7.0710677e-1 5e-1
+vn 3.5355338e-1 7.0710677e-1 6.123725e-1
+vn 1.8301265e-1 7.0710677e-1 6.830128e-1
+vn -3.0908623e-8 7.0710677e-1 7.071068e-1
+vn -1.830128e-1 7.0710677e-1 6.830127e-1
+vn -3.5355344e-1 7.0710677e-1 6.1237246e-1
+vn -5e-1 7.0710677e-1 5e-1
+vn -6.123726e-1 7.0710677e-1 3.535533e-1
+vn -6.830128e-1 7.0710677e-1 1.8301263e-1
+vn -7.071068e-1 7.0710677e-1 -6.1817246e-8
+vn -6.830127e-1 7.0710677e-1 -1.8301274e-1
+vn -6.123724e-1 7.0710677e-1 -3.5355353e-1
+vn -5.0000006e-1 7.0710677e-1 -5e-1
+vn -3.5355335e-1 7.0710677e-1 -6.123725e-1
+vn -1.8301266e-1 7.0710677e-1 -6.830128e-1
+vn 8.432164e-9 7.0710677e-1 -7.071068e-1
+vn 1.830127e-1 7.0710677e-1 -6.830127e-1
+vn 3.5355365e-1 7.0710677e-1 -6.1237234e-1
+vn 5.0000024e-1 7.0710677e-1 -4.9999988e-1
+vn 6.123726e-1 7.0710677e-1 -3.5355324e-1
+vn 6.830127e-1 7.0710677e-1 -1.8301289e-1
+vn 8.3146966e-1 5.5557024e-1 0e0
+vn 8.03138e-1 5.5557024e-1 2.1520019e-1
+vn 7.200738e-1 5.5557024e-1 4.1573483e-1
+vn 5.8793783e-1 5.5557024e-1 5.8793783e-1
+vn 4.157348e-1 5.5557024e-1 7.200739e-1
+vn 2.1520008e-1 5.555702e-1 8.0313796e-1
+vn -3.6344694e-8 5.5557024e-1 8.3146966e-1
+vn -2.1520028e-1 5.5557024e-1 8.03138e-1
+vn -4.157349e-1 5.5557024e-1 7.200738e-1
+vn -5.8793783e-1 5.5557024e-1 5.8793783e-1
+vn -7.2007394e-1 5.5557024e-1 4.1573468e-1
+vn -8.031381e-1 5.5557024e-1 2.1520008e-1
+vn -8.3146966e-1 5.5557024e-1 -7.268939e-8
+vn -8.03138e-1 5.5557024e-1 -2.152002e-1
+vn -7.2007376e-1 5.5557024e-1 -4.1573498e-1
+vn -5.879379e-1 5.5557024e-1 -5.879378e-1
+vn -4.1573477e-1 5.5557024e-1 -7.200739e-1
+vn -2.152001e-1 5.555702e-1 -8.0313796e-1
+vn 9.915176e-9 5.5557024e-1 -8.3146966e-1
+vn 2.1520016e-1 5.5557024e-1 -8.03138e-1
+vn 4.1573513e-1 5.5557024e-1 -7.200737e-1
+vn 5.87938e-1 5.5557024e-1 -5.879376e-1
+vn 7.20074e-1 5.5557024e-1 -4.1573462e-1
+vn 8.0313796e-1 5.5557024e-1 -2.1520038e-1
+vn 9.238796e-1 3.8268346e-1 0e0
+vn 8.923992e-1 3.8268346e-1 2.3911764e-1
+vn 8.0010325e-1 3.8268346e-1 4.619398e-1
+vn 6.532815e-1 3.8268346e-1 6.532815e-1
+vn 4.6193972e-1 3.826834e-1 8.001032e-1
+vn 2.3911753e-1 3.826834e-1 8.9239913e-1
+vn -4.038406e-8 3.8268346e-1 9.238796e-1
+vn -2.3911773e-1 3.826834e-1 8.923991e-1
+vn -4.619398e-1 3.826834e-1 8.001031e-1
+vn -6.532815e-1 3.8268346e-1 6.532815e-1
+vn -8.001033e-1 3.8268346e-1 4.6193963e-1
+vn -8.9239913e-1 3.826834e-1 2.391175e-1
+vn -9.238796e-1 3.8268346e-1 -8.076812e-8
+vn -8.923992e-1 3.8268346e-1 -2.3911767e-1
+vn -8.001031e-1 3.8268346e-1 -4.6194e-1
+vn -6.5328157e-1 3.8268346e-1 -6.5328145e-1
+vn -4.6193972e-1 3.8268346e-1 -8.001033e-1
+vn -2.3911755e-1 3.826834e-1 -8.9239913e-1
+vn 1.1017153e-8 3.8268346e-1 -9.238796e-1
+vn 2.3911761e-1 3.8268346e-1 -8.923992e-1
+vn 4.6194014e-1 3.8268346e-1 -8.0010307e-1
+vn 6.5328175e-1 3.8268346e-1 -6.532813e-1
+vn 8.0010337e-1 3.8268346e-1 -4.619396e-1
+vn 8.9239913e-1 3.8268346e-1 -2.3911786e-1
+vn 9.807853e-1 1.9509017e-1 0e0
+vn 9.4736594e-1 1.950902e-1 2.5384596e-1
+vn 8.493851e-1 1.950902e-1 4.903927e-1
+vn 6.9351995e-1 1.9509017e-1 6.9351995e-1
+vn 4.9039263e-1 1.9509017e-1 8.49385e-1
+vn 2.5384584e-1 1.9509017e-1 9.473659e-1
+vn -4.2871488e-8 1.9509017e-1 9.807853e-1
+vn -2.5384602e-1 1.9509017e-1 9.473658e-1
+vn -4.903927e-1 1.9509017e-1 8.4938496e-1
+vn -6.9351995e-1 1.9509017e-1 6.9351995e-1
+vn -8.493851e-1 1.9509017e-1 4.9039248e-1
+vn -9.473659e-1 1.9509017e-1 2.538458e-1
+vn -9.807853e-1 1.9509017e-1 -8.5742975e-8
+vn -9.4736594e-1 1.950902e-1 -2.53846e-1
+vn -8.4938496e-1 1.950902e-1 -4.903929e-1
+vn -6.9352e-1 1.9509017e-1 -6.935199e-1
+vn -4.9039257e-1 1.9509017e-1 -8.49385e-1
+vn -2.5384587e-1 1.9509017e-1 -9.473659e-1
+vn 1.16957475e-8 1.9509017e-1 -9.807853e-1
+vn 2.5384593e-1 1.950902e-1 -9.4736594e-1
+vn 4.90393e-1 1.9509017e-1 -8.493848e-1
+vn 6.935202e-1 1.9509017e-1 -6.935197e-1
+vn 8.4938514e-1 1.9509017e-1 -4.9039242e-1
+vn 9.473659e-1 1.950902e-1 -2.5384617e-1
+vn 1e0 0e0 0e0
+vn 9.6592593e-1 0e0 2.5881907e-1
+vn 8.660254e-1 0e0 5e-1
+vn 7.071068e-1 0e0 7.071068e-1
+vn 4.9999997e-1 0e0 8.6602545e-1
+vn 2.5881895e-1 0e0 9.659259e-1
+vn -4.371139e-8 0e0 1e0
+vn -2.5881916e-1 0e0 9.659258e-1
+vn -5.0000006e-1 0e0 8.660254e-1
+vn -7.071068e-1 0e0 7.071068e-1
+vn -8.660255e-1 0e0 4.9999982e-1
+vn -9.659259e-1 0e0 2.5881892e-1
+vn -1e0 0e0 -8.742278e-8
+vn -9.659258e-1 0e0 -2.5881907e-1
+vn -8.660254e-1 0e0 -5.0000024e-1
+vn -7.071068e-1 0e0 -7.071067e-1
+vn -4.999999e-1 0e0 -8.6602545e-1
+vn -2.5881898e-1 0e0 -9.659259e-1
+vn 1.1924881e-8 0e0 -1e0
+vn 2.5881904e-1 0e0 -9.6592593e-1
+vn 5.0000036e-1 0e0 -8.660252e-1
+vn 7.0710707e-1 0e0 -7.071066e-1
+vn 8.6602557e-1 0e0 -4.9999976e-1
+vn 9.659259e-1 0e0 -2.588193e-1
+vn 9.8078537e-1 -1.9509032e-1 0e0
+vn 9.473659e-1 -1.9509032e-1 2.5384593e-1
+vn 8.49385e-1 -1.9509032e-1 4.9039268e-1
+vn 6.9351995e-1 -1.9509032e-1 6.9351995e-1
+vn 4.9039266e-1 -1.9509032e-1 8.493851e-1
+vn 2.5384584e-1 -1.9509032e-1 9.4736594e-1
+vn -4.2871488e-8 -1.9509032e-1 9.8078537e-1
+vn -2.5384605e-1 -1.9509032e-1 9.473659e-1
+vn -4.9039274e-1 -1.9509032e-1 8.49385e-1
+vn -6.9351995e-1 -1.9509032e-1 6.9351995e-1
+vn -8.4938514e-1 -1.9509032e-1 4.903925e-1
+vn -9.4736594e-1 -1.9509032e-1 2.538458e-1
+vn -9.8078537e-1 -1.9509032e-1 -8.5742975e-8
+vn -9.473659e-1 -1.9509032e-1 -2.5384596e-1
+vn -8.493849e-1 -1.9509032e-1 -4.9039286e-1
+vn -6.9352e-1 -1.9509032e-1 -6.935199e-1
+vn -4.903926e-1 -1.9509032e-1 -8.493851e-1
+vn -2.5384587e-1 -1.9509032e-1 -9.4736594e-1
+vn 1.1695749e-8 -1.9509032e-1 -9.8078537e-1
+vn 2.538459e-1 -1.9509032e-1 -9.473659e-1
+vn 4.9039304e-1 -1.9509032e-1 -8.4938484e-1
+vn 6.935202e-1 -1.9509032e-1 -6.935197e-1
+vn 8.493851e-1 -1.950903e-1 -4.903924e-1
+vn 9.4736576e-1 -1.950903e-1 -2.5384614e-1
+vn 9.238795e-1 -3.8268352e-1 0e0
+vn 8.923991e-1 -3.8268352e-1 2.3911761e-1
+vn 8.001031e-1 -3.8268352e-1 4.6193975e-1
+vn 6.5328145e-1 -3.8268352e-1 6.5328145e-1
+vn 4.6193972e-1 -3.8268352e-1 8.001032e-1
+vn 2.3911753e-1 -3.8268352e-1 8.9239913e-1
+vn -4.0384055e-8 -3.8268352e-1 9.238795e-1
+vn -2.3911773e-1 -3.8268352e-1 8.923991e-1
+vn -4.619398e-1 -3.8268352e-1 8.001031e-1
+vn -6.5328145e-1 -3.8268352e-1 6.5328145e-1
+vn -8.001032e-1 -3.8268352e-1 4.6193957e-1
+vn -8.9239913e-1 -3.8268352e-1 2.391175e-1
+vn -9.238795e-1 -3.8268352e-1 -8.076811e-8
+vn -8.923991e-1 -3.8268352e-1 -2.3911764e-1
+vn -8.00103e-1 -3.8268352e-1 -4.6193993e-1
+vn -6.532815e-1 -3.8268352e-1 -6.532814e-1
+vn -4.6193966e-1 -3.8268352e-1 -8.001032e-1
+vn -2.3911755e-1 -3.8268352e-1 -8.9239913e-1
+vn 1.10171525e-8 -3.8268352e-1 -9.238795e-1
+vn 2.3911758e-1 -3.8268352e-1 -8.923991e-1
+vn 4.6194008e-1 -3.8268352e-1 -8.0010295e-1
+vn 6.532817e-1 -3.8268352e-1 -6.532812e-1
+vn 8.0010325e-1 -3.8268352e-1 -4.6193954e-1
+vn 8.92399e-1 -3.8268352e-1 -2.3911783e-1
+vn 8.3146954e-1 -5.5557036e-1 0e0
+vn 8.031379e-1 -5.5557036e-1 2.1520016e-1
+vn 7.2007376e-1 -5.555704e-1 4.1573483e-1
+vn 5.879378e-1 -5.555704e-1 5.879378e-1
+vn 4.1573474e-1 -5.5557036e-1 7.2007376e-1
+vn 2.1520008e-1 -5.5557036e-1 8.0313796e-1
+vn -3.6344687e-8 -5.5557036e-1 8.3146954e-1
+vn -2.1520025e-1 -5.5557036e-1 8.031379e-1
+vn -4.1573483e-1 -5.5557036e-1 7.200737e-1
+vn -5.879378e-1 -5.555704e-1 5.879378e-1
+vn -7.200738e-1 -5.5557036e-1 4.1573462e-1
+vn -8.0313796e-1 -5.5557036e-1 2.1520005e-1
+vn -8.3146954e-1 -5.5557036e-1 -7.2689375e-8
+vn -8.031379e-1 -5.5557036e-1 -2.1520017e-1
+vn -7.2007364e-1 -5.5557036e-1 -4.1573492e-1
+vn -5.879378e-1 -5.5557036e-1 -5.879377e-1
+vn -4.157347e-1 -5.5557036e-1 -7.2007376e-1
+vn -2.152001e-1 -5.5557036e-1 -8.0313796e-1
+vn 9.915175e-9 -5.5557036e-1 -8.3146954e-1
+vn 2.1520013e-1 -5.5557036e-1 -8.031379e-1
+vn 4.1573507e-1 -5.5557036e-1 -7.200736e-1
+vn 5.8793795e-1 -5.5557036e-1 -5.8793753e-1
+vn 7.200739e-1 -5.5557036e-1 -4.1573456e-1
+vn 8.0313784e-1 -5.5557036e-1 -2.1520035e-1
+vn 7.071068e-1 -7.071068e-1 0e0
+vn 6.830127e-1 -7.071068e-1 1.8301271e-1
+vn 6.1237246e-1 -7.071068e-1 3.535534e-1
+vn 5e-1 -7.071068e-1 5e-1
+vn 3.5355335e-1 -7.0710677e-1 6.1237246e-1
+vn 1.8301263e-1 -7.0710677e-1 6.830127e-1
+vn -3.0908623e-8 -7.071068e-1 7.071068e-1
+vn -1.830128e-1 -7.071068e-1 6.830127e-1
+vn -3.5355344e-1 -7.071068e-1 6.1237246e-1
+vn -5e-1 -7.071068e-1 5e-1
+vn -6.123725e-1 -7.0710677e-1 3.5355327e-1
+vn -6.830127e-1 -7.0710677e-1 1.8301262e-1
+vn -7.071068e-1 -7.071068e-1 -6.1817246e-8
+vn -6.830127e-1 -7.071068e-1 -1.8301274e-1
+vn -6.123724e-1 -7.071068e-1 -3.5355353e-1
+vn -5.0000006e-1 -7.071068e-1 -5e-1
+vn -3.5355332e-1 -7.0710677e-1 -6.1237246e-1
+vn -1.8301265e-1 -7.0710677e-1 -6.830127e-1
+vn 8.432164e-9 -7.071068e-1 -7.071068e-1
+vn 1.830127e-1 -7.071068e-1 -6.830127e-1
+vn 3.5355365e-1 -7.071068e-1 -6.1237234e-1
+vn 5.0000024e-1 -7.071068e-1 -4.9999988e-1
+vn 6.123726e-1 -7.071068e-1 -3.5355324e-1
+vn 6.830127e-1 -7.071068e-1 -1.8301289e-1
+vn 5.555702e-1 -8.3146966e-1 0e0
+vn 5.3663963e-1 -8.314698e-1 1.4379215e-1
+vn 4.8113787e-1 -8.3146966e-1 2.777851e-1
+vn 3.9284745e-1 -8.3146966e-1 3.9284745e-1
+vn 2.7778506e-1 -8.3146966e-1 4.8113793e-1
+vn 1.437921e-1 -8.3146966e-1 5.3663963e-1
+vn -2.4284745e-8 -8.3146966e-1 5.555702e-1
+vn -1.4379221e-1 -8.3146966e-1 5.366396e-1
+vn -2.7778512e-1 -8.3146966e-1 4.8113787e-1
+vn -3.9284745e-1 -8.3146966e-1 3.9284745e-1
+vn -4.8113796e-1 -8.3146966e-1 2.77785e-1
+vn -5.3663963e-1 -8.3146966e-1 1.4379208e-1
+vn -5.555702e-1 -8.3146966e-1 -4.856949e-8
+vn -5.3663963e-1 -8.314698e-1 -1.4379218e-1
+vn -4.8113787e-1 -8.314698e-1 -2.777852e-1
+vn -3.9284748e-1 -8.3146966e-1 -3.9284742e-1
+vn -2.7778503e-1 -8.3146966e-1 -4.8113793e-1
+vn -1.4379211e-1 -8.3146966e-1 -5.3663963e-1
+vn 6.625108e-9 -8.3146966e-1 -5.555702e-1
+vn 1.4379212e-1 -8.3146966e-1 -5.366396e-1
+vn 2.777853e-1 -8.3146966e-1 -4.8113778e-1
+vn 3.9284763e-1 -8.314698e-1 -3.9284736e-1
+vn 4.81138e-1 -8.3146966e-1 -2.7778497e-1
+vn 5.3663963e-1 -8.314698e-1 -1.4379229e-1
+vn 3.8268328e-1 -9.238796e-1 0e0
+vn 3.6964366e-1 -9.238796e-1 9.9045716e-2
+vn 3.3141345e-1 -9.238796e-1 1.9134164e-1
+vn 2.7059793e-1 -9.238796e-1 2.7059793e-1
+vn 1.9134162e-1 -9.238796e-1 3.3141345e-1
+vn 9.904569e-2 -9.238796e-1 3.696437e-1
+vn -1.6727617e-8 -9.238796e-1 3.8268328e-1
+vn -9.904577e-2 -9.238796e-1 3.6964366e-1
+vn -1.9134167e-1 -9.238796e-1 3.3141345e-1
+vn -2.7059793e-1 -9.238796e-1 2.7059793e-1
+vn -3.3141348e-1 -9.238796e-1 1.9134156e-1
+vn -3.696437e-1 -9.238796e-1 9.904567e-2
+vn -3.8268328e-1 -9.238796e-1 -3.3455233e-8
+vn -3.6964366e-1 -9.238796e-1 -9.904573e-2
+vn -3.314134e-1 -9.238796e-1 -1.9134171e-1
+vn -2.7059796e-1 -9.238796e-1 -2.705979e-1
+vn -1.9134161e-1 -9.238796e-1 -3.3141345e-1
+vn -9.9045694e-2 -9.238796e-1 -3.696437e-1
+vn 4.5634523e-9 -9.238796e-1 -3.8268328e-1
+vn 9.904571e-2 -9.238796e-1 -3.6964366e-1
+vn 1.9134177e-1 -9.238796e-1 -3.3141336e-1
+vn 2.7059802e-1 -9.238796e-1 -2.7059785e-1
+vn 3.314135e-1 -9.238796e-1 -1.9134155e-1
+vn 3.6964363e-1 -9.238796e-1 -9.904581e-2
+vn 1.9509031e-1 -9.807853e-1 0e0
+vn 1.8844277e-1 -9.807853e-1 5.0493088e-2
+vn 1.6895317e-1 -9.807853e-1 9.7545154e-2
+vn 1.3794968e-1 -9.807853e-1 1.3794968e-1
+vn 9.754515e-2 -9.807853e-1 1.6895317e-1
+vn 5.049307e-2 -9.807853e-1 1.8844278e-1
+vn -8.527668e-9 -9.807853e-1 1.9509031e-1
+vn -5.049311e-2 -9.807853e-1 1.8844277e-1
+vn -9.754517e-2 -9.807853e-1 1.6895317e-1
+vn -1.3794968e-1 -9.807853e-1 1.3794968e-1
+vn -1.6895318e-1 -9.807853e-1 9.754512e-2
+vn -1.8844278e-1 -9.807853e-1 5.0493065e-2
+vn -1.9509031e-1 -9.807853e-1 -1.7055337e-8
+vn -1.8844277e-1 -9.807853e-1 -5.049309e-2
+vn -1.6895314e-1 -9.807853e-1 -9.754519e-2
+vn -1.3794969e-1 -9.807853e-1 -1.3794966e-1
+vn -9.754514e-2 -9.807853e-1 -1.6895317e-1
+vn -5.0493076e-2 -9.807853e-1 -1.8844278e-1
+vn 2.3264286e-9 -9.807853e-1 -1.9509031e-1
+vn 5.049308e-2 -9.807853e-1 -1.8844277e-1
+vn 9.754522e-2 -9.807853e-1 -1.6895312e-1
+vn 1.3794972e-1 -9.807853e-1 -1.3794963e-1
+vn 1.689532e-1 -9.807853e-1 -9.754511e-2
+vn 1.8844275e-1 -9.807853e-1 -5.0493132e-2
+vn -8.742278e-8 -1e0 0e0
+f 1//1 3//3 2//2
+f 1//1 4//4 3//3
+f 1//1 5//5 4//4
+f 1//1 6//6 5//5
+f 1//1 7//7 6//6
+f 1//1 8//8 7//7
+f 1//1 9//9 8//8
+f 1//1 10//10 9//9
+f 1//1 11//11 10//10
+f 1//1 12//12 11//11
+f 1//1 13//13 12//12
+f 1//1 14//14 13//13
+f 1//1 15//15 14//14
+f 1//1 16//16 15//15
+f 1//1 17//17 16//16
+f 1//1 18//18 17//17
+f 1//1 19//19 18//18
+f 1//1 20//20 19//19
+f 1//1 21//21 20//20
+f 1//1 22//22 21//21
+f 1//1 23//23 22//22
+f 1//1 24//24 23//23
+f 1//1 25//25 24//24
+f 1//1 2//2 25//25
+f 2//2 3//3 27//27 26//26
+f 3//3 4//4 28//28 27//27
+f 4//4 5//5 29//29 28//28
+f 5//5 6//6 30//30 29//29
+f 6//6 7//7 31//31 30//30
+f 7//7 8//8 32//32 31//31
+f 8//8 9//9 33//33 32//32
+f 9//9 10//10 34//34 33//33
+f 10//10 11//11 35//35 34//34
+f 11//11 12//12 36//36 35//35
+f 12//12 13//13 37//37 36//36
+f 13//13 14//14 38//38 37//37
+f 14//14 15//15 39//39 38//38
+f 15//15 16//16 40//40 39//39
+f 16//16 17//17 41//41 40//40
+f 17//17 18//18 42//42 41//41
+f 18//18 19//19 43//43 42//42
+f 19//19 20//20 44//44 43//43
+f 20//20 21//21 45//45 44//44
+f 21//21 22//22 46//46 45//45
+f 22//22 23//23 47//47 46//46
+f 23//23 24//24 48//48 47//47
+f 24//24 25//25 49//49 48//48
+f 25//25 2//2 26//26 49//49
+f 26//26 27//27 51//51 50//50
+f 27//27 28//28 52//52 51//51
+f 28//28 29//29 53//53 52//52
+f 29//29 30//30 54//54 53//53
+f 30//30 31//31 55//55 54//54
+f 31//31 32//32 56//56 55//55
+f 32//32 33//33 57//57 56//56
+f 33//33 34//34 58//58 57//57
+f 34//34 35//35 59//59 58//58
+f 35//35 36//36 60//60 59//59
+f 36//36 37//37 61//61 60//60
+f 37//37 38//38 62//62 61//61
+f 38//38 39//39 63//63 62//62
+f 39//39 40//40 64//64 63//63
+f 40//40 41//41 65//65 64//64
+f 41//41 42//42 66//66 65//65
+f 42//42 43//43 67//67 66//66
+f 43//43 44//44 68//68 67//67
+f 44//44 45//45 69//69 68//68
+f 45//45 46//46 70//70 69//69
+f 46//46 47//47 71//71 70//70
+f 47//47 48//48 72//72 71//71
+f 48//48 49//49 73//73 72//72
+f 49//49 26//26 50//50 73//73
+f 50//50 51//51 75//75 74//74
+f 51//51 52//52 76//76 75//75
+f 52//52 53//53 77//77 76//76
+f 53//53 54//54 78//78 77//77
+f 54//54 55//55 79//79 78//78
+f 55//55 56//56 80//80 79//79
+f 56//56 57//57 81//81 80//80
+f 57//57 58//58 82//82 81//81
+f 58//58 59//59 83//83 82//82
+f 59//59 60//60 84//84 83//83
+f 60//60 61//61 85//85 84//84
+f 61//61 62//62 86//86 85//85
+f 62//62 63//63 87//87 86//86
+f 63//63 64//64 88//88 87//87
+f 64//64 65//65 89//89 88//88
+f 65//65 66//66 90//90 89//89
+f 66//66 67//67 91//91 90//90
+f 67//67 68//68 92//92 91//91
+f 68//68 69//69 93//93 92//92
+f 69//69 70//70 94//94 93//93
+f 70//70 71//71 95//95 94//94
+f 71//71 72//72 96//96 95//95
+f 72//72 73//73 97//97 96//96
+f 73//73 50//50 74//74 97//97
+f 74//74 75//75 99//99 98//98
+f 75//75 76//76 100//100 99//99
+f 76//76 77//77 101//101 100//100
+f 77//77 78//78 102//102 101//101
+f 78//78 79//79 103//103 102//102
+f 79//79 80//80 104//104 103//103
+f 80//80 81//81 105//105 104//104
+f 81//81 82//82 106//106 105//105
+f 82//82 83//83 107//107 106//106
+f 83//83 84//84 108//108 107//107
+f 84//84 85//85 109//109 108//108
+f 85//85 86//86 110//110 109//109
+f 86//86 87//87 111//111 110//110
+f 87//87 88//88 112//112 111//111
+f 88//88 89//89 113//113 112//112
+f 89//89 90//90 114//114 113//113
+f 90//90 91//91 115//115 114//114
+f 91//91 92//92 116//116 115//115
+f 92//92 93//93 117//117 116//116
+f 93//93 94//94 118//118 117//117
+f 94//94 95//95 119//119 118//118
+f 95//95 96//96 120//120 119//119
+f 96//96 97//97 121//121 120//120
+f 97//97 74//74 98//98 121//121
+f 98//98 99//99 123//123 122//122
+f 99//99 100//100 124//124 123//123
+f 100//100 101//101 125//125 124//124
+f 101//101 102//102 126//126 125//125
+f 102//102 103//103 127//127 126//126
+f 103//103 104//104 128//128 127//127
+f 104//104 105//105 129//129 128//128
+f 105//105 106//106 130//130 129//129
+f 106//106 107//107 131//131 130//130
+f 107//107 108//108 132//132 131//131
+f 108//108 109//109 133//133 132//132
+f 109//109 110//110 134//134 133//133
+f 110//110 111//111 135//135 134//134
+f 111//111 112//112 136//136 135//135
+f 112//112 113//113 137//137 136//136
+f 113//113 114//114 138//138 137//137
+f 114//114 115//115 139//139 138//138
+f 115//115 116//116 140//140 139//139
+f 116//116 117//117 141//141 140//140
+f 117//117 118//118 142//142 141//141
+f 118//118 119//119 143//143 142//142
+f 119//119 120//120 144//144 143//143
+f 120//120 121//121 145//145 144//144
+f 121//121 98//98 122//122 145//145
+f 122//122 123//123 147//147 146//146
+f 123//123 124//124 148//148 147//147
+f 124//124 125//125 149//149 148//148
+f 125//125 126//126 150//150 149//149
+f 126//126 127//127 151//151 150//150
+f 127//127 128//128 152//152 151//151
+f 128//128 129//129 153//153 152//152
+f 129//129 130//130 154//154 153//153
+f 130//130 131//131 155//155 154//154
+f 131//131 132//132 156//156 155//155
+f 132//132 133//133 157//157 156//156
+f 133//133 134//134 158//158 157//157
+f 134//134 135//135 159//159 158//158
+f 135//135 136//136 160//160 159//159
+f 136//136 137//137 161//161 160//160
+f 137//137 138//138 162//162 161//161
+f 138//138 139//139 163//163 162//162
+f 139//139 140//140 164//164 163//163
+f 140//140 141//141 165//165 164//164
+f 141//141 142//142 166//166 165//165
+f 142//142 143//143 167//167 166//166
+f 143//143 144//144 168//168 167//167
+f 144//144 145//145 169//169 168//168
+f 145//145 122//122 146//146 169//169
+f 146//146 147//147 171//171 170//170
+f 147//147 148//148 172//172 171//171
+f 148//148 149//149 173//173 172//172
+f 149//149 150//150 174//174 173//173
+f 150//150 151//151 175//175 174//174
+f 151//151 152//152 176//176 175//175
+f 152//152 153//153 177//177 176//176
+f 153//153 154//154 178//178 177//177
+f 154//154 155//155 179//179 178//178
+f 155//155 156//156 180//180 179//179
+f 156//156 157//157 181//181 180//180
+f 157//157 158//158 182//182 181//181
+f 158//158 159//159 183//183 182//182
+f 159//159 160//160 184//184 183//183
+f 160//160 161//161 185//185 184//184
+f 161//161 162//162 186//186 185//185
+f 162//162 163//163 187//187 186//186
+f 163//163 164//164 188//188 187//187
+f 164//164 165//165 189//189 188//188
+f 165//165 166//166 190//190 189//189
+f 166//166 167//167 191//191 190//190
+f 167//167 168//168 192//192 191//191
+f 168//168 169//169 193//193 192//192
+f 169//169 146//146 170//170 193//193
+f 170//170 171//171 195//195 194//194
+f 171//171 172//172 196//196 195//195
+f 172//172 173//173 197//197 196//196
+f 173//173 174//174 198//198 197//197
+f 174//174 175//175 199//199 198//198
+f 175//175 176//176 200//200 199//199
+f 176//176 177//177 201//201 200//200
+f 177//177 178//178 202//202 201//201
+f 178//178 179//179 203//203 202//202
+f 179//179 180//180 204//204 203//203
+f 180//180 181//181 205//205 204//204
+f 181//181 182//182 206//206 205//205
+f 182//182 183//183 207//207 206//206
+f 183//183 184//184 208//208 207//207
+f 184//184 185//185 209//209 208//208
+f 185//185 186//186 210//210 209//209
+f 186//186 187//187 211//211 210//210
+f 187//187 188//188 212//212 211//211
+f 188//188 189//189 213//213 212//212
+f 189//189 190//190 214//214 213//213
+f 190//190 191//191 215//215 214//214
+f 191//191 192//192 216//216 215//215
+f 192//192 193//193 217//217 216//216
+f 193//193 170//170 194//194 217//217
+f 194//194 195//195 219//219 218//218
+f 195//195 196//196 220//220 219//219
+f 196//196 197//197 221//221 220//220
+f 197//197 198//198 222//222 221//221
+f 198//198 199//199 223//223 222//222
+f 199//199 200//200 224//224 223//223
+f 200//200 201//201 225//225 224//224
+f 201//201 202//202 226//226 225//225
+f 202//202 203//203 227//227 226//226
+f 203//203 204//204 228//228 227//227
+f 204//204 205//205 229//229 228//228
+f 205//205 206//206 230//230 229//229
+f 206//206 207//207 231//231 230//230
+f 207//207 208//208 232//232 231//231
+f 208//208 209//209 233//233 232//232
+f 209//209 210//210 234//234 233//233
+f 210//210 211//211 235//235 234//234
+f 211//211 212//212 236//236 235//235
+f 212//212 213//213 237//237 236//236
+f 213//213 214//214 238//238 237//237
+f 214//214 215//215 239//239 238//238
+f 215//215 216//216 240//240 239//239
+f 216//216 217//217 241//241 240//240
+f 217//217 194//194 218//218 241//241
+f 218//218 219//219 243//243 242//242
+f 219//219 220//220 244//244 243//243
+f 220//220 221//221 245//245 244//244
+f 221//221 222//222 246//246 245//245
+f 222//222 223//223 247//247 246//246
+f 223//223 224//224 248//248 247//247
+f 224//224 225//225 249//249 248//248
+f 225//225 226//226 250//250 249//249
+f 226//226 227//227 251//251 250//250
+f 227//227 228//228 252//252 251//251
+f 228//228 229//229 253//253 252//252
+f 229//229 230//230 254//254 253//253
+f 230//230 231//231 255//255 254//254
+f 231//231 232//232 256//256 255//255
+f 232//232 233//233 257//257 256//256
+f 233//233 234//234 258//258 257//257
+f 234//234 235//235 259//259 258//258
+f 235//235 236//236 260//260 259//259
+f 236//236 237//237 261//261 260//260
+f 237//237 238//238 262//262 261//261
+f 238//238 239//239 263//263 262//262
+f 239//239 240//240 264//264 263//263
+f 240//240 241//241 265//265 264//264
+f 241//241 218//218 242//242 265//265
+f 242//242 243//243 267//267 266//266
+f 243//243 244//244 268//268 267//267
+f 244//244 245//245 269//269 268//268
+f 245//245 246//246 270//270 269//269
+f 246//246 247//247 271//271 270//270
+f 247//247 248//248 272//272 271//271
+f 248//248 249//249 273//273 272//272
+f 249//249 250//250 274//274 273//273
+f 250//250 251//251 275//275 274//274
+f 251//251 252//252 276//276 275//275
+f 252//252 253//253 277//277 276//276
+f 253//253 254//254 278//278 277//277
+f 254//254 255//255 279//279 278//278
+f 255//255 256//256 280//280 279//279
+f 256//256 257//257 281//281 280//280
+f 257//257 258//258 282//282 281//281
+f 258//258 259//259 283//283 282//282
+f 259//259 260//260 284//284 283//283
+f 260//260 261//261 285//285 284//284
+f 261//261 262//262 286//286 285//285
+f 262//262 263//263 287//287 286//286
+f 263//263 264//264 288//288 287//287
+f 264//264 265//265 289//289 288//288
+f 265//265 242//242 266//266 289//289
+f 266//266 267//267 291//291 290//290
+f 267//267 268//268 292//292 291//291
+f 268//268 269//269 293//293 292//292
+f 269//269 270//270 294//294 293//293
+f 270//270 271//271 295//295 294//294
+f 271//271 272//272 296//296 295//295
+f 272//272 273//273 297//297 296//296
+f 273//273 274//274 298//298 297//297
+f 274//274 275//275 299//299 298//298
+f 275//275 276//276 300//300 299//299
+f 276//276 277//277 301//301 300//300
+f 277//277 278//278 302//302 301//301
+f 278//278 279//279 303//303 302//302
+f 279//279 280//280 304//304 303//303
+f 280//280 281//281 305//305 304//304
+f 281//281 282//282 306//306 305//305
+f 282//282 283//283 307//307 306//306
+f 283//283 284//284 308//308 307//307
+f 284//284 285//285 309//309 308//308
+f 285//285 286//286 310//310 309//309
+f 286//286 287//287 311//311 310//310
+f 287//287 288//288 312//312 311//311
+f 288//288 289//289 313//313 312//312
+f 289//289 266//266 290//290 313//313
+f 290//290 291//291 315//315 314//314
+f 291//291 292//292 316//316 315//315
+f 292//292 293//293 317//317 316//316
+f 293//293 294//294 318//318 317//317
+f 294//294 295//295 319//319 318//318
+f 295//295 296//296 320//320 319//319
+f 296//296 297//297 321//321 320//320
+f 297//297 298//298 322//322 321//321
+f 298//298 299//299 323//323 322//322
+f 299//299 300//300 324//324 323//323
+f 300//300 301//301 325//325 324//324
+f 301//301 302//302 326//326 325//325
+f 302//302 303//303 327//327 326//326
+f 303//303 304//304 328//328 327//327
+f 304//304 305//305 329//329 328//328
+f 305//305 306//306 330//330 329//329
+f 306//306 307//307 331//331 330//330
+f 307//307 308//308 332//332 331//331
+f 308//308 309//309 333//333 332//332
+f 309//309 310//310 334//334 333//333
+f 310//310 311//311 335//335 334//334
+f 311//311 312//312 336//336 335//335
+f 312//312 313//313 337//337 336//336
+f 313//313 290//290 314//314 337//337
+f 314//314 315//315 339//339 338//338
+f 315//315 316//316 340//340 339//339
+f 316//316 317//317 341//341 340//340
+f 317//317 318//318 342//342 341//341
+f 318//318 319//319 343//343 342//342
+f 319//319 320//320 344//344 343//343
+f 320//320 321//321 345//345 344//344
+f 321//321 322//322 346//346 345//345
+f 322//322 323//323 347//347 346//346
+f 323//323 324//324 348//348 347//347
+f 324//324 325//325 349//349 348//348
+f 325//325 326//326 350//350 349//349
+f 326//326 327//327 351//351 350//350
+f 327//327 328//328 352//352 351//351
+f 328//328 329//329 353//353 352//352
+f 329//329 330//330 354//354 353//353
+f 330//330 331//331 355//355 354//354
+f 331//331 332//332 356//356 355//355
+f 332//332 333//333 357//357 356//356
+f 333//333 334//334 358//358 357//357
+f 334//334 335//335 359//359 358//358
+f 335//335 336//336 360//360 359//359
+f 336//336 337//337 361//361 360//360
+f 337//337 314//314 338//338 361//361
+f 338//338 339//339 362//362
+f 339//339 340//340 362//362
+f 340//340 341//341 362//362
+f 341//341 342//342 362//362
+f 342//342 343//343 362//362
+f 343//343 344//344 362//362
+f 344//344 345//345 362//362
+f 345//345 346//346 362//362
+f 346//346 347//347 362//362
+f 347//347 348//348 362//362
+f 348//348 349//349 362//362
+f 349//349 350//350 362//362
+f 350//350 351//351 362//362
+f 351//351 352//352 362//362
+f 352//352 353//353 362//362
+f 353//353 354//354 362//362
+f 354//354 355//355 362//362
+f 355//355 356//356 362//362
+f 356//356 357//357 362//362
+f 357//357 358//358 362//362
+f 358//358 359//359 362//362
+f 359//359 360//360 362//362
+f 360//360 361//361 362//362
+f 361//361 338//338 362//362
diff --git a/tests/fixtures/designer-default.stl b/tests/fixtures/designer-default.stl
new file mode 100644
index 0000000..0bb5b71
Binary files /dev/null and b/tests/fixtures/designer-default.stl differ