View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.geometry.io.euclidean.threed.obj;
18  
19  import java.io.StringReader;
20  import java.util.ArrayList;
21  import java.util.Arrays;
22  import java.util.Collections;
23  import java.util.List;
24  import java.util.function.IntFunction;
25  
26  import org.apache.commons.geometry.core.GeometryTestUtils;
27  import org.apache.commons.geometry.euclidean.EuclideanTestUtils;
28  import org.apache.commons.geometry.euclidean.threed.Vector3D;
29  import org.junit.jupiter.api.Assertions;
30  import org.junit.jupiter.api.Test;
31  
32  class PolygonObjParserTest {
33  
34      private static final double EPS = 1e-10;
35  
36      @Test
37      void testInitialState() {
38          // act
39          final PolygonObjParser p = parser("");
40  
41          // assert
42          Assertions.assertNull(p.getCurrentKeyword());
43          Assertions.assertEquals(0, p.getVertexCount());
44          Assertions.assertEquals(0, p.getVertexNormalCount());
45          Assertions.assertEquals(0, p.getTextureCoordinateCount());
46          Assertions.assertFalse(p.isFailOnNonPolygonKeywords());
47      }
48  
49      @Test
50      void testNextKeyword() {
51          // arrange
52          final PolygonObjParser p = parser(lines(
53                  "#comment",
54                  "",
55                  "  \t",
56                  "o test",
57                  "v",
58                  "v 1 0 0 1",
59                  "v 0 1 0",
60                  "# comment",
61                  " ",
62                  "g triangle-\\",
63                  "group",
64                  "f 1 2 3",
65                  "",
66                  "curv2",
67                  "# end"
68          ));
69  
70          // act/assert
71          assertNextKeyword("o", p);
72          assertNextKeyword("v", p);
73          assertNextKeyword("v", p);
74          assertNextKeyword("v", p);
75          assertNextKeyword("g", p);
76          assertNextKeyword("f", p);
77          assertNextKeyword("curv2", p);
78  
79          assertNextKeyword(null, p);
80      }
81  
82      @Test
83      void testNextKeyword_polygonKeywordsOnly_valid() {
84          // arrange
85          final PolygonObjParser p = parser(lines(
86                  "v",
87                  "vn",
88                  "vt",
89                  "f",
90                  "o",
91                  "s",
92                  "g",
93                  "mtllib",
94                  "usemtl"
95          ));
96          p.setFailOnNonPolygonKeywords(true);
97  
98          // act/assert
99          assertNextKeyword("v", p);
100         assertNextKeyword("vn", p);
101         assertNextKeyword("vt", p);
102         assertNextKeyword("f", p);
103         assertNextKeyword("o", p);
104         assertNextKeyword("s", p);
105         assertNextKeyword("g", p);
106         assertNextKeyword("mtllib", p);
107         assertNextKeyword("usemtl", p);
108 
109         assertNextKeyword(null, p);
110     }
111 
112     @Test
113     void testNextKeyword_polygonKeywordsOnly_invalid() {
114         // arrange
115         final PolygonObjParser p = parser(lines(
116                 "",
117                 "curv2 abc"
118         ));
119         p.setFailOnNonPolygonKeywords(true);
120 
121         // act/assert
122         GeometryTestUtils.assertThrowsWithMessage(() -> {
123             p.nextKeyword();
124         }, IllegalStateException.class,
125                 "Parsing failed at line 2, column 1: expected keyword to be one of " +
126                 "[f, g, mtllib, o, s, usemtl, v, vn, vt] but was [curv2]");
127     }
128 
129     @Test
130     void testNextKeyword_emptyContent() {
131         // arrange
132         final PolygonObjParser p = parser("");
133 
134         // act/assert
135         assertNextKeyword(null, p);
136     }
137 
138     @Test
139     void testNextKeyword_unexpectedContent() {
140         // arrange
141         final PolygonObjParser p = parser(lines(
142                     " f",
143                     "-- bad comment attempt"
144                 ));
145 
146         // act/assert
147         GeometryTestUtils.assertThrowsWithMessage(() -> {
148             p.nextKeyword();
149         }, IllegalStateException.class, "Parsing failed at line 1, column 2: " +
150             "non-blank lines must begin with an OBJ keyword or comment character");
151 
152         GeometryTestUtils.assertThrowsWithMessage(() -> {
153             p.nextKeyword();
154         }, IllegalStateException.class, "Parsing failed at line 2, column 1: " +
155             "expected OBJ keyword but found empty token followed by [-]");
156     }
157 
158     @Test
159     void testReadDataLine() {
160         // arrange
161         final PolygonObjParser p = parser(lines(
162                 "  line\t",
163                 "",
164                 " \\",
165                 "a \\",
166                 "b\\",
167                 "cd\\",
168                 ".\\"
169         ));
170 
171         // act/assert
172         Assertions.assertEquals("  line\t", p.readDataLine());
173         Assertions.assertEquals("", p.readDataLine());
174         Assertions.assertEquals(" a bcd.", p.readDataLine());
175         Assertions.assertNull(p.readDataLine());
176     }
177 
178     @Test
179     void testDiscardDataLine() {
180         // arrange
181         final PolygonObjParser p = parser(lines(
182                 "  line\t",
183                 "",
184                 " \\",
185                 "a \\",
186                 "b\\",
187                 "cd\\",
188                 ".\\"
189         ));
190 
191         // act/assert
192         p.discardDataLine();
193         Assertions.assertEquals(2, p.getTextParser().getLineNumber());
194         Assertions.assertEquals(1, p.getTextParser().getColumnNumber());
195 
196         p.discardDataLine();
197         Assertions.assertEquals(3, p.getTextParser().getLineNumber());
198         Assertions.assertEquals(1, p.getTextParser().getColumnNumber());
199 
200         p.discardDataLine();
201         Assertions.assertEquals(8, p.getTextParser().getLineNumber());
202         Assertions.assertEquals(1, p.getTextParser().getColumnNumber());
203 
204         p.discardDataLine();
205         Assertions.assertEquals(8, p.getTextParser().getLineNumber());
206         Assertions.assertEquals(1, p.getTextParser().getColumnNumber());
207     }
208 
209     @Test
210     void testReadVector() {
211         // arrange
212         final PolygonObjParser p = parser(lines(
213                 "1.01 3e-02 123.999 extra"
214         ));
215 
216         // act/assert
217         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1.01, 0.03, 123.999), p.readVector(), EPS);
218     }
219 
220     @Test
221     void testReadVector_parseFailures() {
222         // arrange
223         final PolygonObjParser p = parser(lines(
224                 "0.1 0.2 a",
225                 "1",
226                 ""
227         ));
228 
229         // act/assert
230         GeometryTestUtils.assertThrowsWithMessage(() -> {
231             p.readVector();
232         }, IllegalStateException.class, "Parsing failed at line 1, column 9: expected double but found [a]");
233 
234         p.readDataLine();
235 
236         GeometryTestUtils.assertThrowsWithMessage(() -> {
237             p.readVector();
238         }, IllegalStateException.class, "Parsing failed at line 2, column 2: expected double but found end of line");
239     }
240 
241     @Test
242     void testReadDoubles() {
243         // arrange
244         final PolygonObjParser p = parser(lines(
245                 "0.1 0.2 3e2 4e2 500.01",
246                 "  12.001  ",
247                 "  ",
248                 ""
249         ));
250 
251         // act/assert
252         Assertions.assertArrayEquals(new double[] {
253             0.1, 0.2, 3e2, 4e2, 500.01
254         }, p.readDoubles(), EPS);
255         Assertions.assertArrayEquals(new double[0], p.readDoubles(), EPS);
256 
257         p.readDataLine();
258 
259         Assertions.assertArrayEquals(new double[] {12.001}, p.readDoubles(), EPS);
260 
261         p.readDataLine();
262 
263         Assertions.assertArrayEquals(new double[0], p.readDoubles(), EPS);
264 
265         p.readDataLine();
266 
267         Assertions.assertArrayEquals(new double[0], p.readDoubles(), EPS);
268     }
269 
270     @Test
271     void testReadDoubles_parseFailures() {
272         // arrange
273         final PolygonObjParser p = parser(lines(
274                 "0.1 0.2 a",
275                 "b"
276         ));
277 
278         // act/assert
279         GeometryTestUtils.assertThrowsWithMessage(() -> {
280             p.readDoubles();
281         }, IllegalStateException.class, "Parsing failed at line 1, column 9: expected double but found [a]");
282 
283         p.readDataLine();
284 
285         GeometryTestUtils.assertThrowsWithMessage(() -> {
286             p.readDoubles();
287         }, IllegalStateException.class, "Parsing failed at line 2, column 1: expected double but found [b]");
288     }
289 
290     @Test
291     void testReadFace() {
292         // arrange
293         final PolygonObjParser p = parser(lines(
294                 "# test content",
295                 "o test",
296                 "v 0 0 0",
297                 "v 1 0 0",
298                 "v 1 1 0",
299                 "v 0 1 0",
300                 "vt 1 2",
301                 "vt 3 4",
302                 "vt 5 6",
303                 "vt 7 8",
304                 "vt 9 10",
305                 "vn 0 0 1",
306                 "vn 0 0 -1",
307 
308                 "f 1 2 3 4",
309                 "f -4// -3// -2// -1//",
310 
311                 "f 1//1 2//2 3//1 4//2",
312                 "f -4//-2 -3//-1 -2//-2 -1//-1",
313 
314                 "f 1/4/1 2/3/2 3/2/1 4/1/2",
315                 "f -4/-1/-2 -3/-2/-1 -2/-3/-2 -1/-4/-1",
316 
317                 "f 1/4 2/3 3/2 4/1",
318                 "f -4/-1 -3/-2 -2/-3 -1/-4"
319         ));
320 
321         nextFace(p);
322 
323         // act/assert
324         assertFace(new int[][] {
325             {0, -1, -1},
326             {1, -1, -1},
327             {2, -1, -1},
328             {3, -1, -1},
329         }, p.readFace());
330 
331         nextFace(p);
332 
333         assertFace(new int[][] {
334             {0, -1, -1},
335             {1, -1, -1},
336             {2, -1, -1},
337             {3, -1, -1},
338         }, p.readFace());
339 
340         nextFace(p);
341 
342         assertFace(new int[][] {
343             {0, -1, 0},
344             {1, -1, 1},
345             {2, -1, 0},
346             {3, -1, 1},
347         }, p.readFace());
348 
349         nextFace(p);
350 
351         assertFace(new int[][] {
352             {0, -1, 0},
353             {1, -1, 1},
354             {2, -1, 0},
355             {3, -1, 1},
356         }, p.readFace());
357 
358         nextFace(p);
359 
360         assertFace(new int[][] {
361             {0, 3, 0},
362             {1, 2, 1},
363             {2, 1, 0},
364             {3, 0, 1},
365         }, p.readFace());
366 
367         nextFace(p);
368 
369         assertFace(new int[][] {
370             {0, 4, 0},
371             {1, 3, 1},
372             {2, 2, 0},
373             {3, 1, 1},
374         }, p.readFace());
375 
376         nextFace(p);
377 
378         assertFace(new int[][] {
379             {0, 3, -1},
380             {1, 2, -1},
381             {2, 1, -1},
382             {3, 0, -1},
383         }, p.readFace());
384 
385         nextFace(p);
386 
387         assertFace(new int[][] {
388             {0, 4, -1},
389             {1, 3, -1},
390             {2, 2, -1},
391             {3, 1, -1},
392         }, p.readFace());
393     }
394 
395     @Test
396     void testReadFace_notEnoughVertices() {
397         // arrange
398         final PolygonObjParser p = parser(lines(
399                 "# test content",
400                 "v 0 0 0",
401                 "v 1 0 0",
402                 "v 1 1 0",
403                 "f 1 2"
404         ));
405 
406         // act/assert
407         nextFace(p);
408         GeometryTestUtils.assertThrowsWithMessage(() -> {
409             p.readFace();
410         }, IllegalStateException.class, "Parsing failed at line 5, column 6: " +
411             "face must contain at least 3 vertices but found only 2");
412     }
413 
414     @Test
415     void testReadFace_invalidVertexIndex() {
416         // arrange
417         final PolygonObjParser p = parser(lines(
418                 "# test content",
419                 "f 1 2 3",
420                 "v 0 0 0",
421                 "v 1 0 0",
422                 "v 1 1 0",
423                 "f 1 2 -4",
424                 "f 1 0 3",
425                 "f 4 2 3"
426         ));
427 
428         // act/assert
429         nextFace(p);
430         GeometryTestUtils.assertThrowsWithMessage(() -> {
431             p.readFace();
432         }, IllegalStateException.class, "Parsing failed at line 2, column 3: " +
433             "vertex index cannot be used because no values of that type have been defined");
434 
435         nextFace(p);
436         GeometryTestUtils.assertThrowsWithMessage(() -> {
437             p.readFace();
438         }, IllegalStateException.class, "Parsing failed at line 6, column 7: " +
439             "vertex index must evaluate to be within the range [1, 3] but was -4");
440 
441         nextFace(p);
442         GeometryTestUtils.assertThrowsWithMessage(() -> {
443             p.readFace();
444         }, IllegalStateException.class, "Parsing failed at line 7, column 5: " +
445             "vertex index must evaluate to be within the range [1, 3] but was 0");
446 
447         nextFace(p);
448         GeometryTestUtils.assertThrowsWithMessage(() -> {
449             p.readFace();
450         }, IllegalStateException.class, "Parsing failed at line 8, column 3: " +
451             "vertex index must evaluate to be within the range [1, 3] but was 4");
452     }
453 
454     @Test
455     void testReadFace_invalidTextureIndex() {
456         // arrange
457         final PolygonObjParser p = parser(lines(
458                 "# test content",
459                 "v 0 0 0",
460                 "v 1 0 0",
461                 "v 1 1 0",
462                 "f 1/1 2/2 3/3",
463                 "vt 1 2",
464                 "vt 3 4",
465                 "vt 5 6",
466                 "f 1/1 2/2 3/-4",
467                 "f 1/1 1/0 3/3",
468                 "f 1/4 2/2 3/3"
469         ));
470 
471         // act/assert
472         nextFace(p);
473         GeometryTestUtils.assertThrowsWithMessage(() -> {
474             p.readFace();
475         }, IllegalStateException.class, "Parsing failed at line 5, column 5: " +
476             "texture index cannot be used because no values of that type have been defined");
477 
478         nextFace(p);
479         GeometryTestUtils.assertThrowsWithMessage(() -> {
480             p.readFace();
481         }, IllegalStateException.class, "Parsing failed at line 9, column 13: " +
482             "texture index must evaluate to be within the range [1, 3] but was -4");
483 
484         nextFace(p);
485         GeometryTestUtils.assertThrowsWithMessage(() -> {
486             p.readFace();
487         }, IllegalStateException.class, "Parsing failed at line 10, column 9: " +
488             "texture index must evaluate to be within the range [1, 3] but was 0");
489 
490         nextFace(p);
491         GeometryTestUtils.assertThrowsWithMessage(() -> {
492             p.readFace();
493         }, IllegalStateException.class, "Parsing failed at line 11, column 5: " +
494             "texture index must evaluate to be within the range [1, 3] but was 4");
495     }
496 
497     @Test
498     void testReadFace_invalidNormalIndex() {
499         // arrange
500         final PolygonObjParser p = parser(lines(
501                 "# test content",
502                 "v 0 0 0",
503                 "v 1 0 0",
504                 "v 1 1 0",
505                 "f 1//1 2//2 3//3",
506                 "vn 1 0 0",
507                 "vn 0 1 0",
508                 "vn 0 0 1",
509                 "f 1//1 2//2 3//-4",
510                 "f 1//1 1//0 3//3",
511                 "f 1//4 2//2 3//3"
512         ));
513 
514         // act/assert
515         nextFace(p);
516         GeometryTestUtils.assertThrowsWithMessage(() -> {
517             p.readFace();
518         }, IllegalStateException.class, "Parsing failed at line 5, column 6: " +
519             "normal index cannot be used because no values of that type have been defined");
520 
521         nextFace(p);
522         GeometryTestUtils.assertThrowsWithMessage(() -> {
523             p.readFace();
524         }, IllegalStateException.class, "Parsing failed at line 9, column 16: " +
525             "normal index must evaluate to be within the range [1, 3] but was -4");
526 
527         nextFace(p);
528         GeometryTestUtils.assertThrowsWithMessage(() -> {
529             p.readFace();
530         }, IllegalStateException.class, "Parsing failed at line 10, column 11: " +
531             "normal index must evaluate to be within the range [1, 3] but was 0");
532 
533         nextFace(p);
534         GeometryTestUtils.assertThrowsWithMessage(() -> {
535             p.readFace();
536         }, IllegalStateException.class, "Parsing failed at line 11, column 6: " +
537             "normal index must evaluate to be within the range [1, 3] but was 4");
538     }
539 
540     @Test
541     void testParse() {
542         // arrange
543         final PolygonObjParser p = parser(lines(
544                 "# test content",
545                 "o test",
546                 "g test",
547                 "s test",
548                 "mtllib mylib.mtl",
549                 "usemtl mymaterial",
550                 "",
551                 "\\", // line continuation
552                 " \\", // line continuation
553                 "",
554                 "v 0 0 0",
555                 "v 1\\", ".0 0 0", // line continuation
556                 "v 1 1 0",
557                 "v 0 1 0",
558                 "",
559                 "vt 0 0",
560                 "vt 1 0",
561                 "vt 1 1",
562                 "",
563                 "vn 0 0 1",
564                 "",
565                 "f 1 2 4",
566                 "f 1/1/1 2/2/1 3\\", "/3/1" // line continuation
567         ));
568 
569         // act/assert
570         assertNextKeyword("o", p);
571         Assertions.assertEquals("test", p.readDataLine());
572 
573         assertNextKeyword("g", p);
574         Assertions.assertEquals("test", p.readDataLine());
575 
576         assertNextKeyword("s", p);
577         Assertions.assertEquals("test", p.readDataLine());
578 
579         assertNextKeyword("mtllib", p);
580         Assertions.assertEquals("mylib.mtl", p.readDataLine());
581 
582         assertNextKeyword("usemtl", p);
583         Assertions.assertEquals("mymaterial", p.readDataLine());
584 
585         assertNextKeyword("v", p);
586         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.ZERO, p.readVector(), EPS);
587 
588         assertNextKeyword("v", p);
589         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_X, p.readVector(), EPS);
590 
591         assertNextKeyword("v", p);
592         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(1, 1, 0), p.readVector(), EPS);
593 
594         assertNextKeyword("v", p);
595         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Y, p.readVector(), EPS);
596 
597         assertNextKeyword("vt", p);
598         Assertions.assertArrayEquals(new double[] {0, 0}, p.readDoubles(),  EPS);
599 
600         assertNextKeyword("vt", p);
601         Assertions.assertArrayEquals(new double[] {1, 0}, p.readDoubles(),  EPS);
602 
603         assertNextKeyword("vt", p);
604         Assertions.assertArrayEquals(new double[] {1, 1}, p.readDoubles(),  EPS);
605 
606         assertNextKeyword("vn", p);
607         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z, p.readVector(), EPS);
608 
609         assertNextKeyword("f", p);
610         assertFace(new int[][] {
611             {0, -1, -1},
612             {1, -1, -1},
613             {3, -1, -1},
614         }, p.readFace());
615 
616         assertNextKeyword("f", p);
617         assertFace(new int[][] {
618             {0, 0, 0},
619             {1, 1, 0},
620             {2, 2, 0},
621         }, p.readFace());
622 
623         Assertions.assertEquals(4, p.getVertexCount());
624         Assertions.assertEquals(3, p.getTextureCoordinateCount());
625         Assertions.assertEquals(1, p.getVertexNormalCount());
626     }
627 
628     @Test
629     void testFace_getDefinedCompositeNormal() {
630         // arrange
631         final PolygonObjParser p = parser(lines(
632                 "v 0 0 0",
633                 "v 1 0 0",
634                 "v 1 1 0",
635                 "v 0 1 0",
636                 "",
637                 "vn 0 0 1",
638                 "vn 0 0 -1",
639                 "vn 2 2 2",
640                 "vn -2 2 2",
641                 "",
642                 "f 1 2 3 4",
643                 "f 1//1 2 3",
644                 "f 1//1 2//1 3//1 4//1",
645                 "f 1//1 2//2 3//1 4//2",
646                 "f 1//-2 2//-1 3//3 4//4"
647         ));
648 
649         final List<Vector3D> normals = Arrays.asList(
650                 Vector3D.Unit.PLUS_Z,
651                 Vector3D.Unit.MINUS_Z,
652                 Vector3D.of(1, 1, 1),
653                 Vector3D.of(-1, 1, 1));
654         final IntFunction<Vector3D> normalFn = normals::get;
655 
656         // act/assert
657         nextMatchingKeyword("f", p);
658         Assertions.assertNull(p.readFace().getDefinedCompositeNormal(normalFn));
659 
660         nextMatchingKeyword("f", p);
661         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z,
662                 p.readFace().getDefinedCompositeNormal(normalFn), EPS);
663 
664         nextMatchingKeyword("f", p);
665         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z,
666                 p.readFace().getDefinedCompositeNormal(normalFn), EPS);
667 
668         nextMatchingKeyword("f", p);
669         Assertions.assertNull(p.readFace().getDefinedCompositeNormal(normalFn));
670 
671         nextMatchingKeyword("f", p);
672         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.of(0, 1, 1).normalize(),
673                 p.readFace().getDefinedCompositeNormal(normalFn), EPS);
674     }
675 
676     @Test
677     void testFace_computeNormalFromVertices() {
678         // arrange
679         final PolygonObjParser p = parser(lines(
680                 "v 0 0 0",
681                 "v 1 0 0",
682                 "v 2 0 0",
683                 "v 0 1 0",
684                 "",
685                 "vn 0 0 1",
686                 "",
687                 "f 1 2 4",
688                 "f 1//1 2//1 3//1"
689         ));
690 
691         final List<Vector3D> vertices = Arrays.asList(
692                 Vector3D.ZERO,
693                 Vector3D.Unit.PLUS_X,
694                 Vector3D.of(2, 0, 0),
695                 Vector3D.of(0, 1, 0));
696         final IntFunction<Vector3D> vertexFn = vertices::get;
697 
698         // act/assert
699         nextMatchingKeyword("f", p);
700         EuclideanTestUtils.assertCoordinatesEqual(Vector3D.Unit.PLUS_Z,
701                 p.readFace().computeNormalFromVertices(vertexFn), EPS);
702 
703         nextMatchingKeyword("f", p);
704         Assertions.assertNull(p.readFace().computeNormalFromVertices(vertexFn));
705     }
706 
707     @Test
708     void testFace_getVertexAttributesCounterClockwise() {
709         // arrange
710         final PolygonObjParser p = parser(lines(
711                 "v 0 0 0",
712                 "v 1 0 0",
713                 "v 0 1 0",
714                 "f 1 2 3"
715         ));
716 
717         final List<Vector3D> vertices = Arrays.asList(
718                 Vector3D.ZERO,
719                 Vector3D.Unit.PLUS_X,
720                 Vector3D.Unit.PLUS_Y,
721                 Vector3D.of(2, 0, 0));
722         final IntFunction<Vector3D> vertexFn = vertices::get;
723 
724         nextMatchingKeyword("f", p);
725         final PolygonObjParser.Face f = p.readFace();
726 
727         final List<PolygonObjParser.VertexAttributes> attrs = f.getVertexAttributes();
728 
729         final List<PolygonObjParser.VertexAttributes> reverseAttrs = new ArrayList<>(attrs);
730         Collections.reverse(reverseAttrs);
731 
732         // act/assert
733         Assertions.assertEquals(attrs, f.getVertexAttributesCounterClockwise(null, vertexFn));
734 
735         Assertions.assertEquals(attrs, f.getVertexAttributesCounterClockwise(Vector3D.Unit.PLUS_Z, vertexFn));
736         Assertions.assertEquals(attrs, f.getVertexAttributesCounterClockwise(Vector3D.of(1, 0, 0.1), vertexFn));
737         Assertions.assertEquals(attrs, f.getVertexAttributesCounterClockwise(Vector3D.Unit.PLUS_X, vertexFn));
738 
739         Assertions.assertEquals(reverseAttrs, f.getVertexAttributesCounterClockwise(Vector3D.Unit.MINUS_Z, vertexFn));
740         Assertions.assertEquals(reverseAttrs, f.getVertexAttributesCounterClockwise(Vector3D.of(1, 0, -0.1), vertexFn));
741     }
742 
743     @Test
744     void testFace_getVertices() {
745         // arrange
746         final PolygonObjParser p = parser(lines(
747                 "v 0 0 0",
748                 "v 1 0 0",
749                 "v 1 1 0",
750                 "v 0 1 0",
751                 "v 0 0 1",
752                 "v 0 0 -1",
753                 "",
754                 "f 2 3 4"
755         ));
756 
757         final List<Vector3D> vertices = Arrays.asList(
758                 Vector3D.ZERO,
759                 Vector3D.Unit.PLUS_X,
760                 Vector3D.of(1, 1, 0),
761                 Vector3D.Unit.PLUS_Y,
762                 Vector3D.of(0, 0, 1),
763                 Vector3D.of(0, 0, -1));
764         final IntFunction<Vector3D> vertexFn = vertices::get;
765 
766         // act/assert
767         nextMatchingKeyword("f", p);
768         Assertions.assertEquals(vertices.subList(1, 4), p.readFace().getVertices(vertexFn));
769     }
770 
771     @Test
772     void testFace_getVerticesCounterClockwise() {
773         // arrange
774         final PolygonObjParser p = parser(lines(
775                 "v 0 0 0",
776                 "v 1 0 0",
777                 "v 0 1 0",
778                 "v 0 0 -1",
779                 "f 1 2 3"
780         ));
781 
782         final List<Vector3D> vertices = Arrays.asList(
783                 Vector3D.ZERO,
784                 Vector3D.Unit.PLUS_X,
785                 Vector3D.Unit.PLUS_Y,
786                 Vector3D.of(0, 0, -1));
787         final IntFunction<Vector3D> vertexFn = vertices::get;
788 
789         final List<Vector3D> faceVertices = vertices.subList(0, 3);
790         final List<Vector3D> reverseFaceVertices = new ArrayList<>(faceVertices);
791         Collections.reverse(reverseFaceVertices);
792 
793         nextMatchingKeyword("f", p);
794         final PolygonObjParser.Face f = p.readFace();
795 
796         // act/assert
797         Assertions.assertEquals(faceVertices, f.getVerticesCounterClockwise(null, vertexFn));
798 
799         Assertions.assertEquals(faceVertices, f.getVerticesCounterClockwise(Vector3D.Unit.PLUS_Z, vertexFn));
800         Assertions.assertEquals(faceVertices, f.getVerticesCounterClockwise(Vector3D.of(1, 0, 0.1), vertexFn));
801         Assertions.assertEquals(faceVertices, f.getVerticesCounterClockwise(Vector3D.Unit.PLUS_X, vertexFn));
802 
803         Assertions.assertEquals(reverseFaceVertices, f.getVerticesCounterClockwise(Vector3D.Unit.MINUS_Z, vertexFn));
804         Assertions.assertEquals(reverseFaceVertices, f.getVerticesCounterClockwise(Vector3D.of(1, 0, -0.1), vertexFn));
805     }
806 
807     private static PolygonObjParser parser(final String content) {
808         return new PolygonObjParser(new StringReader(content));
809     }
810 
811     private static String lines(final String... lines) {
812         final String[] newlineOptions = {"\n", "\r", "\r\n"};
813 
814         final StringBuilder sb = new StringBuilder();
815         for (int i = 0; i < lines.length; ++i) {
816             sb.append(lines[i])
817                 .append(newlineOptions[i % newlineOptions.length]);
818         }
819 
820         return sb.toString();
821     }
822 
823     private static void nextFace(final PolygonObjParser parser) {
824         nextMatchingKeyword(ObjConstants.FACE_KEYWORD, parser);
825     }
826 
827     private static void nextMatchingKeyword(final String keyword, final PolygonObjParser parser) {
828         while (parser.nextKeyword()) {
829             if (keyword.equals(parser.getCurrentKeyword())) {
830                 return;
831             }
832         }
833     }
834 
835     private static void assertNextKeyword(final String expected, final PolygonObjParser parser) {
836         Assertions.assertEquals(expected != null, parser.nextKeyword());
837         Assertions.assertEquals(expected, parser.getCurrentKeyword());
838     }
839 
840     private static void assertFace(final int[][] vertexAttributes, final PolygonObjParser.Face face) {
841         Assertions.assertEquals(vertexAttributes.length, face.getVertexAttributes().size());
842 
843         final int[] expectedVertexIndices = new int[vertexAttributes.length];
844         final int[] expectedTextureIndices = new int[vertexAttributes.length];
845         final int[] expectedNormalIndices = new int[vertexAttributes.length];
846 
847         // check the indices directly on the vertex attributes
848         PolygonObjParser.VertexAttributes attrs;
849         String msg;
850         for (int i = 0; i < vertexAttributes.length; ++i) {
851             attrs = face.getVertexAttributes().get(i);
852 
853             msg = "Unexpected face vertex attributes at index " + i;
854             Assertions.assertArrayEquals(vertexAttributes[i], new int[] {
855                     attrs.getVertexIndex(),
856                     attrs.getTextureIndex(),
857                     attrs.getNormalIndex()
858             }, msg);
859 
860             expectedVertexIndices[i] = attrs.getVertexIndex();
861             expectedTextureIndices[i] = attrs.getTextureIndex();
862             expectedNormalIndices[i] = attrs.getNormalIndex();
863         }
864 
865         // check the individual index arrays from the face
866         Assertions.assertArrayEquals(expectedVertexIndices, face.getVertexIndices());
867         Assertions.assertArrayEquals(expectedTextureIndices, face.getTextureIndices());
868         Assertions.assertArrayEquals(expectedNormalIndices, face.getNormalIndices());
869     }
870 }