EliCDavis / polyform

Immutable mesh processing library
https://elicdavis.gitbook.io/polyform/
MIT License
79 stars 6 forks source link

Add SaveAll method for OBJ files #39

Closed alexykot closed 3 months ago

alexykot commented 3 months ago

This PR adds a obj.SaveAll(objPath string, meshesToSave map[string]modeling.Mesh) function to OBJ FS operations. This allows saving multiple named meshes in a single OBJ file. All required internal mechanics for this already exist, i.e. WriteMeshes() can already handle this, but this wasn't exposed as an option in FS operations. Now it is.


This PR slightly changes the exported interface of the obj package - former WriteMaterials(modeling.Mesh, io.Writer) is renamed to WriteMaterialsFromMesh(modeling.Mesh, io.Writer), and a new function added under old name, but with a different signature - WriteMaterials([]modeling.MeshMaterial, io.Writer). Examples using those methods are updated accordingly.

This seems more appropriate to me as there is a WriteMaterial() func that accepts singular material, and now also WriteMaterials() that accepts multiple. Although this isn't perfect by itself since singular is modeling.Material, but plural is []modeling.MeshMaterial, slightly different objects.

Naming WriteMaterialsFromMesh() seems uncontroversial to me as this is exactly what the method does. Behind the interface WriteMaterialsFromMesh() is now a proxy to WriteMaterials(), with a convenient interface for the most common use case.

Since polyform is at v0 at the moment - I assume introducing small changes in the exported interfaces of secondary packages is acceptable. Though also I'm happy to rollback and find a different, backwards compatible naming approach if that is preferrable, let me know.


If taking exported interface changes further - it would probably make some sense to combine obj.Save() and obj.SaveAll() into one obj.Save(objPath string, meshesToSave ...obj.ObjMesh) interface. But this would introduce more breaking changes, and on the (probably) most widely used function of this package, so I think that's a bridge too far.

EliCDavis commented 3 months ago

Hey! Thanks for this. I think we're starting to get at a larger issue here and I want your opinion.

Preface

Honestly, I haven't been happy with the OBJ package API (and most formats) for a while. OBJ was the first format I implemented in polyform years ago, and it's probably the ugliest to work with. Being OBJ was the first format, it also has had the largest influence on the material API in polyform, which is also not amazing to work with.

I've kept polyform in v0 for years and I want to start to approach a v1. I think the format and material APIs are some sore thumbs that need looking at before v1 is here.

Problems with Materials

  1. It's implemented such that it takes up ranges of topology primitives. IE: The first 9 triangles in a mesh have one material, the next 6 could have another, and then the next 12 could have no material. The only format implemented that even supports such a feature is OBJ, and it makes certain mesh operations not obvious on how to properly implement (IE what happens in a "merge vertices by distance" when different vertices have different materials)
  2. The fields in a polyform material are basically a carbon copy of OBJ's, which doesn't serve to support any other format other than some of what's in GLTF.
  3. Polyform materials are implemented as pointers to support nullability. This allows developers to break the immutability aspect of a mesh by allowing a programmer to keep a reference to a material and make changes to it without directly messing with the mesh, which goes against much of what polyform is trying to prevent.

Solution

Delete the concept of materials right out of the Mesh, and defer implementation to the format in question. So now you'll be dealing with a obj.Material directly.

Problem with Format API

Currently, the API has no cohesion from format to format. A bunch of common terms are used, Save, Load, Write, and Read, but the signatures themselves change from format to format. Maybe I'm making this a bigger problem in my head than it actually is. But it'd be nice if there was some cohesion, ideally some semblance of a common interface.

Below is an overview of the current state of affairs;

// PLY ===========================================================================
ply.Save(plyPath string, meshToSave modeling.Mesh, format ply.Format) error
ply.Write(out io.Writer, model modeling.Mesh, format ply.Format) error
ply.Load(filepath string) (*modeling.Mesh, error)
ply.ReadMesh(in io.Reader) (*modeling.Mesh, error)

// GLTF ==========================================================================
gltf.Save(modelPath string, scene gltf.PolyformScene) error
gltf.SaveBinary(gltfPath string, scene gltf.PolyformScene) error
gltf.SaveText(gltfPath string, scene gltf.PolyformScene) error
gltf.WriteBinary(scene gltf.PolyformScene, out io.Writer) error
gltf.WriteText(scene gltf.PolyformScene, out io.Writer) error

// OBJ ===========================================================================
obj.Save(objPath string, meshToSave modeling.Mesh) error
obj.WriteMesh(m modeling.Mesh, materialFile string, out io.Writer) error
obj.WriteMeshes(meshes []obj.ObjMesh, materialFile string, out io.Writer) error
obj.WriteMaterial(mat modeling.Material, out io.Writer) (err error)
obj.WriteMaterials(m modeling.Mesh, out io.Writer) error
obj.Load(objPath string) ([]obj.ObjMesh, error)
obj.ReadMaterials(in io.Reader) ([]modeling.Material, error)
obj.ReadMesh(in io.Reader) ([]obj.ObjMesh, []string, error)

// STL ===========================================================================
stl.Save(fp string, m modeling.Mesh) error
stl.Write(out io.Writer, bin stl.Binary) error
stl.WriteMesh(out io.Writer, m modeling.Mesh) error
stl.Load(fp string) (*modeling.Mesh, error)
stl.ReadMesh(in io.Reader) (*modeling.Mesh, error)

Solution

Generally, formats have extra data associated with them that don't fit into a mesh. STL has an 80 byte header, OBJ has materials, GLTF has a ton of stuff, and PLY has comments and obj_info. This means that no matter what, signatures are bound to be different in some capacity.

To allow developers to ignore the format details, we supply an API serving as our "best guess" for translating data into a single polyform mesh. This "best guess" will inevitably fall short for use cases that require context around the mesh that the format in question provides, and in those scenarios, the developer will then need to use the format-specific APIs to resolve those features.

The general purpose interface that every format needs to implement is as follows:

type Format {
    ReadMesh(io.Reader)  (modeling.Mesh, error)
    WriteMesh(io.Writer, modeling.Mesh) error 
    LoadMesh(path string) (modeling.Mesh, error)
    SaveMesh(path string, modeling.Mesh) error
}

We then reserve the function names Read, Write, Load, and Save to have their signatures dependent on the details of the format in question. So the Load in the obj package will look different to the Load in the stl package.

Conclusion

I'm not asking you to implement any of these changes in your PR. I just wanted to let you know what's probably to come and get your thoughts since you are the first person I know who has been digging into the format API.

After learning of this, if you're still happy with your PR, I will happily merge it. Just be aware I might mess with the API in the coming months.

alexykot commented 3 months ago

Hi, sure.

To give a bit of context on what I'm doing and why I'm digging into this.

I don't actually know much about 3D modelling, and this is not my area of expertise. Triangle meshes and RGB colours make sense to me intuitively, but the math behind them - not so much. Fixing minor interface issues and using what's already there is about the top of my current abilities. I'm working on a pet project for a CAD system of sorts and I need a visualization layer that is able to output 3D models in a format that I can then render in-browser.

I picked OBJ because it ticked my boxes (simple to understand, has libraries to work with, supports coloring), but I don't know enough in this area to make a properly competent choice. And I've not looked at the APIs of other formats at all, since I'm not using those.

So take all my inputs with a pinch of salt 🙂 With this context, for what it's worth - here's what I think.


On Materials

Yeah, I already came across that welding doesn't yield good results if you have multiple materials. I hacked around to create a box with different sides in different colours, and those have to stay unwelded, which makes model bigger for no good reason, but if welded - the whole box just goes gray 🤷

I guess it would make sense to make materials more generic, but I really don't know what neither OBJ nor GLTF features are to tell what a common lowest denominator would look like, or if it's even feasible/useful to have one. In general, I suspect it will have the same issue as the formats API - a lowest common denominator material interface is possible, but it will only cover most basic use cases, and beyond that - a format-specific material implementations will be needed.

I can imagine a situation where different material implementations are fitting a common interface, and the interface can return an enum value indicating it's internal format (or just try casting it to the type). Then the format packages would be able to do the conversion and use full features if the underlying material behind the interface is native to that format, or use only basic features provided by the common-denominator interface.

In that situation I would work with the format-agnostic generic material until I need specific features supported only by the format of choice. And at that point I as a user of the library would have to decide whether I want to stay generic and support format compatibility in my software, or it's fine for me to specialize to one format only.

On pointers specifically - I would make it a struct and have an IsInstantiated bool field on it (with a better name 🙂), so that a default empty material value would be clearly "nullified" without being an actual nil pointer. It's clunkier than a nil pointer, but aligns with foundational ideas of the library. As a consumer of the library I'd be fine with that.


On Formats API

Yeah, I think what you're proposing makes perfect sense. A two-layer API:

Again, this structure will shift the decision to the library consumers - whether to stay with the generic Format interface, and support only common features, but across many formats; or specialize to one format and use feature-rich specialist APIs. I think this is the right approach - this decision should stay with library consumers.

The Format interface you're proposing makes sense to me, although I would keep the shortest Read, Write, Load, and Save verbs for the generic package:

type Format {
    Read(io.Reader)  (modeling.Mesh, error)
    Write(io.Writer, modeling.Mesh) error 
    Load(path string) (modeling.Mesh, error)
    Save(path string, modeling.Mesh) error
}

Since all operations are on the Mesh anyway, repeating it seems unnecessary, and since this is the generic interface that likely will be used the most - this one probably should have the most ergonomic names.


Yes, no problem with version updates, fixing this should be fairly easy on my end.

I hope this helps. 🙂

alexykot commented 2 months ago

Hey, brief update, if it helps. I've switched from OBJ to glTF as OBJ stopped working for me.

After my model grew in size (with more parts added in my CAD) - web renderer I was using started failing with Invalid UV index error message. Output in polyform was fine, but the resulting file could not be rendered. I've not tried to debug it much, and switched to glTF instead, since I was thinking about that already anyway. The switch also slashed size of my exported file size by 70%. Size was becoming a problem, so double win.

Part of the github.com/EliCDavis/polyform/formats/gltf package interface that I found annoying is that it doesn't work directly with the modeling.Mesh and modeling.Material types, so I had to rework how I'm combining the scene before it goes into rendering. Back to our conversation above - would be great to have a unified interface between all export types that I could use transparently.

I see now that glTF has way more advanced lighting and material rendering control features. For my use case that is not necessary, I need it to look okay and no more (at this point anyway). So if a generic interface would just produce standard more-less unified visual results using only basic rendering - that would be good enough for me.