added tests for moved-to files and scaling in flat dimensions

Thu, 30 May 2019 12:03:53 +0300

author
Teemu Piippo <teemu@hecknology.net>
date
Thu, 30 May 2019 12:03:53 +0300
changeset 54
0c686d10eb49
parent 53
0cc196c634f1
child 55
388df1fa18a2

added tests for moved-to files and scaling in flat dimensions

filecache.py file | annotate | diff | comparison | revisions
header.py file | annotate | diff | comparison | revisions
ldcheck.py file | annotate | diff | comparison | revisions
parse.py file | annotate | diff | comparison | revisions
tests/subfiles.py file | annotate | diff | comparison | revisions
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/filecache.py	Thu May 30 12:03:53 2019 +0300
@@ -0,0 +1,121 @@
+import datetime
+import parse
+import os
+from pathlib import Path
+import linetypes
+import math
+
+def find_ldraw_file(filename, libraries):
+    '''
+        Searches the LDraw libraries for the specified file.
+        `libraries` must be an iterable of LDraw library root paths.
+    '''
+    if os.path.sep != "\\":
+        filename = filename.replace("\\", os.path.sep)
+    for library in libraries:
+        for path in [
+            Path(library) / filename,
+            Path(library) / "parts" / filename,
+            Path(library) / "p" / filename,
+        ]:
+            if path.exists():
+                return path
+    raise FileNotFoundError(str.format(
+        '"{}" not found in the LDraw libraries',
+        filename,
+    ))
+
+class CyclicalReferenceError(Exception):
+    pass
+
+class SubfileCache:
+    class Subfile:
+        def __init__(self):
+            self.valid = None
+            self.flatness = None
+            self.description = None
+            self.problem = None
+            self.vertices = set()
+    def __init__(self, ldraw_directories):
+        '''
+            Initializes a new subfile cache
+        '''
+        self.cache = dict()
+        self.ldraw_directories = [
+            Path(os.path.expanduser(directory))
+            for directory in ldraw_directories
+        ]
+        self.reference_stack = []
+    def flatness_of(self, filename):
+        '''
+            Returns the set of all directiones the specified file is flat in.
+        '''
+        self.prepare_file(filename)
+        return self.cache[filename].flatness
+    def description_of(self, filename):
+        '''
+            Returns the description of the specified file
+        '''
+        self.prepare_file(filename)
+        return self.cache[filename].description
+    def find_file(self, filename):
+        return find_ldraw_file(
+            filename = filename,
+            libraries = self.ldraw_directories,
+        )
+    def prepare_file(self, filename):
+        '''
+            Loads the file if not loaded yet.
+        '''
+        if filename not in self.cache:
+            self._load_subfile(filename)
+        return self.cache[filename]
+    def _load_subfile(self, filename):
+        # ward against cyclical dependencies
+        if filename in self.reference_stack:
+            raise CyclicalReferenceError(
+                ' -> '.join(self.reference_stack + [filename]),
+            )
+        self.reference_stack.append(filename)
+        subfile = SubfileCache.Subfile()
+        self.cache[filename] = subfile
+        try:
+            path = self.find_file(filename)
+            with path.open() as file:
+                model = parse.read_ldraw(
+                    file,
+                    ldraw_directories = self.ldraw_directories,
+                )
+        except (FileNotFoundError, IOError, PermissionError) as error:
+            subfile.valid = False
+            subfile.problem = str(error)
+        else:
+            if isinstance(model.body[0], linetypes.MetaCommand):
+                subfile.valid = True
+                subfile.description = model.body[0].text
+            else:
+                subfile.valid = False
+                subfile.problem = 'Description not found'
+            if subfile.valid:
+                subfile.vertices = set(parse.model_vertices(model))
+                subfile.flatness = {'x', 'y', 'z'}
+                for vertex in subfile.vertices:
+                    # Use list(subfile.flatness) for iteration because the
+                    # actual set may be modified during the loop
+                    for dimension in list(subfile.flatness):
+                        if not math.isclose(
+                            getattr(vertex, dimension),
+                            0.0,
+                            abs_tol = 1e-05,
+                        ):
+                            subfile.flatness.remove(dimension)
+                    if not subfile.flatness:
+                        break
+                for subfile_reference in model.subfile_references:
+                    # Go through all the subfile references so that we catch any
+                    # cyclical references
+                    self.prepare_file(subfile_reference.subfile_path)
+        self.reference_stack.pop()
+
+if __name__ == '__main__':
+    cache = SubfileCache(ldraw_directories = ["~/ldraw"])
--- a/header.py	Wed May 29 16:36:23 2019 +0300
+++ b/header.py	Thu May 30 12:03:53 2019 +0300
@@ -164,7 +164,7 @@
     def skip_to_next(self, *, spaces_expected = 0):
         while True:
             if self.cursor + 1 >= len(self.model_body):
-                self.parse_error('stub ldraw file')
+                self.parse_error('file does not have a proper header')
             self.cursor += 1
             entry = self.model_body[self.cursor]
             if not is_suitable_header_object(entry):
--- a/ldcheck.py	Wed May 29 16:36:23 2019 +0300
+++ b/ldcheck.py	Thu May 30 12:03:53 2019 +0300
@@ -3,12 +3,12 @@
 if version_info < (3, 4):
     raise RuntimeError('Python 3.4 or newer required')
 
-from parse import parse_ldraw_code
 from colours import load_colours
 from geometry import *
 from pathlib import Path
 import linetypes
 import header
+import parse
 
 from os.path import realpath
 script_directory = Path(realpath(__file__)).parent
@@ -25,26 +25,6 @@
     check_library_paths(config)
     return config
 
-def read_ldraw(file, *, name = '', config):
-    model_body = [
-        parse_ldraw_code(line)
-        for line in file
-    ]
-    headerparser = header.HeaderParser()
-    try:
-        header_parse_result = headerparser.parse(model_body)
-        header_object = header_parse_result['header']
-        end = header_parse_result['end-index']
-    except header.HeaderError as error:
-        header_object = header.BadHeader(error.index, error.reason)
-        end = 0
-    model = Model(
-        header = header_object,
-        body = model_body,
-        header_size = end)
-    model.name = name
-    return model
-
 def library_paths(config):
     for library_path_string in config['libraries']:
         yield Path(library_path_string).expanduser()
@@ -82,33 +62,6 @@
             if (library_path / path).is_file()
         ]
 
-class Model:
-    def __init__(self, header, body, *, header_size = 0):
-        self.header = header
-        self.body = body
-        self.header_size = header_size
-    def filter_by_type(self, type):
-        yield from [
-            element
-            for element in self.body
-            if isinstance(element, type)
-        ]
-    @property
-    def subfile_references(self):
-        yield from self.filter_by_type(linetypes.SubfileReference)
-    @property
-    def line_segments(self):
-        yield from self.filter_by_type(linetypes.LineSegment)
-    @property
-    def triangles(self):
-        yield from self.filter_by_type(linetypes.Triangle)
-    @property
-    def quadrilaterals(self):
-        yield from self.filter_by_type(linetypes.Quadrilateral)
-    @property
-    def has_header(self):
-        return self.header and not isinstance(self.header, header.BadHeader)
-
 import argparse
 class ListTestSuiteAction(argparse.Action):
     def __init__(self, option_strings, dest, nargs = None, **kwargs):
@@ -134,30 +87,47 @@
     )
     parser.add_argument('--dump-structure', action = 'store_true')
     parser.add_argument('--rebuild', action = 'store_true')
+    parser.add_argument('--flatness', action = 'store_true')
     args = parser.parse_args()
     config = load_config('ldcheck.cfg')
     for ldconfig_ldr_path in find_ldconfig_ldr_paths(config):
         with ldconfig_ldr_path.open() as ldconfig_ldr:
             load_colours(ldconfig_ldr)
-    with open(args.filename) as file:
-        from os.path import basename
-        model = read_ldraw(
-            file,
-            name = basename(args.filename),
-            config = config,
+    if args.flatness:
+        import filecache
+        cache = filecache.SubfileCache(
+            ldraw_directories = config['libraries'],
         )
-        if args.dump_structure:
-            print('header: ' + type(model.header).__name__)
-            for key in sorted(dir(model.header)):
-                if not key.startswith('__'):
-                    print('\t' + key + ': ' + repr(getattr(model.header, key)))
-            for entry in model.body:
-                print(entry)
-        elif args.rebuild:
-            for entry in model.body:
-                print(entry.textual_representation(), end = '\r\n')
+        subfile = cache.prepare_file(args.filename)
+        if not subfile.valid:
+            print(subfile.problem)
         else:
-            from testsuite import load_tests, check_model, format_report
-            test_suite = load_tests()
-            report = check_model(model, test_suite)
-            print(format_report(report, model, test_suite))
+            if subfile.flatness:
+                print(str.format(
+                    'Flatness: {}',
+                    ', '.join(subfile.flatness),
+                ))
+            else:
+                print('File is not flat in any dimensions')
+    else:
+        with open(args.filename) as file:
+            from os.path import basename
+            model = parse.read_ldraw(
+                file,
+                name = basename(args.filename),
+                ldraw_directories = config['libraries'])
+            if args.dump_structure:
+                print('header: ' + type(model.header).__name__)
+                for key in sorted(dir(model.header)):
+                    if not key.startswith('__'):
+                        print('\t' + key + ': ' + repr(getattr(model.header, key)))
+                for entry in model.body:
+                    print(entry)
+            elif args.rebuild:
+                for entry in model.body:
+                    print(entry.textual_representation(), end = '\r\n')
+            else:
+                from testsuite import load_tests, check_model, format_report
+                test_suite = load_tests()
+                report = check_model(model, test_suite)
+                print(format_report(report, model, test_suite))
--- a/parse.py	Wed May 29 16:36:23 2019 +0300
+++ b/parse.py	Thu May 30 12:03:53 2019 +0300
@@ -3,10 +3,84 @@
 from geometry import *
 from colours import Colour
 from testsuite import error
+import header
 
 class BadLdrawLine(Exception):
     pass
 
+class Model:
+    def __init__(self, header, body, *, ldraw_directories, header_size = 0):
+        self.header = header
+        self.body = body
+        self.header_size = header_size
+        self.ldraw_directories = ldraw_directories
+    def filter_by_type(self, type):
+        yield from [
+            element
+            for element in self.body
+            if isinstance(element, type)
+        ]
+    @property
+    def subfile_references(self):
+        yield from self.filter_by_type(linetypes.SubfileReference)
+    @property
+    def line_segments(self):
+        yield from self.filter_by_type(linetypes.LineSegment)
+    @property
+    def triangles(self):
+        yield from self.filter_by_type(linetypes.Triangle)
+    @property
+    def quadrilaterals(self):
+        yield from self.filter_by_type(linetypes.Quadrilateral)
+    @property
+    def has_header(self):
+        return self.header and not isinstance(self.header, header.BadHeader)
+
+def model_vertices(
+    model,
+    transformation_matrix = None,
+    file_cache = None,
+):
+    if transformation_matrix is None:
+        transformation_matrix = complete_matrix(Matrix3x3(), Vertex(0, 0, 0))
+    if file_cache is None:
+        import filecache
+        file_cache = filecache.SubfileCache(model.ldraw_directories)
+    for element in model.body:
+        if isinstance(element, linetypes.BasePolygon):
+            for point in element.geometry.vertices:
+                yield point @ transformation_matrix
+        if isinstance(element, linetypes.ConditionalLine):
+            for point in element.control_points:
+                yield point @ transformation_matrix
+        if isinstance(element, linetypes.SubfileReference):
+            subfile = file_cache.prepare_file(element.subfile_path)
+            for point in subfile.vertices:
+                matrix_4x4 = complete_matrix(element.matrix, element.anchor)
+                point @= matrix_4x4
+                yield point @ transformation_matrix
+
+def read_ldraw(file, *, ldraw_directories, name = ''):
+    model_body = [
+        parse_ldraw_code(line)
+        for line in file
+    ]
+    headerparser = header.HeaderParser()
+    try:
+        header_parse_result = headerparser.parse(model_body)
+        header_object = header_parse_result['header']
+        end = header_parse_result['end-index']
+    except header.HeaderError as error:
+        header_object = header.BadHeader(error.index, error.reason)
+        end = 0
+    model = Model(
+        header = header_object,
+        body = model_body,
+        header_size = end,
+        ldraw_directories = ldraw_directories)
+    model.name = name
+    return model
+
 def parse_ldraw_code(line):
     try:
         if isinstance(line, bytes):
--- a/tests/subfiles.py	Wed May 29 16:36:23 2019 +0300
+++ b/tests/subfiles.py	Thu May 30 12:03:53 2019 +0300
@@ -1,8 +1,10 @@
 from testsuite import warning, error
+import testsuite
 from geometry import *
 from os.path import dirname
 from pathlib import Path
 from configparser import ConfigParser
+import math
 ini_path = Path(dirname(__file__)) / 'library-standards.ini'
 library_standards = ConfigParser()
 
@@ -83,10 +85,72 @@
                 axes = interesting_axes,
                 scaling = scaling)
 
+def dependent_subfile_tests(model):
+    '''
+        Tests subfile references for such qualities that are dependent on the
+        actual contents of the subfiles. Checks whether moved-to files are used.
+        Checks whether flat subfiles are scaled in the flat direction.
+    '''
+    import filecache
+    cache = filecache.SubfileCache(model.ldraw_directories)
+    failed_subfiles = set()
+    for subfile_reference in model.subfile_references:
+        path = subfile_reference.subfile_path.lower()
+        if path in failed_subfiles:
+            # Already proven to be a bad apple, don't complain twice
+            pass
+        else:
+            try:
+                subfile = cache.prepare_file(path)
+            except filecache.CyclicalReferenceError as e:
+                failed_subfiles.add(path)
+                yield error(subfile_reference, 'cyclical-reference',
+                    chain = str(e),
+                )
+            if not subfile.valid:
+                yield error(subfile_reference, 'bad-subfile',
+                    path = path,
+                    problem_text = subfile.problem,
+                )
+                failed_subfiles.add(path)
+            import re
+            match = re.search(r'^\~Moved(?: to (\w+))?$', subfile.description)
+            if match:
+                yield error(subfile_reference, 'moved-file-used',
+                    moved_file = path,
+                    new_file = match.group(1))
+            scaling_vector = subfile_reference.matrix.scaling_vector()
+            scaled_dimensions = {
+                dimension
+                for dimension in subfile.flatness
+                if not math.isclose(
+                    getattr(scaling_vector, dimension),
+                    1,
+                    abs_tol = 1.0e-05
+                )
+            }
+            scaled_flat_dimensions = subfile.flatness & scaled_dimensions
+            if scaled_flat_dimensions:
+                yield testsuite.notice(subfile_reference, 'unnecessary-scaling',
+                    scaled_flat_dimensions = scaled_flat_dimensions,
+                    scaling_vector = scaling_vector,
+                )
+
+def dimensions_description(dimensions):
+    sorted_dims = sorted(dimensions)
+    if len(sorted_dims) == 1:
+        return sorted_dims[0] + ' dimension'
+    else:
+        return str.format('{} and {} dimensions',
+            ', '.join(sorted_dims[:-1]),
+            sorted_dims[-1],
+        )
+
 manifest = {
     'tests': {
         'determinant': determinant_test,
         'scaling-legality': scaling_legality_test,
+        'dependent-subfiles': dependent_subfile_tests,
     },
     'messages': {
         'zero-determinant': 'matrix determinant is zero '
@@ -96,5 +160,23 @@
                 primitive,
                 scaling_description(scaling, axes),
             ),
+        'cyclical-reference': lambda chain:
+            str.format('cyclical subfile dependency: {chain}',
+                **locals(),
+            ),
+        'bad-subfile': lambda path, problem_text:
+            str.format('cannot process subfile "{path}": {problem_text}',
+                **locals(),
+            ),
+        'moved-file-used': lambda moved_file, new_file:
+            str.format('subfile "{moved_file}" has been moved to "{new_file}"',
+                **locals(),
+            ),
+        'unnecessary-scaling': lambda scaled_flat_dimensions, scaling_vector:
+            str.format(
+                'subfile unnecessarily scaled in the {dims} ({scaling})',
+                dims = dimensions_description(scaled_flat_dimensions),
+                scaling = scaling_description(scaling_vector),
+            )
     },
 }

mercurial