# vim: set et sw=4 sts=4 fileencoding=utf-8:
#
# An alternate Python Minecraft library for the Rasperry-Pi
# Copyright (c) 2013-2015 Dave Jones <dave@waveform.org.uk>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the copyright holder nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""
The render module defines a series of classes for interpreting and rendering
models in the `Wavefront object format`_.
.. note::
All items in this module are available from the :mod:`picraft` namespace
without having to import :mod:`picraft.render` directly.
.. _Wavefront object format: http://paulbourke.net/dataformats/obj/
The following items are defined in the module:
Model
=====
.. autoclass:: Model
:members:
ModelFace
=========
.. autoclass:: ModelFace
:members:
"""
from __future__ import (
unicode_literals,
absolute_import,
print_function,
division,
)
str = type('')
import io
import warnings
from collections import namedtuple, defaultdict
from itertools import chain
from .vector import Vector, vector_range, filled, lines
from .block import Block
from .exc import (
UnsupportedCommand,
NegativeWeight,
)
COMMANDS = {
'v', # geometric vertices
'vt', # texture vertices
'vn', # normal vertices
'vp', # parameter space vertices
'cstype', # rational or non-rational curve or surface type
'deg', # degree
'bmat', # basis matrix
'step', # step size
'p', # point
'l', # line
'f', # face
'curv', # curve
'curv2', # 2D curve
'surf', # surface
'parm', # parameter values
'trim', # outer trimming loop
'hole', # inner trimming loop
'scrv', # special curve
'sp', # special point
'end', # end statement
'con', # connect
'g', # group
's', # smoothing group
'mg', # merging group
'o', # object name
'bevel', # bevel interpolation
'c_interp', # color interpolation
'd_interp', # dissolve interpolation
'lod', # level of detail
'usemtl', # material name
'mtllib', # material library
'shadow_obj', # shadow casting
'trace_obj', # ray tracing
'ctech', # curve approximation technique
'stech', # surface approximation technique
'call', # file inclusion
'csh', # shell execution
# Obsolete
'res', # set number of segments in patches
'bzp', # bezier patch
'bsp', # b-spline patch
'cdc', # cardinal curve
'cdp', # cardinal patch
}
IGNORED = COMMANDS - {'v', 'vn', 'vt', 'vp', 'f', 'g', 'usemtl'}
class Vertex(namedtuple('Vertex', ('x', 'y', 'z', 'w'))):
"""
Represents a geometric vertex in a Wavefront obj file. The w component is
optional and defaults to 1.0.
"""
__slots__ = ()
def __new__(cls, x, y, z, w=1.0):
x = float(x)
y = float(y)
z = float(z)
w = float(w)
if w <= 0.0:
warnings.warn(NegativeWeight('negative or zero weight: %f'))
return super(Vertex, cls).__new__(cls, x, y, z, w)
@property
def __dict__(self):
return super(Vertex, self).__dict__
class VertexParameter(namedtuple('VertexParameter', ('u', 'v', 'w'))):
"""
Represents a point in the parameter space of a curve or surface. The w
component is optional and defaults to 1.0.
"""
__slots__ = ()
def __new__(cls, u, v=0.0, w=1.0):
u = float(u)
v = float(v)
w = float(w)
return super(VertexParameter, cls).__new__(cls, u, v, w)
@property
def __dict__(self):
return super(VertexParameter, self).__dict__
class VertexNormal(namedtuple('VertexNormal', ('i', 'j', 'k'))):
"""
Represents a normal vector with components i, j, and k.
"""
__slots__ = ()
def __new__(cls, i, j, k):
i = float(i)
j = float(j)
k = float(k)
return super(VertexNormal, cls).__new__(cls, i, j, k)
@property
def __dict__(self):
return super(VertexNormal, self).__dict__
class VertexTexture(namedtuple('VertexTexture', ('u', 'v', 'w'))):
"""
Represents a texture vertex and its coordinates. The v and w components
are optional and default to 0.0.
"""
__slots__ = ()
def __new__(cls, u, v=0.0, w=0.0):
u = float(u)
v = float(v)
w = float(w)
return super(VertexTexture, cls).__new__(cls, u, v, w)
@property
def __dict__(self):
return super(VertexTexture, self).__dict__
class FaceIndex(namedtuple('FaceIndex', ('v', 'vt', 'vn'))):
"""
Represents a vertex, optional texture, and optional normal reference in
a face statement.
"""
__slots__ = ()
def __new__(cls, v, vt=None, vn=None):
v = int(v)
vt = vt if vt is None else int(vt)
vn = vn if vn is None else int(vn)
return super(FaceIndex, cls).__new__(cls, v, vt, vn)
@classmethod
def from_string(cls, s):
s = s.split('/')
v = s[0]
vt = s[1] if len(s) > 1 else None
vn = s[2] if len(s) > 2 else None
if len(s) > 3:
raise ValueError('too many values in face index')
return cls.__new__(cls, v, vt, vn)
@property
def __dict__(self):
return super(FaceIndex, self).__dict__
class FaceIndexes(object):
"""
Represents a face containing an arbitrary number of vertices (>3). A
:meth:`resolve` method is provided to permit resolution of negative indices
but otherwise the resulting object is effectively an immutable list.
"""
__slots__ = ('_items', '_material', '_groups')
def __init__(self, *indexes):
if len(indexes) < 3:
raise ValueError('insufficient number of vertixes for face')
self._items = [FaceIndex.from_string(i) for i in indexes]
def __repr__(self):
return '<FaceIndexes %d vertexes>' % len(self)
def __len__(self):
return len(self._items)
def __iter__(self):
return iter(self._items)
def __getitem__(self, index):
return self._items[index]
class Group(object):
"""
Represents a group, or groups as a list of :attr:`names`. Any number of
groups may be "active" at a time, and if no group names are specified,
"default" is used.
"""
__slots__ = ('_names')
def __init__(self, *names):
if not names:
names = ['default']
self._names = frozenset(names)
def __repr__(self):
return '<Group %s>' % ', '.join(repr(n) for n in self._names)
@property
def names(self):
return self._names
class Material(str):
"""
Represents a material (or more specifically a command to switch material).
This is simply a derivative of Python's built-in :class:`str`.
"""
def __new__(cls, *args):
if len(args) == 0:
raise ValueError('missing material name')
return super(Material, cls).__new__(cls, args[0])
def __repr__(self):
return '<Material %s>' % super(Material, self).__repr__()
class Parser(object):
"""
Parser for the Alias|Wavefront Obj format. Based partially on the
`specification`_ published in Appendix B1 of the Alias|Wavefront manual.
Handles backslash continuation of any lines, ignoring comments and blank
lines, and assumes commands always start in the first column.
The Parser acts as an iterable object (akin to a generator). Constructed
with a filename or file-like object (which must return unicode strings in
Python 3, as opposed to bytes), the instance acts as an iterable yielding
:class:`Vertex`, :class:`VertexParameter`, :class:`VertexNormal`,
:class:`VertexTexture`, :class:`FaceIndex`, :class:`Group`, and
:class:`Material` instances depending on the statement encountered.
ASCII encoding of the source file is assumed (no other character sets are
supported), and all other legitimate commands are recognized but ignored.
Such commands will cause an :exc:`UnsupportedCommand` warning to be raised
but this is ignored by default.
"""
def __init__(self, source):
if isinstance(source, bytes):
source = source.decode('utf-8')
self._opened = isinstance(source, str)
if self._opened:
self._source = io.open(source, 'r', encoding='ascii')
else:
self._source = source
def close(self):
if self._opened:
self._source.close()
self._source = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_tb):
self.close()
def __iter__(self):
compound = []
for line_num, line in enumerate(self._source, start=1):
line = line.rstrip()
if line.endswith('\\'):
compound.append(line[:-1])
else:
compound.append(line)
line = ' '.join(compound)
compound = []
if line and not line.startswith('#'):
try:
command, params = line.split(None, 1)
except ValueError:
command, params = line, ''
params = params.split()
if command in IGNORED:
warnings.warn(UnsupportedCommand(
'line %d: unsupported command %s' % (line_num, command)))
elif not command in COMMANDS:
raise ValueError(
'line %d: unknown command %s' % (line_num, command))
else:
yield {
'v': Vertex,
'vn': VertexNormal,
'vp': VertexParameter,
'vt': VertexTexture,
'f': FaceIndexes,
'g': Group,
'usemtl': Material,
}[command](*params)
[docs]class ModelFace(object):
"""
Represents a face belonging to a :class:`Model`. A face consists of three
or more :attr:`vectors` which are all `coplanar`_ (belonging to the same
two-dimensional plane within the three-dimensional space).
A face also has a :attr:`material`. As Minecraft's rendering is relatively
crude this is simply stored as the name of the material; it is up to the
user to map this to a meaningful block type. Finally each face belongs to
zero or more :attr:`groups` which can be used to distinguish components of
a model from each other.
.. _coplanar: https://en.wikipedia.org/wiki/Coplanarity
"""
def __init__(self, vectors, material, groups):
self._vectors = tuple(vectors)
self._groups = frozenset(groups)
self._material = material
@property
def material(self):
"""
The material assigned to the face. This is simply stored as the name of
the material as it would be ridiculous to even attempt to emulate the
material model of a full ray-tracer as Minecraft blocks.
The :meth:`Model.render` method provides a simple means for mapping a
material name to a block type in Minecraft.
"""
return self._material
@property
def groups(self):
"""
The set of groups that the face belongs to. By default all faces belong
to a :class:`Model`. However, in additionl to this a face can belong to
zero or more "groups" which are effectively components of the model.
This facility can be used to render particular parts of a model.
"""
return self._groups
@property
def vectors(self):
"""
The sequence of vectors that makes up the face. These are assumed to be
`coplanar`_ but this is not explicitly checked. Each point is
represented as a :class:`~picraft.vector.Vector` instance.
.. _coplanar: https://en.wikipedia.org/wiki/Coplanarity
"""
return self._vectors
def __repr__(self):
return '<ModelFace %d points, material="%s", groups=%s>' % (
len(self._vectors), self._material,
'{%s}' % ', '.join('"%s"' % g for g in self._groups))
[docs]class Model(object):
"""
Represents a three-dimensional model parsed from an Alias|Wavefront `object
file`_ (.obj extension). The constructor accepts a *source* parameter which
can be a filename or file-like object (in the latter case, this must be
opened in text mode such that it returns unicode strings rather than bytes
in Python 3).
The optional *swap_yz* parameter specifies whether the Y and Z coordinates
of each vertex in the model will be swapped; some models require this to
render correctly in Minecraft, some do not.
The :attr:`faces` attribute provides access to all object faces extracted
from the file's content. The :attr:`materials` property enumerates all
material names used by the object. The :attr:`groups` mapping maps group
names to subsets of the available faces. The :attr:`bounds` attribute
provides a range describing the bounding box of the unscaled model.
Finally, the :meth:`render` method can be used to easily render the object
in the Minecraft world at the specified scale, and with a given material
mapping.
.. _object file: https://en.wikipedia.org/wiki/Wavefront_.obj_file
"""
def __init__(self, source, swap_yz=False):
self._faces = []
self._materials = set()
self._groups = defaultdict(list)
self._swap_yz = swap_yz
self._parse(source)
def _parse(self, source):
vertexes = []
textures = []
normals = []
active_groups = set()
active_material = None
for i in Parser(source):
if isinstance(i, Vertex):
vertexes.append(i)
elif isinstance(i, VertexTexture):
textures.append(i)
elif isinstance(i, VertexNormal):
normals.append(i)
elif isinstance(i, Group):
active_groups = i.names
elif isinstance(i, Material):
self._materials.add(i)
active_material = i
elif isinstance(i, FaceIndexes):
if active_material is None:
self._materials.add(None)
vectors = [
Vector(v.x, v.z, v.y) if self._swap_yz else
Vector(v.x, v.y, v.z)
for vi in i
for v in (vertexes[vi.v - 1 if vi.v > 0 else len(vertexes) + vi.v],)
]
face = ModelFace(vectors, active_material, active_groups)
self._faces.append(face)
for group in active_groups:
self._groups[group].append(face)
@property
def faces(self):
"""
Returns the sequence of faces that make up the model. Each instance of
this sequence is a :class:`ModelFace` instance which provides details
of the coordinates of the face vertices, the face material, etc.
"""
return self._faces
@property
def materials(self):
"""
Returns the set of materials used by the model. This is derived from
the :class:`~ModelFace.material` assigned to each face of the model.
"""
return self._materials
@property
def groups(self):
"""
A mapping of group names to sequences of :class:`ModelFace` instances.
This can be used to extract a component of the model for further
processing or rendering.
"""
return self._groups
@property
def bounds(self):
"""
Returns a vector range which completely encompasses the model at scale
1.0. This can be useful for determining scaling factors when rendering.
.. note::
The bounding box returned is `axis-aligned`_ and is not guaranteed
to be the minimal bounding box for the model.
.. _axis-aligned: https://en.wikipedia.org/wiki/Minimum_bounding_box#Axis-aligned_minimum_bounding_box
"""
min_v = Vector(
min(v.x for f in self.faces for v in f.vectors),
min(v.y for f in self.faces for v in f.vectors),
min(v.z for f in self.faces for v in f.vectors),
).floor()
max_v = Vector(
max(v.x for f in self.faces for v in f.vectors),
max(v.y for f in self.faces for v in f.vectors),
max(v.z for f in self.faces for v in f.vectors),
).floor()
return vector_range(min_v, max_v + 1)
[docs] def render(self, scale=1.0, materials=None, groups=None):
"""
Renders the model as a :class:`dict` mapping vectors to block types.
Effectively this rounds the vertices of each face to integers (after
applying the *scale* multiplier, which defaults to 1.0), then calls
:func:`~picraft.vector.filled` and :func:`~picraft.vector.lines` to
obtain the complete coordinates of each face.
Each coordinate then needs to be mapped to a block type. By default
the material name is simply passed to the :class:`~picraft.block.Block`
constructor. This assumes that material names are valid Minecraft
block types (see :attr:`~picraft.block.Block.NAMES`).
You can override this mechanism with the *materials* parameter. This
can be set to a mapping (e.g. a :class:`dict`) which maps material
names to :class:`~picraft.block.Block` instances. For example::
from picraft import Model, Block
m = Model('airboat.obj')
d = m.render(materials={
'bluteal': Block('diamond_block'),
'bronze': Block('gold_block'),
'dkdkgrey': Block((64, 64, 64)),
'dkteal': Block('#000080'),
'red': Block('#ff0000'),
'silver': Block.from_color('#ffffff'),
'black': Block(id=35, data=15),
None: Block('stone'),
})
.. note::
Some object files may include faces with no associated material.
In this case you will need to map ``None`` to a block type, as in
the example above.
Alternatively, *materials* can be a callable which will be called with
the :class:`ModelFace` being rendered, which should return a block
type. The following is equivalent to the default behaviour::
from picraft import Model, Block
m = Model('airboat.obj')
d = m.render(materials=lambda f: Block(f.material))
If you simply want to preview a shape without bothering with any
material mapping you can use this method to map any face to a single
material (in this case stone)::
from picraft import Model, Block
m = Model('airboat.obj')
d = m.render(materials=lambda f: Block('stone'))
If the *materials* mapping or callable returns ``None`` instead of a
:class:`~picraft.block.Block` instance, the corresponding blocks will
not be included in the result. This is a simple mechanism for excluding
parts of a model. The other mechanism for achieving this is the
*groups* parameter which specifies which sub-components of the model
should be rendered. This can be specified as a string (indicating that
only that sub-component should be rendered) or as a sequence of strings
(indicating that all specified sub-components should be rendered).
The result is a mapping of :class:`~picraft.vector.Vector` to
:class:`~picraft.block.Block` instances. Rendering the result in the
main world should be as trivial as the following code::
from picraft import World, Model
w = World()
m = Model('airboat.obj').render(scale=2.0)
with w.connection.batch_start():
for v, b in m.items():
w.blocks[v] = b
Of course, you may choose to further transform the result first. This
can be accomplished by modifying the vectors as you set them::
from picraft import World, Model, Y
w = World()
m = Model('airboat.obj').render(scale=2.0)
with w.connection.batch_start():
for v, b in m.items():
w.blocks[v + 10*Y] = b
Alternatively you may choose to use a dict-comprehension::
from picraft import Model, Vector
m = Model('airboat.obj').render(scale=2.0)
offset = Vector(y=10)
m = {v + offset: b for v, b in m.items()}
Note that the Alias|Wavefront `object file`_ format is a relatively
simple text based format that can be constructed by hand without
too much difficulty. Combined with the default mapping of material
names to block types, this enables another means of constructing
objects in the Minecraft world. For example, see :ref:`models`.
.. _object file: https://en.wikipedia.org/wiki/Wavefront_.obj_file
"""
if materials is None:
materials = lambda f: Block(f.material)
if isinstance(groups, bytes):
groups = groups.decode('utf-8')
if groups is None:
faces = self.faces
elif isinstance(groups, str):
faces = self.groups[groups]
else:
faces = chain(*(self.groups[g] for g in groups))
result = {}
for face in faces:
try:
b = materials[face.material]
except KeyError:
raise KeyError('missing mapping for material "%s"' % face.material)
except TypeError:
b = materials(face)
if b is not None:
points = ((p * scale).round() for p in face.vectors)
for v in filled(lines(points)):
result[v] = b
return result