Simple obj file loader

Hi, I am new to odin and have spent a weekend developing a little .obj file loader. I would appreciate if anyone could give me feedback and things that could be improved. Notably I would like to see if I’m doing things the “Odin way” like using Odin specific quality of life improvements of syntax I could be using, as opposed to the bare bones C way I’m used to.

package main
import "core:fmt"
import "core:os"
import "core:strings"
import "core:strconv"
import "core:mem"


obj_parse :: proc(filename: string, verbose := false) ->
  (vertex_positions: []f32, 
   vertex_colors: []f32,
   vertex_texture_coordinates: []f32,
   vertex_normals: []f32,
   indices: []u32) {
  data, read_ok := os.read_entire_file(filename)
  if read_ok {
    return obj_parse_from_memory(data, verbose)
  }
  fmt.eprintfln("Failed to read %s obj file", filename)
  return {}, {}, {}, {}, {}
}

@(private) OBJModeType :: enum {
  none,
  vertex_pos,
  vertex_nor,
  vertex_tex,
  face_ind
}

obj_parse_from_memory :: proc(contents: []u8, verbose := false) -> 
  (vertex_positions: []f32, 
   vertex_colors: []f32,
   vertex_texture_coordinates: []f32,
   vertex_normals: []f32, 
   indices: []u32) {
  
  vertex_pos: [dynamic]f32
  vertex_col: [dynamic]f32
  vertex_nor: [dynamic]f32
  vertex_tex: [dynamic]f32
  face_ind: [dynamic]u32

  number_sb := strings.builder_make()

  state := OBJModeType.none
  is_comment := false
  line, col := 0, 0
  numbers_encountered := u32(0) // Per line
  is_beginning_of_line := true
  for char, i in contents {
    next_char: u8 = 0
    if len(contents) > i + 1 {
      next_char = contents[i+1]
    }

    if char == '#' {
      is_comment = true
    }
    col += 1
    if char == '\n' {
      is_comment = false
      is_beginning_of_line = true
      line += 1
      col = 0
      numbers_encountered = 0
      continue
    }
    if is_comment || (is_beginning_of_line && (char == ' ' || char == '\t')) {
      continue
    }

    if is_beginning_of_line {
      state = .none
      is_beginning_of_line = false
      if char == 'v' {
        numbers_encountered = 0
        switch next_char {
        case ' ': state = .vertex_pos
        case 'n': state = .vertex_nor
        case 't': state = .vertex_tex
        case: fmt.eprintfln("Invalid v%c directive at %d:%d in obj", next_char, line, col)
        }
      }

      if char == 'f' {
        state = .face_ind
      }

      if char == 's' || char == 'o' {
        state = .none
      }

      continue
    }

    if state != .none && is_numeric(char) {
      strings.write_byte(&number_sb, char)
      if !is_numeric(next_char) {
        value := strconv.parse_f32(strings.to_string(number_sb)) or_else 0
        strings.builder_reset(&number_sb)
        numbers_encountered += 1
        #partial switch state {
          case .vertex_pos: append((numbers_encountered > 3) ? &vertex_col : &vertex_pos, value)
          case .vertex_tex: append(&vertex_tex, value)
          case .vertex_nor: append(&vertex_nor, value)
          case .face_ind: append(&face_ind, u32(value))
        }
      }
    }

  }

  if verbose {
    fmt.printfln("POSLEN %d NORLEN %d TEXLEN %d FACLEN %d", len(vertex_pos), len(vertex_nor), len(vertex_tex), len(face_ind))
    if len(face_ind) < 50 {
      fmt.printfln("VERTEX NORMALS %f", vertex_nor[:])
      fmt.printfln("VERTEX POSITIONS %f", vertex_pos[:])
      fmt.printfln("VERTEX TEXTURE COORDINATES %f", vertex_tex[:])
      fmt.printfln("FACE INDICES %d %d", len(face_ind), face_ind[:])
    }
  }

  vertex_amount := len(face_ind) / 3
  out_vertex_positions := make([]f32, vertex_amount * 3)
  out_vertex_colors := make([]f32, vertex_amount * 3)
  out_vertex_normals := make([]f32, vertex_amount * 3)
  out_vertex_texture_coordinates := make([]f32, vertex_amount * 2)
  out_indices := make([]u32, vertex_amount)

  for i in 0..<vertex_amount {
    pos_index := face_ind[i * 3]     - 1
    tex_index := face_ind[i * 3 + 1] - 1
    nor_index := face_ind[i * 3 + 2] - 1

    out_vertex_positions[i * 3]     = vertex_pos[pos_index * 3]
    out_vertex_positions[i * 3 + 1] = vertex_pos[pos_index * 3 + 1]
    out_vertex_positions[i * 3 + 2] = vertex_pos[pos_index * 3 + 2]

    if len(vertex_col) > 0 {
      out_vertex_colors[i * 3]     = vertex_col[pos_index * 3]
      out_vertex_colors[i * 3 + 1] = vertex_col[pos_index * 3 + 1]
      out_vertex_colors[i * 3 + 2] = vertex_col[pos_index * 3 + 2]
    }

    out_vertex_normals[i * 3] = vertex_nor[nor_index * 3]
    out_vertex_normals[i * 3 + 1] = vertex_nor[nor_index * 3 + 1]
    out_vertex_normals[i * 3 + 2] = vertex_nor[nor_index * 3 + 2]

    out_vertex_texture_coordinates[i * 2] = vertex_tex[tex_index * 2]
    out_vertex_texture_coordinates[i * 2 + 1] = 1-vertex_tex[tex_index * 2 + 1]

    out_indices[i] = u32(i)
  }

  return out_vertex_positions,
  out_vertex_colors,
  out_vertex_texture_coordinates,
  out_vertex_normals,
  out_indices
}
2 Likes

Welcome to Odin!!

I see many things that could be recommended. I’m better at showing than explaining, so if you could include a reference to an example object file and the expected output, I could maybe show how this could be reduced by 30-60 lines (or maybe more). Also, where is main()?

A few things up front:

  • Are you on the latest version of Odin from latest release? I ask, because os.read_entire_file() now requires an explicit allocator and returns (data: [ ]byte, err: Error) now. This change happened with last months update.
  • I see multiple references to vertex_positions, vertex_colors, vertex_texture_coordinates, vertex_normals, indices. You can save on much of the repetition by defining a struct and returning that instead.
  • I also recommend to make the struct have [dynamic] arrays and appending directly to them instead of creating intermediary [dynamic] arrays and then assigning them. That will help to reduce redundancy and also it will be much easier to see how to clean up that memory when it is all in one place.
  • I touch on the memory part later.

Something like…

Object :: struct {
	vertex_positions:           [dynamic]f32,
	vertex_colors:              [dynamic]f32,
	vertex_texture_coordinates: [dynamic]f32,
	vertex_normals:             [dynamic]f32,
	indices:                    [dynamic]u32,
}

obj_parse :: proc(filename: string, verbose := false) -> (obj: Object) {
  data, err := os.read_entire_file(filename, context.allocator)
  if err == nil {
    return obj_parse_from_memory(data, verbose)
  }
  fmt.eprintfln("Failed to read %s obj file", filename)
  return
}

obj_parse_from_memory :: proc(contents: []u8, verbose := false) -> (obj: Object) {
	// ... skipped some stuff here to get to the return stuff for obj

	// I include this part as example for returning Object struct instead.
	// If my advice is followed to just append to these directly in the code I skipped above, this whole section may not even be needed.
	vertex_amount := len(face_ind) / 3
	obj.vertex_positions = make([dynamic]f32, vertex_amount * 3)
	obj.vertex_colors = make([dynamic]f32, vertex_amount * 3)
	obj.vertex_normals = make([dynamic]f32, vertex_amount * 3)
	obj.vertex_texture_coordinates = make([dynamic]f32, vertex_amount * 2)
	obj.indices = make([dynamic]u32, vertex_amount)

	for i in 0..<vertex_amount {
		pos_index := face_ind[i * 3]     - 1
		tex_index := face_ind[i * 3 + 1] - 1
		nor_index := face_ind[i * 3 + 2] - 1

		obj.vertex_positions[i * 3]     = vertex_pos[pos_index * 3]
		obj.vertex_positions[i * 3 + 1] = vertex_pos[pos_index * 3 + 1]
		obj.vertex_positions[i * 3 + 2] = vertex_pos[pos_index * 3 + 2]

		if len(vertex_col) > 0 {
			obj.vertex_colors[i * 3]     = vertex_col[pos_index * 3]
			obj.vertex_colors[i * 3 + 1] = vertex_col[pos_index * 3 + 1]
			obj.vertex_colors[i * 3 + 2] = vertex_col[pos_index * 3 + 2]
		}

		obj.vertex_normals[i * 3] = vertex_nor[nor_index * 3]
		obj.vertex_normals[i * 3 + 1] = vertex_nor[nor_index * 3 + 1]
		obj.vertex_normals[i * 3 + 2] = vertex_nor[nor_index * 3 + 2]

		obj.vertex_texture_coordinates[i * 2] = vertex_tex[tex_index * 2]
		obj.vertex_texture_coordinates[i * 2 + 1] = 1-vertex_tex[tex_index * 2 + 1]

		obj.indices[i] = u32(i)
	}

	return
}
1 Like

Thanks! Here is an example obj file mtllib triangle.mtl o triangle v 0.000000 0.000000 -1.000000 1.0000 0.0000 0.0000 v -0.800000 0.000000 0.500000 0.0000 1.0000 0.0000 v 0.800000 0.000000 0.500000 0.0000 0.0000 1.0000 vn -0.0000 1.0000 -0.0000 vt 0.000000 0.000000 vt 1.000000 0.000000 vt 0.500000 1.000000 s 0 f 2/1/1 3/2/1 1/3/1 I am ignoring the mtl files for now, multiple objects and some other settings

I want the arrays to result in out_vertex_positions = [-0.8, 0, 0.5, 0.8, 0, 0.5, 0, 0, -1] out_vertex_colors = [ 0, 1, 0, 0, 0, 1, 1, 0, 0 ] out_texture_coordinates = [0, 1, 1, 1, 0.5, 0] out_vertex_normals = [ 0, 1, 0, 0, 1, 0, 0, 1, 0 ] out_indices = [0, 1, 2] With the data getting duplicated so the indices can go up by one every vertex. Essentially stripping the indices from the very job they are supposed to do :sweat_smile:.

I am also not using the latest version, I suppose I should be updating once in a while. Just not used to it as with C you can install once and forget about it. If it is useful, I am using version dev-2025-12-nightly

Defining a struct is a really good idea. Really kind of disappointed I didn’t think of it myself as it first was only vertex positions and just kept adding as I wanted more features.

And the main procedure is in a different file main.odin in the same directory and same package.

Usually the same is true for Odin, it is just this one time, things were needed. See moving-towards-a-new-core-os.

I’ll take a deeper look at this later today when I get some time.

2 Likes

Oh, ok. Thanks for the help. Appreciate you spending your time on this

Nice! One thing I noticed while skimming through the code: If an error happens somewhere in the middle, the caller of the procedure is not notified apart from a log that’s printed directly to stderr. It’s totally fine and even idiomatic in Odin to still return a result when errors occur, but you should then also return an error or at least a bool.

1 Like

Thanks for the idea! At one point I did think about just returning empty arrays when an error occurs. But returning a single bool seems like a much cleaner solution and I noticed core libraries already do this

I’m gonna make a couple posts here that hopefully help. Disclaimer: I’m not a graphics programmer, more a systems programmer, so my understanding of the object file specifications are lacking, but I’ve done a few things to facilitate illustrating some idiomatic Odin examples and best practices. Feel free to ask questions about any specifics.

My references:

The example I’m gonna provide in the next post is not so much meant to be a replacement of your project, but a way for me to illustrate recommendations. It is not complete, but with some effort, I suppose it could be. I did not parse faces into indices, as that seemed to require a bit more knowledge of the specifications. I understand now the comment about shifting indexes up 1, since faces are defined as 1-based index instead of 0-based index. Also there is an edge case that could be particularly tricky if you choose to support it, which is the fact that a single line can be joined by multiple lines separated by ‘\’ and any amount of white-space in front of it. I chose to ignore that silly specification (imho should not have been allowed for this type of file, oh well), but if you choose to support it, I can help with getting it in there.

The example data posted did not seem to meet the specifications for obj files. Specifically that each definition should be on a separate line. My example assumes that to be the case. Like so:

mtllib triangle.mtl
o triangle
v 0.000000 0.000000 -1.000000 1.0000 0.0000 0.0000
v -0.800000 0.000000 0.500000 0.0000 1.0000 0.0000
v 0.800000 0.000000 0.500000 0.0000 0.0000 1.0000
vn -0.0000 1.0000 -0.0000
vt 0.000000 0.000000
vt 1.000000 0.000000
vt 0.500000 1.000000
s 0
f 2/1/1 3/2/1 1/3/1
1 Like

Firstly, in my defense :wink: , I mentioned shortening the code, but what I’m posting is longer. It does however have many many things added, like main, utility procedures, Object definition, comments, memory management, printing, error stuffs, etc.

package obj
import "core:fmt"
import "core:os"
import "core:bytes"
import "core:strings"
import "core:strconv"

//utils

//parses [][]byte into either [2]f32 or [3]f32
//returns false if parse fails / invalid f32 number
parse_vector :: proc(s: [][]byte, $T: typeid) -> (v: T, ok: bool) where T == Vector2 || T == Vector3 {
	if len(s) != len(T) || len(s) < 2 || len(s) > 3 {
		return v, false
	}
	for i in 0..<len(T) {
		v[i] = strconv.parse_f32(string(s[i])) or_return
	}
	return v, true
}

//This handles an odd case where split_multi will return [[]] if a line has 1 word, but no spaces or tabs
split_to_words :: proc(s: []byte, allocator := context.allocator, loc := #caller_location) -> [][]byte {
	if bytes.count(s, {' '}) > 0 || bytes.count(s, {'\t'}) > 0 {
		return bytes.split_multi(s, {{' '}, {'\t'}}, true, allocator, loc)
	}
	if len(s) > 0 {
		ln := make([dynamic][]byte, 1, allocator, loc)
		ln[0] = s
		return ln[:]
	}
	return nil
}

//Object types
Vector2 :: [2]f32
Vector3 :: [3]f32

Object :: struct {
	name:                string,
	positions:           [dynamic]Vector3,
	colors:              [dynamic]Vector3,
	texture_coordinates: [dynamic]Vector2,
	normals:             [dynamic]Vector3,
	indices:             [dynamic]Vector3,
}

// delete Object data
destroy_object :: proc(objs: ^[]Object, allocator := context.allocator, loc := #caller_location) {
	//delete lowest level memory first
	for o in objs {
		//dynamic strings do not keep track of their own allocator
		delete(o.name, allocator, loc)
		//dynamic arrays do keep track of their own allocator
		delete(o.positions, loc)
		delete(o.colors, loc)
		delete(o.texture_coordinates, loc)
		delete(o.normals, loc)
		delete(o.indices, loc)
	}
	//delete top level memory last
	//dynamic slices do not keep track of their own allocator
	delete(objs[:], allocator, loc)
}

//Join os.Error and Parse_Error so we can return one or the other
Error :: union {
	os.Error,
	Parse_Error,
}

// Errors for parse_obj_data
Parse_Error :: enum {
	NONE,
	INVALID_VERTEX,
	INVALID_NORMAL,
	INVALID_COORDINATE,
	INVALID_FACE,
	INVALID_INDEX,
}

// read only lookup table for error strings
@(rodata)
parse_error := [Parse_Error]string {
	.NONE = "",
	.INVALID_VERTEX = "Bad vertex data in file.",
	.INVALID_NORMAL = "Bad normal data in file.",
	.INVALID_COORDINATE = "Bad coordinates in file.",
	.INVALID_FACE   = "Bad face data in file.",
	.INVALID_INDEX  = "Bad index data in file.",
}

// Convert error to error string
error_string :: proc(error: Error) -> (s: string) {
	switch e in error {
	case os.Error:    s = os.error_string(e)
	case Parse_Error: s = parse_error[e]
	}
	return
}

// Procedure overload - can either pass a string for filepath or data from somewhere else
parse_obj :: proc {parse_obj_file, parse_obj_data}

//Require results be handled since Object contains dynamic memory
@(require_results)
parse_obj_file :: proc(filename: string, allocator := context.allocator, loc := #caller_location) -> (obj: []Object, err: Error) {
	data := os.read_entire_file(filename, context.allocator, loc) or_return
	defer delete(data, context.allocator) // this will cleanup memory from readin file in after the return below
	return parse_obj_data(data[:], allocator, loc) //only use passed in allocator for obj since that is the only dynamic memory returned
}

make_objects :: proc(len: int, allocator := context.allocator, loc := #caller_location) ->(objs: []Object) {
	objs = make([]Object, len, allocator, loc)
	for i in 0..<len {
		objs[i].positions = make([dynamic]Vector3, allocator, loc)
		objs[i].colors    = make([dynamic]Vector3, allocator, loc)
		objs[i].texture_coordinates = make([dynamic]Vector2, allocator, loc)
		objs[i].normals   = make([dynamic]Vector3, allocator, loc)
		objs[i].indices   = make([dynamic]Vector3, allocator, loc)
	}
	return objs
}

//Require results be handled since Object contains dynamic memory
@(require_results)
parse_obj_data :: proc(data: []byte, allocator := context.allocator, loc := #caller_location) -> (objs: []Object, err: Error) {
	//only use passed in allocator for obj since that is the only dynamic memory returned
	//everything else is contained inside this procedure so they use explicit allocators

	obj_count: int
	//if there is more than one object, we require each to have a name
	//else if there is no line starting with o, we assume only 1 object defined
	obj_name_exists := bytes.contains(data, {'o', ' '}) || bytes.contains(data, {'o', '\t'})
	if obj_name_exists {
		obj_count := bytes.count(data, {'o', ' '})  //o followed by a space
		obj_count += bytes.count(data, {'o', '\t'}) //o followed by a tab
		objs = make_objects(obj_count, allocator, loc)
	} else {
		objs = make_objects(1, allocator, loc)
	}

	lines := bytes.split_multi(data, {{'\n'}, {'\r'}}, true, context.allocator, loc) //split lines on either newline or cariage return (for Windblows)
	defer delete(lines, context.allocator) //this deletes allocated memory for splitting into lines at the end of this scope (i.e. after return)

	//if there is no line starting with o, we assume only 1 object defined
	//this means that the case "o" will not increment objs so index starts at 0
	//otherwise index starts at -1 so case "o" can increment to 0 and so on
	obj_index := obj_name_exists ? -1 : 0
	for line in lines {
		ln := split_to_words(line, context.allocator) //split line into individual 'words' using spaces or tabs
		defer delete(ln, context.allocator) //deletes at end of scope which is end of each loop
		switch string(ln[0]) {
		case "#": // comments - ignored
		case "o": //fmt.println(string(ln[1]))
			obj_index += 1
			if len(ln) == 2 {
				objs[obj_index].name = strings.clone(string(ln[1]), allocator, loc)
			}
		case "v": // line is a vertex. After the v come 3 numbers giving the position, optionally followed by 3 more giving the vertex color.
			switch len(ln[1:]) {
			case 3:
				if v3, ok := parse_vector(ln[1:4], Vector3); ok {
					append(&objs[obj_index].positions, v3)
				} else {
					return objs, .INVALID_VERTEX
				}
			case 6:
				if v3, ok := parse_vector(ln[1:4], Vector3); ok {
					append(&objs[obj_index].positions, v3)
				} else {
					return objs, .INVALID_VERTEX
				}
				if v3, ok := parse_vector(ln[4:7], Vector3); ok {
					append(&objs[obj_index].colors, v3)
				} else {
					return objs, .INVALID_VERTEX
				}
			case: return objs, .INVALID_VERTEX
			}
		case "vn": // line is a normal. After the vn come three numbers giving the normal direction.
			if len(ln[1:]) == 3 {
				if v3, ok := parse_vector(ln[1:4], Vector3); ok {
					for _ in 0..<len(objs[obj_index].positions) {
						append(&objs[obj_index].normals, v3)
					}
				} else {
					return objs, .INVALID_NORMAL
				}
			} else {
				return objs, .INVALID_NORMAL
			}
		case "vt": // line is a texture coordinate. After the vt come two numbers giving the coordinate.
			if len(ln[1:]) == 2 {
				if v2, ok := parse_vector(ln[1:3], Vector2); ok {
					append(&objs[obj_index].texture_coordinates, v2)
				} else {
					return objs, .INVALID_COORDINATE
				}
			} else {
				return objs, .INVALID_COORDINATE
			}
		case "s": // Smoothing group ??
		case "f": // line is a face. After it comes three or more index specifiers.
		}
	}

	return
}

print_obj :: proc(objs: []Object) {
	for o, i in objs {
		if len(o.name) > 0 { fmt.println(o.name) }
		fmt.println("positions:")
		for d in o.positions { fmt.printfln("%-2s%f", "", d) }
		fmt.println("colors:")
		for d in o.positions { fmt.printfln("%-2s%f", "", d) }
		fmt.println("texture coordinates:")
		for d in o.texture_coordinates { fmt.printfln("%-2s%f", "", d) }
		fmt.println("normals:")
		for d in o.normals { fmt.printfln("%-2s%f", "", d) }
		fmt.println("indices:")
		for d in o.indices { fmt.printfln("%-2s%f", "", d) }
		if len(objs) > 1 && i != len(objs) - 1 { fmt.println() }
	}
}

main :: proc() {
	if len(os.args) == 2 {
		if objs, err := parse_obj(os.args[1], context.allocator); err == nil {
			defer destroy_object(&objs, context.allocator)
			print_obj(objs)
		} else {
			fmt.println(error_string(err))
		}
	} else {
		fmt.println("Usage: objloader <file>")
	}
}

Notes:

  • It appeared normals are defined once and duplicated for each vertex (position), so that’s what I did. Not sure if that was correct though.
  • parse_vector uses some basic polymorphic specialization. Check it here.
  • Use of or_return, read about it here.
  • destroy_object - procedure to cleanup returned memory - I always create one of these if I have a custom structure with dynamic memory. Makes cleaning up much easier when only a single procedure is needed in the meaty bits of code.
  • Error :: union - this is especially handy for joining different error types that may be returned through a chain of procedure calls.
  • parse_obj - procedure overload - I like these when there is a clear case that 2 or more procedures return the same data, but have different entry points (i.e. parameters)
  • Lots of examples of memory management. Not strictly needed for a program that runs and then exists shortly after. I do recommend practicing with these anyway. It forces better idiomatic Odin practices. After a while code will become cleaner and flow better because it will become apparent that Odin has been designed with memory management in mind. It also becomes obvious that core procedures and syntax work a certain way with the assumption that memory will be managed.
  • Switch/case statements are a great way to reduce the noise of multiple if/else statements.
  • Use tabs instead of spaces :wink: - setup the IDE to use tabs and customize your tab width to taste.
  • If using VSCode or VSCodium, install the Odin Language extension by DanielGavin. It helps alot while coding and makes learning Odin much easier. If using a different editor, then manually setup ols from here.
  • That’s all I can think of for now …
2 Likes

The example data posted did not seem to meet the specifications for obj files. Specifically that each definition should be on a separate line.

Yeah, I ran into some formatting issues. Couldn’t figure out how to add a new line. But yes I meant that the definitions are on the seperate lines. Apologies for the confusion.

Also there is an edge case that could be particularly tricky if you choose to support it

Nah, I don’t think there’s a need to be 100% compliant to the spec, especially since this is just a quick and dirty loader. Purely done for loading small models instead of manually defining vertices :melting_face:

Man, never seen lookup tables for error messages. It makes sense though, and never seen a parser which parses by lines instead of characters, I guess in this case it is more appropriate and cleaner than character by character that I was doing. or_return also seems pretty clean. Thanks for the feedback

The forum formatting trips me up sometimes too. Pasting into a code block should preserve newlines, etc. The problem I often run into: the code block sometimes turns off syntax highlighting. Not sure what causes it. :man_shrugging:

I got a few more ideas to add to my example. I’ll update the code example above later this afternoon when I got some time.

I updated the example with a few things that were stuck in my head that I had to scratch :wink:

I realized that when splitting a line into “words”, there might be any number of spaces or tabs. So I changed bytes.split to byte.split_multi for two reasons. 1. It allows splitting on more than one substring. 2. Of the split procedures, it’s the only one that has a parameter for skip_empty. It’s often the case when I use split, I really mean to use split_multi. Lesson learned for me.

While changing split to split_multi, I ran into an odd case where split_multi returned an empty value if there was only one “word” and nothing to split on. Not sure if that’s a bug. To address that I added an utility procedure: split_to_words(). This behaves the way I intended. I thought it was important, because it might be possible there is a line with only one “word” on it that represents a feature you may want to support. It is now possible to add a case statement for that.

I created a make_objects() procedure to keep the parsing part clean. This is a good example of seeing how Odin does a thing (like make), and then expanding on it to create your own to support a custom type definition like [ ]Object.

I added support for more than one object defined in the file. It follows these rules:

  1. If no line starts with “o”, that means the object does not have a name, and we assume only 1 object definition in the file.
  2. If there is multiple objects defined, they must have a line starting with “o” to define their name. This is used to increment [ ]Object index.
  3. It is possible for only 1 object to be defined that does have a name.

Is there anything you’d like the example to illustrate? Questions?

Input multi object:


mtllib triangle.mtl
o triangle_0
v 0.000000 0.000000 -1.000000 1.0000 0.0000 0.0000
v -0.800000 0.000000 0.500000 0.0000 1.0000 0.0000
v 0.800000 0.000000 0.500000 0.0000 0.0000 1.0000
vn -0.0000 1.0000 -0.0000
vt 0.000000 0.000000
vt 1.000000 0.000000
vt 0.500000 1.000000
s 0
f 2/1/1 3/2/1 1/3/1

o triangle_1
v 0.000000 0.000000 -1.000000 1.0000 0.0000 0.0000
v -0.800000 0.000000 0.500000 0.0000 1.0000 0.0000
v 0.800000 0.000000 0.500000 0.0000 0.0000 1.0000
vn -0.0000 1.0000 -0.0000
vt 0.000000 0.000000
vt 1.000000 0.000000
vt 0.500000 1.000000
s 0
f 2/1/1 3/2/1 1/3/1

Output multi object

triangle_0
positions:
  [0.000, 0.000, -1.000]
  [-0.800, 0.000, 0.500]
  [0.800, 0.000, 0.500]
colors:
  [0.000, 0.000, -1.000]
  [-0.800, 0.000, 0.500]
  [0.800, 0.000, 0.500]
texture coordinates:
  [0.000, 0.000]
  [1.000, 0.000]
  [0.500, 1.000]
normals:
  [-0.000, 1.000, -0.000]
  [-0.000, 1.000, -0.000]
  [-0.000, 1.000, -0.000]

triangle_1
positions:
  [0.000, 0.000, -1.000]
  [-0.800, 0.000, 0.500]
  [0.800, 0.000, 0.500]
colors:
  [0.000, 0.000, -1.000]
  [-0.800, 0.000, 0.500]
  [0.800, 0.000, 0.500]
texture coordinates:
  [0.000, 0.000]
  [1.000, 0.000]
  [0.500, 1.000]
normals:
  [-0.000, 1.000, -0.000]
  [-0.000, 1.000, -0.000]
  [-0.000, 1.000, -0.000]