tobspr/RenderPipeline

View on GitHub
rpcore/util/ies_profile_loader.py

Summary

Maintainability
B
5 hrs
Test Coverage
"""

RenderPipeline

Copyright (c) 2014-2016 tobspr <tobias.springer1@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

"""

# Disable the unused variable warning - this occurs since we read a lot more
# properties from the IES file than we actually need
# pylint: disable=unused-variable

from __future__ import print_function

import re

from panda3d.core import PTAFloat, Filename, SamplerState, VirtualFileSystem
from panda3d.core import get_model_path
from direct.stdpy.file import open, join, isfile

from rplibs.six.moves import range  # pylint: disable=import-error

from rpcore.native import IESDataset
from rpcore.image import Image
from rpcore.rpobject import RPObject


class InvalidIESProfileException(Exception):
    """ Exception which is thrown when an error occurs during loading an IES
    Profile """


class IESProfileLoader(RPObject):

    """ Loader class to load .IES files and create an IESDataset from it.
    It generates a LUT for each loaded ies profile which is used by the lighting
    pipeline later on. """

    # Supported IES Profiles
    PROFILES = [
        "IESNA:LM-63-1986",
        "IESNA:LM-63-1991",
        "IESNA91",
        "IESNA:LM-63-1995",
        "IESNA:LM-63-2002",
        "ERCO Leuchten GmbH  BY: ERCO/LUM650/8701",
        "ERCO Leuchten GmbH"
    ]

    # Regexp for extracting keywords
    KEYWORD_REGEX = re.compile(r"\[([A-Za-z0-8_-]+)\](.*)")

    def __init__(self, pipeline):
        RPObject.__init__(self)
        self._pipeline = pipeline
        self._entries = []
        self._max_entries = 32
        self._create_storage()

    def _create_storage(self):
        """ Internal method to create the storage for the profile dataset textures """
        self._storage_tex = Image.create_3d("IESDatasets", 512, 512, self._max_entries, "R16")
        self._storage_tex.set_minfilter(SamplerState.FT_linear)
        self._storage_tex.set_magfilter(SamplerState.FT_linear)
        self._storage_tex.set_wrap_u(SamplerState.WM_clamp)
        self._storage_tex.set_wrap_v(SamplerState.WM_repeat)
        self._storage_tex.set_wrap_w(SamplerState.WM_clamp)

        self._pipeline.stage_mgr.inputs["IESDatasetTex"] = self._storage_tex
        self._pipeline.stage_mgr.defines["MAX_IES_PROFILES"] = self._max_entries

    def load(self, filename):
        """ Loads a profile from a given filename and returns the internal
        used index which can be assigned to a light."""

        # Make sure the user can load profiles directly from the ies profile folder
        data_path = join("/$$rp/data/ies_profiles/", filename)
        if isfile(data_path):
            filename = data_path

        # Make filename unique
        fname = Filename.from_os_specific(filename)
        if not VirtualFileSystem.get_global_ptr().resolve_filename(
                fname, get_model_path().get_value(), "ies"):
            self.error("Could not resolve", filename)
            return -1
        fname = fname.get_fullpath()

        # Check for cache entries
        if fname in self._entries:
            return self._entries.index(fname)

        # Check for out of bounds
        if len(self._entries) >= self._max_entries:
            # TODO: Could remove unused profiles here or regenerate texture
            self.warn("Cannot load IES Profile, too many loaded! (Maximum: 32)")

        # Try loading the dataset, and see what happes
        try:
            dataset = self._load_and_parse_file(fname)
        except InvalidIESProfileException as msg:
            self.warn("Failed to load profile from", filename, ":", msg)
            return -1

        if not dataset:
            return -1

        # Dataset was loaded successfully, now copy it
        dataset.generate_dataset_texture_into(self._storage_tex, len(self._entries))
        self._entries.append(fname)

        return len(self._entries) - 1

    def _load_and_parse_file(self, pth):
        """ Loads a .IES file from a given filename, returns an IESDataset
        which is used by the load function later on. """
        self.debug("Loading ies profile from", pth)

        try:
            with open(pth, "r") as handle:
                lines = handle.readlines()
        except IOError as msg:
            self.error("Failed to open", pth, ":", msg)
            return None

        lines = [i.strip() for i in lines]

        # Parse version header
        self._check_version_header(lines.pop(0))

        # Parse arbitrary amount of keywords
        keywords = self._extract_keywords(lines)  # noqa

        # Next line should be TILT=NONE according to the spec
        if lines.pop(0) != "TILT=NONE":
            raise InvalidIESProfileException("Expected TILT=NONE line, but none found!")

        # From now on, lines do not matter anymore, instead everything is
        # space seperated
        new_parts = (' '.join(lines)).replace(",", " ").split()

        def read_int():
            return int(new_parts.pop(0))

        def read_float():
            return float(new_parts.pop(0))

        # Amount of Lamps
        if read_int() != 1:
            raise InvalidIESProfileException("Only 1 Lamp supported!")

        # Extract various properties
        lumen_per_lamp = read_float()  # noqa
        candela_multiplier = read_float()  # noqa
        num_vertical_angles = read_int()
        num_horizontal_angles = read_int()

        if num_vertical_angles < 1 or num_horizontal_angles < 1:
            raise InvalidIESProfileException("Invalid of vertical/horizontal angles!")

        photometric_type = read_int()  # noqa
        unit_type = read_int()

        # Check for a correct unit type, should be 1 for meters and 2 for feet
        if unit_type not in [1, 2]:
            raise InvalidIESProfileException("Invalid unit type")

        width = read_float()  # noqa
        length = read_float()  # noqa
        height = read_float()  # noqa
        ballast_factor = read_float()  # noqa
        future_use = read_float()  # Unused field for future usage  # noqa
        input_watts = read_float()  # noqa

        # Read vertical angles
        vertical_angles = [read_float() for i in range(num_vertical_angles)]
        horizontal_angles = [read_float() for i in range(num_horizontal_angles)]

        candela_values = []
        candela_scale = 0.0

        for i in range(num_horizontal_angles):
            vertical_data = [read_float() for i in range(num_vertical_angles)]
            candela_scale = max(candela_scale, max(vertical_data))
            candela_values += vertical_data

        # Rescale values, divide by maximum
        candela_values = [i / candela_scale for i in candela_values]

        if len(new_parts) != 0:
            self.warn("Unhandled data at file-end left:", new_parts)

            # Dont abort here, some formats like those from ERCO Leuchten GmbH
            # have an END keyword, just ignore everything after the data was
            # read in.

        dataset = IESDataset()
        dataset.set_vertical_angles(self._list_to_pta(vertical_angles))
        dataset.set_horizontal_angles(self._list_to_pta(horizontal_angles))
        dataset.set_candela_values(self._list_to_pta(candela_values))

        # Testing code to write out the LUT
        # from panda3d.core import Texture
        # tex = Texture("temp")
        # tex.setup_3d_texture(512, 512, 1, Image.T_float, Image.F_r16)
        # dataset.generate_dataset_texture_into(tex, 0)
        # tex.write("generated.png")

        return dataset

    def _list_to_pta(self, list_values):
        """ Converts a list to a PTAFloat """
        pta = PTAFloat.empty_array(len(list_values))
        for i, val in enumerate(list_values):
            pta[i] = val
        return pta

    def _check_version_header(self, first_line):
        """ Checks if the IES version header is correct and the specified IES
        version is supported """
        if first_line not in self.PROFILES:
            raise InvalidIESProfileException("Unsupported Profile: " + first_line)

    def _extract_keywords(self, lines):
        """ Extracts the keywords from a list of lines, and removes all lines
        containing keywords """
        keywords = {}
        while lines:
            line = lines.pop(0)
            if not line.startswith("["):

                # Special format used by some IES files, indicates end of properties
                # By just checking for the tilt keyword instead of validating each line,
                # we can read even malformed lines, like those from ERCO Leuchten GmbH
                if line != "TILT=NONE":
                    continue

                # No keyword, we popped the line already tho, so append it again
                lines.insert(0, line)
                return keywords
            else:

                # Try matching the keywords
                match = self.KEYWORD_REGEX.match(line)
                if match:
                    key, val = match.group(1, 2)
                    keywords[key.strip()] = val.strip()
                else:
                    raise InvalidIESProfileException("Invalid keyword line: " + line)

        return keywords