Thu, 30 May 2019 12:03:53 +0300
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), + ) }, }