tests/subfiles.py

Mon, 24 Jun 2019 00:51:04 +0300

author
Teemu Piippo <teemu@hecknology.net>
date
Mon, 24 Jun 2019 00:51:04 +0300
changeset 83
bd840d5dc8d8
parent 82
75b5241a35ec
child 85
4438502fd3e0
permissions
-rw-r--r--

added a test for mirrored studs

from testsuite import problem_type, report_problem
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()

with ini_path.open() as file:
    library_standards.read_file(file)

@problem_type('zero-determinant',
    severity = 'hold',
    message = 'matrix row or column all zero'
)
def determinant_test(model):
    '''
        Checks all subfile references for matrices with rows or columns all
        zero.
    '''
    yield from (
        report_problem('zero-determinant', bad_object = subfile_reference)
        for subfile_reference in model.subfile_references
        if abs(subfile_reference.matrix.determinant() - 0) < 1e-15
    )

def scaling_description(scaling, axes = 'xyz'):
    '''
        Returns a pretty description of a scaling vector. The axes parameter
        controls what axes are printed and can be used to filter away
        uninteresting values.
    '''
    if isinstance(scaling, str):
        return scaling
    else:
        return ', '.join(
            str.format('{} = {}', letter, getattr(scaling, letter))
            for letter in axes
        )

def check_scaling(scaling, axes):
    ''' Returns whether all given axes on the given scaling vector are 1. '''
    return all(
        abs(getattr(scaling, axis) - 1) < 1e-5
        for axis in axes
    )

# Restriction to checking function mapping.
restriction_tests = {
    'no scaling': lambda scaling: check_scaling(scaling, 'xyz'),
    'y-scaling only': lambda scaling: check_scaling(scaling, 'xz'),
    'stud3-like scaling': lambda scaling: all([
        check_scaling(scaling, 'xz'),
        # check if y-scaling is 1 or -1
        abs(abs(scaling.y) - 1) < 1e-5,
    ]),
}

@problem_type('illegal-scaling',
    severity = 'hold',
    message = lambda primitive, scaling, axes:
        str.format('scaling of unscalable primitive {} ({})',
            primitive,
            scaling_description(scaling, axes),
        ),
)
def scaling_legality_test(model):
    '''
        Checks the part against primitive references with bad scaling. Some
        primitives (e.g. pegs) are not allowed to be scaled in the
        X or Z directions. Some (e.g. most studs) are not allowed to be scaled
        in the Y direction either.
    '''
    from fnmatch import fnmatch
    scaling_restrictions = library_standards['scaling restrictions']
    for subfile_reference in model.subfile_references:
        primitive = subfile_reference.subfile_path.lower()
        scaling = subfile_reference.matrix.scaling_vector()
        # Find all restrictions that apply to this subfile reference.
        restrictions = {
            restriction
            for pattern, restriction in scaling_restrictions.items()
            if fnmatch(primitive, pattern)
        }
        # Check restrictions against the subfile. If any restrictions were
        # found then the scaling vector must pass at least one of them.
        if restrictions and not any(
            restriction_tests[restriction](scaling)
            for restriction in restrictions
        ):
            interesting_axes = ''.join(
                axis
                for axis in 'xyz'
                if abs(getattr(scaling, axis) - 1) > 1e-5
            )
            yield report_problem('illegal-scaling',
                bad_object = subfile_reference,
                primitive = primitive,
                axes = interesting_axes,
                scaling = scaling,
            )

def subfile_references_with_invertnext(model):
    import linetypes
    has_invertnext = False
    for element in model.body:
        if isinstance(element, linetypes.MetaCommand) \
        and element.text == 'BFC INVERTNEXT':
            has_invertnext = True
        else:
            if isinstance(element, linetypes.SubfileReference):
                yield element, has_invertnext
            has_invertnext = False

@problem_type('cyclical-reference',
    severity = 'hold',
    message = lambda chain:
        str.format('cyclical subfile dependency: {chain}',
            **locals(),
        ),
)
@problem_type('bad-subfile',
    severity = 'hold',
    message = lambda path, problem_text:
        str.format('cannot process subfile "{path}": {problem_text}',
            **locals(),
        ),
)
@problem_type('moved-file-used',
    severity = 'hold',
    message = lambda moved_file, new_file:
        str.format('subfile "{moved_file}" has been moved to "{new_file}"',
            **locals(),
        ),
)
@problem_type('unnecessary-scaling',
    severity = 'warning',
    message = 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),
        ),
)
@problem_type('unnecessary-invertnext',
    severity = 'warning',
    message = 'flat subfile unnecessarily inverted using BFC INVERTNEXT'
)
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, has_invertnext in subfile_references_with_invertnext(model):
        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 report_problem(
                    'cyclical-reference',
                    bad_object = subfile_reference,
                    chain = str(e),
                )
            if not subfile.valid:
                yield report_problem(
                    'bad-subfile',
                    bad_object = subfile_reference,
                    path = path,
                    problem_text = subfile.problem,
                )
                failed_subfiles.add(path)
            else:
                # Test for use of moved-to files
                import re
                match = re.search(r'^\~Moved(?: to (\w+))?$', subfile.description)
                if match:
                    yield report_problem(
                        'moved-file-used',
                        bad_object = subfile_reference,
                        moved_file = path,
                        new_file = match.group(1),
                    )
                # Test for scaling in flat direction
                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 report_problem(
                        'unnecessary-scaling',
                        bad_object = subfile_reference,
                        scaled_flat_dimensions = scaled_flat_dimensions,
                        scaling_vector = scaling_vector,
                    )
                # Test whether a flat subfile is inverted using invertnext
                if has_invertnext and subfile.flatness:
                    yield report_problem(
                        'unnecessary-invertnext',
                        bad_object = subfile_reference,
                    )

def dimensions_description(dimensions):
    if isinstance(dimensions, str):
        return dimensions
    else:
        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],
            )

@problem_type('bad-category',
    severity = 'hold',
    message = lambda category: str.format(
        '"{category}" is not an official category',
        category = category,
    )
)
@problem_type('bad-category-in-description',
    severity = 'hold',
    message = lambda category: str.format(
        'the category "{category}" must be set using !CATEGORY '
        'and not by description',
        category = category,
    )
)
def category_test(model):
    if model.header.valid:
        categories = library_standards['categories']
        illegal_categories_in_description = [
            category_name.lower()
            for category_name in categories.keys()
            if ' ' in category_name
        ]
        has_bad_category = False
        if model.header.effective_category not in categories.keys():
            try:
                bad_object = model.find_first_header_object('category')
            except KeyError:
                # category was not specified using !CATEGORY, blame
                # the description instead
                bad_object = model.body[0]
            has_bad_category = True
            yield report_problem(
                'bad-category',
                bad_object = bad_object,
                category = model.header.effective_category,
            )
        # Check if the description sets a multi-word category
        if not has_bad_category and model.header.category is None:
            for category_name in illegal_categories_in_description:
                if model.header.description.lower().startswith(category_name):
                    yield report_problem(
                        'bad-category-in-description',
                        bad_object = model.body[0],
                        category = category_name.title(),
                    )
                    break

@problem_type('mirrored-studs',
    severity = 'warning',
    message = lambda primitive: str.format(
        '"{primitive}" should not be mirrored',
        primitive = primitive,
    )
)
def mirrored_studs_test(model):
    for subfile_reference in model.subfile_references:
        # Test whether any stud subfile is mirrored.
        # A subfile is mirrored if its determinant is negative.
        if subfile_reference.subfile_path.startswith('stu') \
        and subfile_reference.subfile_path != 'stud4.dat' \
        and subfile_reference.matrix.determinant() < 0:
            yield report_problem(
                'mirrored-studs',
                bad_object = subfile_reference,
                primitive = subfile_reference.subfile_path,
            )

manifest = {
    'tests': [
        determinant_test,
        scaling_legality_test,
        dependent_subfile_tests,
        category_test,
        mirrored_studs_test,
    ],
}

mercurial