flutter3d
Showcase Changelog 38 packages API reference

OBJ and MTL

since 0.1.0 Formats and I/O

Wavefront OBJ is plain text, older than glTF, and still what a lot of tools export by default. This page reads a tiny hand-written OBJ, fills in what it left out, and writes it back.

Step 1: A file with no normals #

Real OBJ files skip vn often enough that a loader has to have an answer for it. This tetrahedron has four vertices and four faces, and nothing else.

  // A tetrahedron with no `vn` records at all — real files omit normals
  // often enough that `ObjLoader` has to have an answer for it.
  static const String _sourceObj = '''
v 0 1 0
v 1 -1 1
v -1 -1 1
v 0 -1 -1.4
f 1 2 3
f 1 3 4
f 1 4 2
f 2 4 3
''';

Step 2: Decode it, and let the loader fill the gap #

ObjNormals.smooth, the default, averages the face normals meeting at each vertex rather than leaving them at zero. A flat-shaded look on a curved surface is exactly what leaving normals at zero would produce.

// `ObjNormals.smooth`, the loader's default, averages the face normals
// meeting at each vertex rather than leaving them at zero.
final bytes = Uint8List.fromList(utf8.encode(_sourceObj));
_decoded = await ObjLoader(normals: ObjNormals.smooth).load(bytes);

Note. ObjNormals.flat gives one normal per face instead, which needs shared vertices split apart first. ObjNormals.none leaves them at zero, for a caller that computes its own.

Step 3: Write it back out #

ObjWriter takes any ModelDocument, not only one ObjLoader produced, and writes the dialect the loader reads: the same V flip, the same sticky usemtl. This document has no material, so only the .obj comes out; a .mtl is written only when the document names one.

final writer = ObjWriter(_decoded, name: 'tetra');
_written = writer.write();

Step 4: Compare source and output #

The source had no normals; the file this page writes does, because a normal that was generated to satisfy the loader's own request is still a normal ObjWriter will write.

String _report() {
  final mesh = _decoded.surfaces.single.mesh;
  return 'source bytes: ${_sourceObj.length}\n'
      'written bytes: ${_written.length}\n'
      'vertices: ${mesh.vertexCount}\n'
      'triangles: ${mesh.triangleCount}\n'
      'a generated normal: ${mesh.vertices[3].toStringAsFixed(3)}, '
      '${mesh.vertices[4].toStringAsFixed(3)}, '
      '${mesh.vertices[5].toStringAsFixed(3)}';
}

Step 5: Check what the page claims #

Four faces in, four triangles out, and a generated normal that actually points somewhere rather than sitting at zero.

final mesh = _decoded.surfaces.single.mesh;
if (mesh.triangleCount != 4) {
  throw StateError('the tetrahedron did not decode to four triangles');
}
// A generated normal is never the zero vector `ObjNormals.none` would
// have left behind.
final nx = mesh.vertices[3];
final ny = mesh.vertices[4];
final nz = mesh.vertices[5];
if (nx == 0.0 && ny == 0.0 && nz == 0.0) {
  throw StateError('no normal was generated for a file that had none');
}
if (_written.isEmpty) {
  throw StateError('the writer produced nothing');
}
if (frame.drawCalls < 1) {
  throw StateError('the tetrahedron was not drawn');
}