Repository: qmuntal/gltf
Pull request #84 (2024-06-28 13:41:11)

Simplify interleaved primitive attribute writing
Changes:
- Rename `modeler.Attribute` to `modeler.PrimitiveAttributes`
- Rename `gltf.Attribute` to `gltf.PrimitiveAttributes`
- Improve `modeler.WritePrimitiveAttributes` error handling
- Change `modeler.WritePrimitiveAttributes` parameters to accept a `modeler.PrimitiveAttributes` to the now defunc `modeler.Attributes`


Patch:
diff --git a/README.md b/README.md
index 3005fb7..1bd4e55 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ doc.Meshes = []*gltf.Mesh{{
     Name: "Pyramid",
     Primitives: []*gltf.Primitive{{
         Indices: gltf.Index(modeler.WriteIndices(doc, []uint16{0, 1, 2})),
-        Attributes: map[string]uint32{
+        Attributes: gltf.PrimitiveAttributes{
           gltf.POSITION: modeler.WritePosition(doc, [][3]float32{{0, 0, 0}, {0, 10, 0}, {0, 0, 10}}),
           gltf.COLOR_0:  modeler.WriteColor(doc, [][3]uint8{{255, 0, 0}, {0, 255, 0}, {0, 0, 255}}),
         },
@@ -130,14 +130,14 @@ gltf.Save(doc, "./test.gltf")
 
 ### Data interleaving
 
-The data of the attributes that are stored in a single bufferView may be stored as an Array-Of-Structures, which may produce a rendering perfomance boost in static attributes. `qmuntal/gltf/modeler` facilitates the creation of interleaved accessors and buffer views with the methods [WriteAttributesInterleaved](https://pkg.go.dev/github.com/qmuntal/gltf/modeler#WriteAttributesInterleaved), [WriteAccessorsInterleaved](https://pkg.go.dev/github.com/qmuntal/gltf/modeler#WriteAccessorsInterleaved), and [WriteBufferViewInterleaved](https://pkg.go.dev/github.com/qmuntal/gltf/modeler#WriteBufferViewInterleaved) being the first one the most recommended for creating mesh primitives:
+The data of the attributes that are stored in a single bufferView may be stored as an Array-Of-Structures, which may produce a rendering perfomance boost in static attributes. `qmuntal/gltf/modeler` facilitates the creation of interleaved accessors and buffer views with the methods [WritePrimitiveAttributes](https://pkg.go.dev/github.com/qmuntal/gltf/modeler#WritePrimitiveAttributes), [WriteAccessorsInterleaved](https://pkg.go.dev/github.com/qmuntal/gltf/modeler#WriteAccessorsInterleaved), and [WriteBufferViewInterleaved](https://pkg.go.dev/github.com/qmuntal/gltf/modeler#WriteBufferViewInterleaved) being the first one the most recommended for creating mesh primitives:
 
 ```go
 doc := gltf.NewDocument()
-attrs, _ := modeler.WriteAttributesInterleaved(doc, modeler.Attributes{
-  Position:       [][3]float32{{0, 0, 0}, {0, 10, 0}, {0, 0, 10}},
-  Color:          [][3]uint8{{255, 0, 0}, {0, 255, 0}, {0, 0, 255}},
-})
+attrs, _ := modeler.WritePrimitiveAttributes(doc,
+    modeler.PrimitiveAttribute{Name: gltf.POSITION, Data: [][3]float32{{0, 0, 0}, {0, 10, 0}, {0, 0, 10}}},
+    modeler.PrimitiveAttribute{Name: gltf.COLOR_0, Data: [][3]uint8{{255, 0, 0}, {0, 255, 0}, {0, 0, 255}}},
+)
 doc.Meshes = []*gltf.Mesh{{
     Name: "Pyramid",
     Primitives: []*gltf.Primitive{{
@@ -199,7 +199,7 @@ const ExtensionName = "FAKE_Extension"
 
 type Foo struct {
     BufferView uint32          `json:"bufferView"`
-    Attributes gltf.Attribute  `json:"attributes"`
+    Attributes gltf.Attributes  `json:"attributes"`
 }
 
 func init() {
diff --git a/const.go b/const.go
index 98fd5da..6f125fe 100644
--- a/const.go
+++ b/const.go
@@ -160,8 +160,8 @@ const (
 	TargetElementArrayBuffer Target = 34963 // ELEMENT_ARRAY_BUFFER
 )
 
-// Attribute is a map that each key corresponds to mesh attribute semantic and each value is the index of the accessor containing attribute's data.
-type Attribute = map[string]uint32
+// PrimitiveAttributes is a map that each key corresponds to mesh attribute semantic and each value is the index of the accessor containing attribute's data.
+type PrimitiveAttributes = map[string]uint32
 
 // PrimitiveMode defines the type of primitives to render. All valid values correspond to WebGL enums.
 type PrimitiveMode uint8
diff --git a/gltf.go b/gltf.go
index 532a3ae..edb1419 100644
--- a/gltf.go
+++ b/gltf.go
@@ -260,13 +260,13 @@ type Mesh struct {
 
 // Primitive defines the geometry to be rendered with the given material.
 type Primitive struct {
-	Extensions Extensions    `json:"extensions,omitempty"`
-	Extras     any           `json:"extras,omitempty"`
-	Attributes Attribute     `json:"attributes"`
-	Indices    *uint32       `json:"indices,omitempty"` // The index of the accessor that contains the indices.
-	Material   *uint32       `json:"material,omitempty"`
-	Mode       PrimitiveMode `json:"mode,omitempty" validate:"lte=6"`
-	Targets    []Attribute   `json:"targets,omitempty" validate:"omitempty,dive,dive,keys,oneof=POSITION NORMAL TANGENT,endkeys"` // Only POSITION, NORMAL, and TANGENT supported.
+	Extensions Extensions            `json:"extensions,omitempty"`
+	Extras     any                   `json:"extras,omitempty"`
+	Attributes PrimitiveAttributes   `json:"attributes"`
+	Indices    *uint32               `json:"indices,omitempty"` // The index of the accessor that contains the indices.
+	Material   *uint32               `json:"material,omitempty"`
+	Mode       PrimitiveMode         `json:"mode,omitempty" validate:"lte=6"`
+	Targets    []PrimitiveAttributes `json:"targets,omitempty" validate:"omitempty,dive,dive,keys,oneof=POSITION NORMAL TANGENT,endkeys"` // Only POSITION, NORMAL, and TANGENT supported.
 }
 
 // The Material appearance of a primitive.
diff --git a/modeler/write.go b/modeler/write.go
index bf8ef8b..21b5928 100644
--- a/modeler/write.go
+++ b/modeler/write.go
@@ -45,60 +45,70 @@ func WriteTangent(doc *gltf.Document, data [][4]float32) uint32 {
 // and fills the last buffer with data.
 // If success it returns the index of the new accessor.
 func WriteTextureCoord(doc *gltf.Document, data any) uint32 {
-	normalized := checkTextureCoord(data)
+	normalized, err := checkTextureCoord(data)
+	if err != nil {
+		panic(err)
+	}
 	index := WriteAccessor(doc, gltf.TargetArrayBuffer, data)
 	doc.Accessors[index].Normalized = normalized
 	return index
 }
 
-func checkTextureCoord(data any) bool {
+func checkTextureCoord(data any) (bool, error) {
 	var normalized bool
 	switch data.(type) {
 	case [][2]uint8, [][2]uint16:
 		normalized = true
 	case [][2]float32:
 	default:
-		panic(fmt.Sprintf("modeler.WriteTextureCoord: invalid type %T", data))
+		return false, fmt.Errorf("invalid type %T", data)
 	}
-	return normalized
+	return normalized, nil
 }
 
 // WriteWeights adds a new WEIGHTS accessor to doc
 // and fills the last buffer with data.
 // If success it returns the index of the new accessor.
 func WriteWeights(doc *gltf.Document, data any) uint32 {
-	normalized := checkWeights(data)
+	normalized, err := checkWeights(data)
+	if err != nil {
+		panic(err)
+	}
 	index := WriteAccessor(doc, gltf.TargetArrayBuffer, data)
 	doc.Accessors[index].Normalized = normalized
 	return index
 }
 
-func checkWeights(data any) bool {
+func checkWeights(data any) (bool, error) {
 	var normalized bool
 	switch data.(type) {
 	case [][4]uint8, [][4]uint16:
 		normalized = true
 	case [][4]float32:
 	default:
-		panic(fmt.Sprintf("modeler.WriteWeights: invalid type %T", data))
+		return false, fmt.Errorf("invalid type %T", data)
 	}
-	return normalized
+	return normalized, nil
 }
 
 // WriteJoints adds a new JOINTS accessor to doc
 // and fills the last buffer with data.
 // If success it returns the index of the new accessor.
 func WriteJoints(doc *gltf.Document, data any) uint32 {
-	checkJoints(data)
+	err := checkJoints(data)
+	if err != nil {
+		panic(err)
+	}
 	return WriteAccessor(doc, gltf.TargetArrayBuffer, data)
 }
 
-func checkJoints(data any) {
+func checkJoints(data any) error {
 	switch data.(type) {
 	case [][4]uint8, [][4]uint16:
 	default:
-		panic(fmt.Sprintf("modeler.WriteJoints: invalid type %T", data))
+		return fmt.Errorf("invalid type %T", data)
 	}
+	return nil
 }
 
 // WritePosition adds a new POSITION accessor to doc
@@ -128,22 +138,25 @@ func minMaxFloat32(data [][3]float32) ([3]float64, [3]float64) {
 // and fills the buffer with data.
 // If success it returns the index of the new accessor.
 func WriteColor(doc *gltf.Document, data any) uint32 {
-	normalized := checkColor(data)
+	normalized, err := checkColor(data)
+	if err != nil {
+		panic(err)
+	}
 	index := WriteAccessor(doc, gltf.TargetArrayBuffer, data)
 	doc.Accessors[index].Normalized = normalized
 	return index
 }
 
-func checkColor(data any) bool {
+func checkColor(data any) (bool, error) {
 	var normalized bool
 	switch data.(type) {
 	case []color.RGBA, []color.RGBA64, [][4]uint8, [][3]uint8, [][4]uint16, [][3]uint16:
 		normalized = true
 	case [][3]float32, [][4]float32:
 	default:
-		panic(fmt.Sprintf("modeler.WriteColor: invalid type %T", data))
+		return false, fmt.Errorf("invalid type %T", data)
 	}
-	return normalized
+	return normalized, nil
 }
 
 // WriteImage adds a new image to doc
@@ -216,97 +229,61 @@ func WriteAccessorsInterleaved(doc *gltf.Document, data ...any) ([]uint32, error
 	return indices, nil
 }
 
-// CustomAttribute defines an application-specific attribute
-type CustomAttribute struct {
+// PrimitiveAttribute holds the data referenced by a gltf.PrimitiveAttributes entry.
+type PrimitiveAttribute struct {
 	Name string
 	Data any
 }
 
-// Attributes defines all the vertex attributes that can
-// be associated to a primitive.
-type Attributes struct {
-	Position [][3]float32
-	Normal   [][3]float32
-	Tangent  [][4]float32
-	// [][2]uint8, [][2]uint16 or [][2]float32
-	TextureCoord_0, TextureCoord_1 any
-	// [][4]uint8, [][4]uint16 or [][4]float32
-	Weights any
-	// [][4]uint8 or [][4]uint16
-	Joints any
-	//[]color.RGBA, []color.RGBA64, [][4]uint8, [][3]uint8, [][4]uint16, [][3]uint16, [][3]float32 or [][4]float32
-	Color            any
-	CustomAttributes []CustomAttribute
-}
-
-// WriteAttributesInterleaved write all the attributes in v
-// which are not nil and have a non-zero length.
-// Returns an attribute map that can be directly used
-// as a primitive attributes.
-func WriteAttributesInterleaved(doc *gltf.Document, v Attributes) (map[string]uint32, error) {
+// WritePrimitiveAttributes write all the primitives attributes to doc as interleaved data.
+// Returns an attribute map that can be directly used as a primitive attributes.
+func WritePrimitiveAttributes(doc *gltf.Document, attr ...PrimitiveAttribute) (gltf.PrimitiveAttributes, error) {
 	type attrProps struct {
 		Name       string
 		Normalized bool
 	}
-	var (
-		props []attrProps
-		data  []any
-	)
-	if len(v.Position) != 0 {
-		props = append(props, attrProps{Name: gltf.POSITION})
-		data = append(data, v.Position)
-	}
-	if len(v.Normal) != 0 {
-		props = append(props, attrProps{Name: gltf.NORMAL})
-		data = append(data, v.Normal)
-	}
-	if len(v.Tangent) != 0 {
-		props = append(props, attrProps{Name: gltf.TANGENT})
-		data = append(data, v.Tangent)
-	}
-	if sliceLength(v.TextureCoord_0) != 0 {
-		normalized := checkTextureCoord(v.TextureCoord_0)
-		props = append(props, attrProps{Name: gltf.TEXCOORD_0, Normalized: normalized})
-		data = append(data, v.TextureCoord_0)
-	}
-	if sliceLength(v.TextureCoord_1) != 0 {
-		normalized := checkTextureCoord(v.TextureCoord_1)
-		props = append(props, attrProps{Name: gltf.TEXCOORD_1, Normalized: normalized})
-		data = append(data, v.TextureCoord_1)
-	}
-	if sliceLength(v.Weights) != 0 {
-		normalized := checkWeights(v.Weights)
-		props = append(props, attrProps{Name: gltf.WEIGHTS_0, Normalized: normalized})
-		data = append(data, v.Weights)
-	}
-	if sliceLength(v.Joints) != 0 {
-		checkJoints(v.Joints)
-		props = append(props, attrProps{Name: gltf.JOINTS_0})
-		data = append(data, v.Joints)
-	}
-	if sliceLength(v.Color) != 0 {
-		normalized := checkColor(v.Color)
-		props = append(props, attrProps{Name: gltf.COLOR_0, Normalized: normalized})
-		data = append(data, v.Color)
-	}
-	for _, c := range v.CustomAttributes {
-		if sliceLength(c.Data) != 0 {
-			props = append(props, attrProps{Name: c.Name})
-			data = append(data, c.Data)
+	data := make([]any, 0, len(attr))
+	props := make([]attrProps, 0, len(attr))
+	var min, max [3]float64
+	var err error
+	for _, a := range attr {
+		if sliceLength(a.Data) == 0 {
+			continue
+		}
+		var normalized bool
+		switch a.Name {
+		case gltf.POSITION:
+			if v, ok := a.Data.([][3]float32); ok {
+				min, max = minMaxFloat32(v)
+			} else {
+				err = fmt.Errorf("invalid type %T", data)
+			}
+		case gltf.TEXCOORD_0, gltf.TEXCOORD_1:
+			normalized, err = checkTextureCoord(a.Data)
+		case gltf.WEIGHTS_0:
+			normalized, err = checkWeights(a.Data)
+		case gltf.JOINTS_0:
+			err = checkJoints(a.Data)
+		case gltf.COLOR_0:
+			normalized, err = checkColor(a.Data)
+		}
+		if err != nil {
+			return nil, fmt.Errorf("%s: %w", a.Name, err)
 		}
+		data = append(data, a.Data)
+		props = append(props, attrProps{Name: a.Name, Normalized: normalized})
 	}
 	indices, err := WriteAccessorsInterleaved(doc, data...)
 	if err != nil {
 		return nil, err
 	}
-	attrs := make(map[string]uint32, len(props))
+	attrs := make(gltf.PrimitiveAttributes, len(props))
 	for i, index := range indices {
 		prop := props[i]
 		attrs[prop.Name] = index
 		doc.Accessors[index].Normalized = prop.Normalized
 	}
 	if pos, ok := attrs[gltf.POSITION]; ok {
-		min, max := minMaxFloat32(v.Position)
 		doc.Accessors[pos].Min = min[:]
 		doc.Accessors[pos].Max = max[:]
 	}


Test patch:
diff --git a/decoder_test.go b/decoder_test.go
index 37da88a..a36e1e5 100644
--- a/decoder_test.go
+++ b/decoder_test.go
@@ -46,7 +46,7 @@ func TestOpen(t *testing.T) {
 			Buffers:   []*Buffer{{ByteLength: 1800, URI: "Cube.bin", Data: readFile("testdata/Cube/glTF/Cube.bin")}},
 			Images:    []*Image{{URI: "Cube_BaseColor.png"}, {URI: "Cube_MetallicRoughness.png"}},
 			Materials: []*Material{{Name: "Cube", AlphaMode: AlphaOpaque, AlphaCutoff: Float(0.5), PBRMetallicRoughness: &PBRMetallicRoughness{BaseColorFactor: &[4]float64{1, 1, 1, 1}, MetallicFactor: Float(1), RoughnessFactor: Float(1), BaseColorTexture: &TextureInfo{Index: 0}, MetallicRoughnessTexture: &TextureInfo{Index: 1}}}},
-			Meshes:    []*Mesh{{Name: "Cube", Primitives: []*Primitive{{Indices: Index(0), Material: Index(0), Mode: PrimitiveTriangles, Attributes: map[string]uint32{NORMAL: 2, POSITION: 1, TANGENT: 3, TEXCOORD_0: 4}}}}},
+			Meshes:    []*Mesh{{Name: "Cube", Primitives: []*Primitive{{Indices: Index(0), Material: Index(0), Mode: PrimitiveTriangles, Attributes: PrimitiveAttributes{NORMAL: 2, POSITION: 1, TANGENT: 3, TEXCOORD_0: 4}}}}},
 			Nodes:     []*Node{{Mesh: Index(0), Name: "Cube", Matrix: [16]float64{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}, Rotation: [4]float64{0, 0, 0, 1}, Scale: [3]float64{1, 1, 1}}},
 			Samplers:  []*Sampler{{WrapS: WrapRepeat, WrapT: WrapRepeat}},
 			Scene:     Index(0),
@@ -70,7 +70,7 @@ func TestOpen(t *testing.T) {
 				{Perspective: &Perspective{AspectRatio: Float(1.0), Yfov: 0.7, Zfar: Float(100), Znear: 0.01}},
 				{Orthographic: &Orthographic{Xmag: 1.0, Ymag: 1.0, Zfar: 100, Znear: 0.01}},
 			},
-			Meshes: []*Mesh{{Primitives: []*Primitive{{Indices: Index(0), Mode: PrimitiveTriangles, Attributes: map[string]uint32{POSITION: 1}}}}},
+			Meshes: []*Mesh{{Primitives: []*Primitive{{Indices: Index(0), Mode: PrimitiveTriangles, Attributes: PrimitiveAttributes{POSITION: 1}}}}},
 			Nodes: []*Node{
 				{Mesh: Index(0), Matrix: [16]float64{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}, Rotation: [4]float64{-0.3, 0, 0, 0.9}, Scale: [3]float64{1, 1, 1}},
 				{Camera: Index(0), Matrix: [16]float64{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}, Rotation: [4]float64{0, 0, 0, 1}, Scale: [3]float64{1, 1, 1}, Translation: [3]float64{0.5, 0.5, 3.0}},
@@ -97,7 +97,7 @@ func TestOpen(t *testing.T) {
 			},
 			Buffers:   []*Buffer{{ByteLength: 1224, Data: readFile("testdata/BoxVertexColors/glTF-Binary/BoxVertexColors.glb")[1628+20+8:]}},
 			Materials: []*Material{{Name: "Default", AlphaMode: AlphaOpaque, AlphaCutoff: Float(0.5), PBRMetallicRoughness: &PBRMetallicRoughness{BaseColorFactor: &[4]float64{0.8, 0.8, 0.8, 1}, MetallicFactor: Float(0.1), RoughnessFactor: Float(0.99)}}},
-			Meshes:    []*Mesh{{Name: "Cube", Primitives: []*Primitive{{Indices: Index(0), Material: Index(0), Mode: PrimitiveTriangles, Attributes: map[string]uint32{POSITION: 1, COLOR_0: 3, NORMAL: 2, TEXCOORD_0: 4}}}}},
+			Meshes:    []*Mesh{{Name: "Cube", Primitives: []*Primitive{{Indices: Index(0), Material: Index(0), Mode: PrimitiveTriangles, Attributes: PrimitiveAttributes{POSITION: 1, COLOR_0: 3, NORMAL: 2, TEXCOORD_0: 4}}}}},
 			Nodes: []*Node{
 				{Name: "RootNode", Children: []uint32{1, 2, 3}, Matrix: [16]float64{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}, Rotation: [4]float64{0, 0, 0, 1}, Scale: [3]float64{1, 1, 1}},
 				{Name: "Mesh", Matrix: [16]float64{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}, Rotation: [4]float64{0, 0, 0, 1}, Scale: [3]float64{1, 1, 1}},
@@ -132,7 +132,7 @@ func TestOpen(t *testing.T) {
 				Name: "Material", AlphaMode: AlphaOpaque, AlphaCutoff: Float(0.5), NormalTexture: &NormalTexture{Index: Index(0), Scale: Float(1)}, PBRMetallicRoughness: &PBRMetallicRoughness{
 					BaseColorFactor: &[4]float64{1, 1, 1, 1}, MetallicFactor: Float(1), RoughnessFactor: Float(1), BaseColorTexture: &TextureInfo{Index: 1}, MetallicRoughnessTexture: &TextureInfo{Index: 2},
 				}}},
-			Meshes:   []*Mesh{{Name: "Cube", Primitives: []*Primitive{{Indices: Index(3), Material: Index(0), Mode: PrimitiveTriangles, Attributes: map[string]uint32{NORMAL: 1, POSITION: 0, TEXCOORD_0: 2}}}}},
+			Meshes:   []*Mesh{{Name: "Cube", Primitives: []*Primitive{{Indices: Index(3), Material: Index(0), Mode: PrimitiveTriangles, Attributes: PrimitiveAttributes{NORMAL: 1, POSITION: 0, TEXCOORD_0: 2}}}}},
 			Nodes:    []*Node{{Mesh: Index(0), Name: "Cube", Matrix: [16]float64{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}, Rotation: [4]float64{0, 0, 0, 1}, Scale: [3]float64{1, 1, 1}}},
 			Scene:    Index(0),
 			Scenes:   []*Scene{{Name: "Scene", Nodes: []uint32{0}}},
diff --git a/encode_test.go b/encode_test.go
index 986ed23..e48ca4b 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -211,8 +211,8 @@ func TestEncoder_Encode(t *testing.T) {
 		{"withMeshes", args{&Document{Meshes: []*Mesh{
 			{Extras: 8.0, Name: "mesh_1", Weights: []float64{1.2, 2}},
 			{Extras: 8.0, Name: "mesh_2", Primitives: []*Primitive{
-				{Extras: 8.0, Attributes: Attribute{POSITION: 1}, Indices: Index(2), Material: Index(1), Mode: PrimitiveLines},
-				{Extras: 8.0, Targets: []Attribute{{POSITION: 1, "THEN": 4}, {"OTHER": 2}}, Indices: Index(2), Material: Index(1), Mode: PrimitiveLines},
+				{Extras: 8.0, Attributes: PrimitiveAttributes{POSITION: 1}, Indices: Index(2), Material: Index(1), Mode: PrimitiveLines},
+				{Extras: 8.0, Targets: []PrimitiveAttributes{{POSITION: 1, "THEN": 4}, {"OTHER": 2}}, Indices: Index(2), Material: Index(1), Mode: PrimitiveLines},
 			}},
 		}}}, false},
 		{"withNodes", args{&Document{Nodes: []*Node{
diff --git a/example_test.go b/example_test.go
index 7f196b5..4999cd9 100644
--- a/example_test.go
+++ b/example_test.go
@@ -36,7 +36,7 @@ func ExampleSave() {
 			Name: "Default", AlphaMode: gltf.AlphaOpaque, AlphaCutoff: gltf.Float(0.5),
 			PBRMetallicRoughness: &gltf.PBRMetallicRoughness{BaseColorFactor: &[4]float64{0.8, 0.8, 0.8, 1}, MetallicFactor: gltf.Float(0.1), RoughnessFactor: gltf.Float(0.99)},
 		}},
-		Meshes: []*gltf.Mesh{{Name: "Cube", Primitives: []*gltf.Primitive{{Indices: gltf.Index(0), Material: gltf.Index(0), Mode: gltf.PrimitiveTriangles, Attributes: map[string]uint32{gltf.POSITION: 1, gltf.COLOR_0: 3, gltf.NORMAL: 2, gltf.TEXCOORD_0: 4}}}}},
+		Meshes: []*gltf.Mesh{{Name: "Cube", Primitives: []*gltf.Primitive{{Indices: gltf.Index(0), Material: gltf.Index(0), Mode: gltf.PrimitiveTriangles, Attributes: gltf.PrimitiveAttributes{gltf.POSITION: 1, gltf.COLOR_0: 3, gltf.NORMAL: 2, gltf.TEXCOORD_0: 4}}}}},
 		Nodes: []*gltf.Node{
 			{Name: "RootNode", Children: []uint32{1, 2, 3}},
 			{Name: "Mesh"},
diff --git a/modeler/example_test.go b/modeler/example_test.go
index 07aef74..67e4f29 100644
--- a/modeler/example_test.go
+++ b/modeler/example_test.go
@@ -18,7 +18,7 @@ func Example() {
 		Primitives: []*gltf.Primitive{
 			{
 				Indices: gltf.Index(indicesAccessor),
-				Attributes: map[string]uint32{
+				Attributes: gltf.PrimitiveAttributes{
 					gltf.POSITION: positionAccessor,
 					gltf.COLOR_0:  colorIndices,
 				},
@@ -60,7 +60,7 @@ func ExampleWriteImage() {
 		Primitives: []*gltf.Primitive{
 			{
 				Indices: gltf.Index(indicesAccessor),
-				Attributes: map[string]uint32{
+				Attributes: gltf.PrimitiveAttributes{
 					gltf.POSITION:   positionAccessor,
 					gltf.TEXCOORD_0: textureAccessor,
 				},
@@ -87,7 +87,7 @@ func ExampleWriteAccessorsInterleaved() {
 		Primitives: []*gltf.Primitive{
 			{
 				Indices: gltf.Index(indicesAccessor),
-				Attributes: map[string]uint32{
+				Attributes: gltf.PrimitiveAttributes{
 					gltf.POSITION: indices[0],
 					gltf.COLOR_0:  indices[1],
 				},
@@ -103,20 +103,18 @@ func ExampleWriteAccessorsInterleaved() {
 
 func ExampleWriteAttributesInterleaved() {
 	doc := gltf.NewDocument()
-	attrs, _ := modeler.WriteAttributesInterleaved(doc, modeler.Attributes{
-		Position:       [][3]float32{{1, 2, 3}, {0, 0, -1}},
-		Normal:         [][3]float32{{1, 2, 3}, {0, 0, -1}},
-		Tangent:        [][4]float32{{1, 2, 3, 4}, {1, 2, 3, 4}},
-		TextureCoord_0: [][2]uint8{{0, 255}, {255, 0}},
-		TextureCoord_1: [][2]float32{{1, 2}, {1, 2}},
-		Joints:         [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}},
-		Weights:        [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}},
-		Color:          [][3]uint8{{255, 255, 255}, {0, 255, 0}},
-		CustomAttributes: []modeler.CustomAttribute{
-			{Name: "COLOR_1", Data: [][3]uint8{{0, 0, 255}, {100, 200, 0}}},
-			{Name: "COLOR_2", Data: [][4]uint8{{23, 58, 188, 1}, {0, 155, 0, 0}}},
-		},
-	})
+	attrs, _ := modeler.WritePrimitiveAttributes(doc,
+		modeler.PrimitiveAttribute{Name: gltf.POSITION, Data: [][3]float32{{1, 2, 3}, {0, 0, -1}}},
+		modeler.PrimitiveAttribute{Name: gltf.NORMAL, Data: [][3]float32{{1, 2, 3}, {0, 0, -1}}},
+		modeler.PrimitiveAttribute{Name: gltf.TANGENT, Data: [][4]float32{{1, 2, 3, 4}, {1, 2, 3, 4}}},
+		modeler.PrimitiveAttribute{Name: gltf.TEXCOORD_0, Data: [][2]uint8{{0, 255}, {255, 0}}},
+		modeler.PrimitiveAttribute{Name: gltf.TEXCOORD_1, Data: [][2]float32{{1, 2}, {1, 2}}},
+		modeler.PrimitiveAttribute{Name: gltf.JOINTS_0, Data: [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}}},
+		modeler.PrimitiveAttribute{Name: gltf.WEIGHTS_0, Data: [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}}},
+		modeler.PrimitiveAttribute{Name: gltf.COLOR_0, Data: [][3]uint8{{255, 255, 255}, {0, 255, 0}}},
+		modeler.PrimitiveAttribute{Name: "COLOR_1", Data: [][3]uint8{{0, 0, 255}, {100, 200, 0}}},
+		modeler.PrimitiveAttribute{Name: "COLOR_2", Data: [][4]uint8{{23, 58, 188, 1}, {0, 155, 0, 0}}},
+	)
 	indicesAccessor := modeler.WriteIndices(doc, []uint16{0, 1, 2, 3, 1, 0, 0, 2, 3, 1, 4, 2, 4, 3, 2, 4, 1, 3})
 	doc.Meshes = []*gltf.Mesh{{
 		Name: "Pyramid",
diff --git a/modeler/write_test.go b/modeler/write_test.go
index 7822597..527f71f 100644
--- a/modeler/write_test.go
+++ b/modeler/write_test.go
@@ -31,20 +31,18 @@ func TestAlignment(t *testing.T) {
 func TestWriteAttributesInterleaved(t *testing.T) {
 	data := [][3]float32{{1, 2, 3}, {0, 0, -1}}
 	doc := gltf.NewDocument()
-	attrs, err := WriteAttributesInterleaved(doc, Attributes{
-		Position:       data,
-		Normal:         data,
-		Tangent:        [][4]float32{{1, 2, 3, 4}, {1, 2, 3, 4}},
-		TextureCoord_0: [][2]float32{{1, 2}, {1, 2}},
-		TextureCoord_1: [][2]float32{{1, 2}, {1, 2}},
-		Joints:         [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}},
-		Weights:        [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}},
-		Color:          data,
-		CustomAttributes: []CustomAttribute{
-			{Name: "COLOR_1", Data: data},
-			{Name: "COLOR_2", Data: data},
-		},
-	})
+	attrs, err := WritePrimitiveAttributes(doc,
+		PrimitiveAttribute{Name: gltf.POSITION, Data: data},
+		PrimitiveAttribute{Name: gltf.NORMAL, Data: data},
+		PrimitiveAttribute{Name: gltf.TANGENT, Data: [][4]float32{{1, 2, 3, 4}, {1, 2, 3, 4}}},
+		PrimitiveAttribute{Name: gltf.TEXCOORD_0, Data: [][2]float32{{1, 2}, {1, 2}}},
+		PrimitiveAttribute{Name: gltf.TEXCOORD_1, Data: [][2]float32{{1, 2}, {1, 2}}},
+		PrimitiveAttribute{Name: gltf.WEIGHTS_0, Data: [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}}},
+		PrimitiveAttribute{Name: gltf.JOINTS_0, Data: [][4]uint8{{1, 2, 3, 4}, {1, 2, 3, 4}}},
+		PrimitiveAttribute{Name: gltf.COLOR_0, Data: data},
+		PrimitiveAttribute{Name: "COLOR_1", Data: data},
+		PrimitiveAttribute{Name: "COLOR_2", Data: data},
+	)
 	if err != nil {
 		t.Fatalf("TestWriteAttributesInterleaved() got error = %v", err)
 	}
@@ -91,11 +89,10 @@ func TestWriteAttributesInterleaved(t *testing.T) {
 
 func TestWriteAttributesInterleaved_OnlyPosition(t *testing.T) {
 	doc := gltf.NewDocument()
-	_, err := WriteAttributesInterleaved(doc, Attributes{
-		Position:         [][3]float32{{1, 2, 3}, {0, 0, -1}},
-		Tangent:          make([][4]float32, 0),
-		CustomAttributes: []CustomAttribute{{Name: "COLOR_1"}},
-	})
+	_, err := WritePrimitiveAttributes(doc,
+		PrimitiveAttribute{Name: gltf.POSITION, Data: [][3]float32{{1, 2, 3}, {0, 0, -1}}},
+		PrimitiveAttribute{Name: gltf.TANGENT, Data: make([][4]float32, 0)},
+		PrimitiveAttribute{Name: "COLOR_1"})
 	if err != nil {
 		t.Fatalf("TestWriteAttributesInterleaved_OnlyPosition() got error = %v", err)
 	}
@@ -106,10 +103,10 @@ func TestWriteAttributesInterleaved_OnlyPosition(t *testing.T) {
 
 func TestWriteAttributesInterleaved_Error(t *testing.T) {
 	doc := gltf.NewDocument()
-	_, err := WriteAttributesInterleaved(doc, Attributes{
-		Position: [][3]float32{{1, 2, 3}, {0, 0, -1}},
-		Color:    [][3]float32{{1, 2, 3}},
-	})
+	_, err := WritePrimitiveAttributes(doc,
+		PrimitiveAttribute{Name: gltf.POSITION, Data: [][3]float32{{1, 2, 3}, {0, 0, -1}}},
+		PrimitiveAttribute{Name: gltf.COLOR_0, Data: [][3]float32{{1, 2, 3}}},
+	)
 	if err == nil {
 		t.Error("TestWriteAttributesInterleaved_Error() expected an error")
 	}