Search code examples
c++boostboost-spiritwavefront

Parse OBJ file with mixed data-types using Boost.Spirit?


I do have an OBJ file that looks like this:

# This file uses centimeters as units for non-parametric coordinates.

# first element
v -0.017050 -0.017928 0.005579
v -0.014504 -0.017928 0.010577
   .
   .
v -0.000000 -0.017928 0.017967

vt 0.397581 0.004762
vt 0.397544 0.034263
   .
   .
vt 0.397507 0.063764

vn -0.951057 0.000000 0.309016
vn -0.809017 0.000000 0.587785
   .
   .
vn -0.587785 0.000000 0.809017

f 1/1/1 2/2/2 21/4/3
f 21/4/3 2/2/2 22/3/4
   .
   .
f 3/5/5 4/7/7 23/6/6


# second element
v -0.014504 0.017928 -0.010499
v -0.017050 0.017928 -0.005501
   .
   .
v -0.000000 0.017928 0.000039

vt 0.063001 0.262615
vt 0.073837 0.268136
   .
   .
vt 0.089861 0.299584

vn 0.000000 1.000000 -0.000002
vn 0.000000 1.000000 -0.000002
   .
   .
vn 0.000000 1.000000 -0.000000

f 36/80/78 37/81/79 42/66/64
f 37/81/79 38/82/80 42/66/64
   .
   .
f 40/84/82 21/64/62 42/66/64

# third element
   .
   .
etc.

The file contains four different type of records, each begins with either v, vt, vn or f. I would like to know how can I use boost-spirit to parse such a file under the following conditions:

int main(int argc, char **argv) {

    std::vector<double> positions;
    std::vector<double> texcoords;
    std::vector<double> normals;
    std::vector<uint32_t> faces;


    // 1- store each record begins with 'v' into 'positions'
    // 2- store each record begins with 'vt' into 'texcoords'
    // 3- store each record begins with 'vn' into 'normals'
    // 4- store each record begins with 'f' into 'faces'.  The integers are separated either with '/' or a blank-space (Each record with three groups, each group with three integers)
    // 5- ignore each record begins with '#'
    // 6- ignoring all empty lines

    return 0;
}

How and what would be the most efficient way to do so with boost-spirit?


Solution

  • Okay, so I got around to expounding this in Spirit X3, for a change.

    THe AST

    As always, first things first. I thought we could be a little more precise about the data structure to match the data itself.

    This will make it more useful to work with and less error-prone.

    using Position = std::tuple<double, double, double>;
    using Coords   = std::tuple<double, double>;
    using Normal   = std::tuple<double, double, double>;
    //using Face3    = std::array<uint32_t, 3>;
    //using Face9    = std::array<Face3, 3>; // 9
    using Face9    = std::vector<uint32_t>; // hmmm
    
    struct Element {
        std::vector<Position> positions;
        std::vector<Coords>   texcoords;
        std::vector<Normal>   normals;
        std::vector<Face9>    faces;
    };
    
    struct OBJ {
        std::vector<Element> elements;
    };
    

    I'd have loved to use std::array for fixed-length sequences (Face3/Face9) but coudln't readily make it work, so here I opted for the more flexible vector like you did.

    The Parser

    It's a 1:1 mapping:

    // 1- store each record begins with 'v' into 'positions'
    auto pos
        = rule<struct _pos, Position> {"pos"}
        = "v" >> double_ >> double_ >> double_;
    
    // 2- store each record begins with 'vt' into 'texcoords'
    auto tex
        = rule<struct _tex, Coords> {"tex"}
        = "vt" >> double_ >> double_;
    
    // 3- store each record begins with 'vn' into 'normals'
    auto normals
        = rule<struct _norm, Normal> {"normal"}
        = "vn" >> double_ >> double_ >> double_;
    
    // 4- store each record begins with 'f' into 'faces'.
    //    The integers are separated either with '/' or a blank-space (Each
    //    record with three groups, each group with three integers)
    
    auto face3
        = rule<struct _face3, Face9> {"face3"}
        = uint_ >> '/' >> uint_ >> '/' >> uint_;
    auto faces
        = rule<struct _faces, Face9> {"faces"}
        = "f" >> repeat(3) [ face3 ];
    

    We didn't concern ourselves with comment/whitespace, because we'll use a skipper:

    // 5- ignore each record begins with '#'
    // 6- ignoring all empty lines
    auto skipper = blank | '#' >> *(char_ - eol) >> (eol|eoi);
    

    Now, let's jump to the final stage: the main parser rule:

    auto OBJ = *skip(skipper) [
        *eol >> &pos [ new_element ] >>
        lines_of(pos,     &Element::positions) >>
        lines_of(tex,     &Element::texcoords) >>
        lines_of(normals, &Element::normals) >>
        lines_of(faces,   &Element::faces)
    ];
    

    As you can see I've, again, assumed a few more restrictions (because the comments in the sample file suggest that separate elements appear, and the parts appear in order).

    To parse repeated lines, we have this factory:

    auto lines_of = [](auto p, auto member) {
        return *(p [ push(member) ] >> (eoi|+eol));
    };
    

    The push action is generated functionally:

    auto push = [](auto member) {
        return [member](auto& ctx) {
            auto& data = get<OBJ>(ctx);
            auto& v = std::invoke(member, data.elements.back());
            v.push_back(std::move(_attr(ctx)));
        };
    };
    

    We'll be getting the OBJ object from the parser context

    Invoking the parser with OBJ context

    The main code looks like this:

    #include <iostream>
    #include <iomanip>
    int main() {
        std::ifstream ifs("input.txt");
        boost::spirit::istream_iterator f(ifs >> std::noskipws), l;
    
        OBJ data;
        if (x3::parse(f, l, x3::with<OBJ>(data) [ Parser::OBJ ])) {
            std::cout << "Yay, " << data.elements.size() << " elements\n";
    
            for (auto& el : data.elements) {
                dump(el);
            }
        } else {
            std::cout << "Failed\n";
        }
    
        if (f!=l) {
            std::cout << "Remaining unparsed: " << std::quoted(std::string(f,l)) << "\n";
        }
    }
    

    That's all. For the given input, prints:

    Yay, 2 elements
    

    BONUS: Debug output

    If you have {fmt} available, compile with -DHAVE_FMT, to get pretty output:

    #ifdef HAVE_FMT
    #include <fmt/printf.h>
    #include <fmt/ranges.h>
    void dump(Element const& el) {
        auto& [pos,tex,nrm,fac] = el;
        fmt::print("positions: {}\n"
                "texcoords: {}\n"
                "normals: {}\n"
                "faces: {}\n\n", pos, tex, nrm, fac);
    }
    #else
    void dump(Element const&) { }
    #endif
    

    Prints:

    Yay, 2 elements
    positions: {(-0.01705, -0.017928, 0.005579), (-0.014504, -0.017928, 0.010577), (-0.0, -0.017928, 0.017967)}
    texcoords: {(0.397581, 0.004762), (0.397544, 0.034263), (0.397507, 0.063764)}
    normals: {(-0.951057, 0.0, 0.309016), (-0.809017, 0.0, 0.587785), (-0.587785, 0.0, 0.809017)}
    faces: {{1, 1, 1, 2, 2, 2, 21, 4, 3}, {21, 4, 3, 2, 2, 2, 22, 3, 4}, {3, 5, 5, 4, 7, 7, 23, 6, 6}}
    
    positions: {(-0.014504, 0.017928, -0.010499), (-0.01705, 0.017928, -0.005501), (-0.0, 0.017928, 3.9e-05)}
    texcoords: {(0.063001, 0.262615), (0.073837, 0.268136), (0.089861, 0.299584)}
    normals: {(0.0, 1.0, -2e-06), (0.0, 1.0, -2e-06), (0.0, 1.0, -0.0)}
    faces: {{36, 80, 78, 37, 81, 79, 42, 66, 64}, {37, 81, 79, 38, 82, 80, 42, 66, 64}, {40, 84, 82, 21, 64, 62, 42, 66, 64}}
    

    FULL LISTING

    Live On Wandbox

    //#define HAVE_FMT
    //#define BOOST_SPIRIT_X3_DEBUG
    #include <boost/fusion/adapted/std_tuple.hpp>
    #include <boost/fusion/adapted/std_array.hpp>
    #include <boost/spirit/home/x3.hpp>
    #include <boost/spirit/include/support_istream_iterator.hpp>
    #include <fstream>
    
    using Position = std::tuple<double, double, double>;
    using Coords   = std::tuple<double, double>;
    using Normal   = std::tuple<double, double, double>;
    //using Face3    = std::array<uint32_t, 3>;
    //using Face9    = std::array<Face3, 3>; // 9
    using Face9    = std::vector<uint32_t>; // hmmm
    
    struct Element {
        std::vector<Position> positions;
        std::vector<Coords>   texcoords;
        std::vector<Normal>   normals;
        std::vector<Face9>    faces;
    };
    
    struct OBJ {
        std::vector<Element> elements;
    };
    
    namespace x3 = boost::spirit::x3;
    
    namespace Parser {
        using namespace x3;
    
        auto new_element = [](auto& ctx) {
            auto& data = get<OBJ>(ctx);
            data.elements.emplace_back();
        };
    
        // 1- store each record begins with 'v' into 'positions'
        auto pos
            = rule<struct _pos, Position> {"pos"}
            = "v" >> double_ >> double_ >> double_;
    
        // 2- store each record begins with 'vt' into 'texcoords'
        auto tex
            = rule<struct _tex, Coords> {"tex"}
            = "vt" >> double_ >> double_;
    
        // 3- store each record begins with 'vn' into 'normals'
        auto normals
            = rule<struct _norm, Normal> {"normal"}
            = "vn" >> double_ >> double_ >> double_;
    
        // 4- store each record begins with 'f' into 'faces'.
        //    The integers are separated either with '/' or a blank-space (Each
        //    record with three groups, each group with three integers)
    
        auto face3
            = rule<struct _face3, Face9> {"face3"}
            = uint_ >> '/' >> uint_ >> '/' >> uint_;
        auto faces
            = rule<struct _faces, Face9> {"faces"}
            = "f" >> repeat(3) [ face3 ];
    
        // 5- ignore each record begins with '#'
        // 6- ignoring all empty lines
        auto skipper = blank | '#' >> *(char_ - eol) >> (eol|eoi);
    
        auto push = [](auto member) {
            return [member](auto& ctx) {
                auto& data = get<OBJ>(ctx);
                auto& v = std::invoke(member, data.elements.back());
                v.push_back(std::move(_attr(ctx)));
            };
        };
    
        auto lines_of = [](auto p, auto member) {
            return *(p [ push(member) ] >> (eoi|+eol));
        };
    
        auto OBJ = *skip(skipper) [
            *eol >> &pos [ new_element ] >>
            lines_of(pos,     &Element::positions) >>
            lines_of(tex,     &Element::texcoords) >>
            lines_of(normals, &Element::normals) >>
            lines_of(faces,   &Element::faces)
        ];
    }
    
    #ifdef HAVE_FMT
    #include <fmt/printf.h>
    #include <fmt/ranges.h>
    void dump(Element const& el) {
        auto& [pos,tex,nrm,fac] = el;
        fmt::print("positions: {}\n"
                "texcoords: {}\n"
                "normals: {}\n"
                "faces: {}\n\n", pos, tex, nrm, fac);
    }
    #else
    void dump(Element const&) { }
    #endif
    
    #include <iostream>
    #include <iomanip>
    int main() {
        std::ifstream ifs("input.txt");
        boost::spirit::istream_iterator f(ifs >> std::noskipws), l;
    
        OBJ data;
        if (x3::parse(f, l, x3::with<OBJ>(data) [ Parser::OBJ ])) {
            std::cout << "Yay, " << data.elements.size() << " elements\n";
    
            for (auto& el : data.elements) {
                dump(el);
            }
        } else {
            std::cout << "Failed\n";
        }
    
        if (f!=l) {
            std::cout << "Remaining unparsed: " << std::quoted(std::string(f,l)) << "\n";
        }
    }