rpcore/util/ies_profile_loader.py
"""
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