1#!/usr/local/bin/python3.8
2# coding=utf-8
3#
4# Copyright (C) 2007 John Beard john.j.beard@gmail.com
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19#
20"""
21This extension draws 3d objects from a Wavefront .obj 3D file stored in a local folder
22Many settings for appearance, lighting, rotation, etc are available.
23
24                              ^y
25                              |
26        __--``|               |_--``|     __--
27  __--``      |         __--``|     |_--``
28 |       z    |        |      |_--``|
29 |       <----|--------|-----_0-----|----------------
30 |            |        |_--`` |     |
31 |      __--``     <-``|      |_--``
32 |__--``           x   |__--``|
33  IMAGE PLANE           SCENE|
34                              |
35
36 Vertices are given as "v" followed by three numbers (x,y,z).
37 All files need a vertex list
38 v  x.xxx   y.yyy   z.zzz
39
40 Faces are given by a list of vertices
41 (vertex 1 is the first in the list above, 2 the second, etc):
42 f  1   2   3
43
44 Edges are given by a list of vertices. These will be broken down
45 into adjacent pairs automatically.
46 l  1   2   3
47
48 Faces are rendered according to the painter's algorithm and perhaps
49 back-face culling, if selected. The parameter to sort the faces by
50 is user-selectable between max, min and average z-value of the vertices
51"""
52
53import os
54from math import acos, cos, floor, pi, sin, sqrt
55
56import inkex
57from inkex.utils import pairwise
58from inkex import Group, Circle
59from inkex.paths import Move, Line
60
61try:
62    import numpy
63except:
64    numpy = None
65
66def draw_circle(r, cx, cy, width, fill, name, parent):
67    """Draw an SVG circle"""
68    circle = parent.add(Circle(cx=str(cx), cy=str(cy), r=str(r)))
69    circle.style = {'stroke': '#000000', 'stroke-width': str(width), 'fill': fill}
70    circle.label = name
71
72
73def draw_line(x1, y1, x2, y2, width, name, parent):
74    elem = parent.add(inkex.PathElement())
75    elem.style = {'stroke': '#000000', 'stroke-width': str(width), 'fill': 'none',
76                  'stroke-linecap': 'round'}
77    elem.set('inkscape:label', name)
78    elem.path = [Move(x1, y1), Line(x2, y2)]
79
80def draw_poly(pts, face, st, name, parent):
81    """Draw polygone"""
82    style = {'stroke': '#000000', 'stroke-width': str(st.th), 'stroke-linejoin': st.linejoin,
83             'stroke-opacity': st.s_opac, 'fill': st.fill, 'fill-opacity': st.f_opac}
84    path = inkex.Path()
85    for facet in face:
86        if not path:  # for first point
87            path.append(Move(pts[facet - 1][0], -pts[facet - 1][1]))
88        else:
89            path.append(Line(pts[facet - 1][0], -pts[facet - 1][1]))
90    path.close()
91
92    poly = parent.add(inkex.PathElement())
93    poly.label = name
94    poly.style = style
95    poly.path = path
96
97
98def draw_edges(edge_list, pts, st, parent):
99    for edge in edge_list:  # for every edge
100        pt_1 = pts[edge[0] - 1][0:2]  # the point at the start
101        pt_2 = pts[edge[1] - 1][0:2]  # the point at the end
102        name = 'Edge' + str(edge[0]) + '-' + str(edge[1])
103        draw_line(pt_1[0], -pt_1[1], pt_2[0], -pt_2[1], st.th, name, parent)
104
105
106def draw_faces(faces_data, pts, obj, shading, fill_col, st, parent):
107    for face in faces_data:  # for every polygon that has been sorted
108        if shading:
109            st.fill = get_darkened_colour(fill_col, face[1] / pi)  # darken proportionally to angle to lighting vector
110        else:
111            st.fill = get_darkened_colour(fill_col, 1)  # do not darken colour
112
113        face_no = face[3]  # the number of the face to draw
114        draw_poly(pts, obj.fce[face_no], st, 'Face:' + str(face_no), parent)
115
116
117def get_darkened_colour(rgb, factor):
118    """return a hex triplet of colour, reduced in lightness 0.0-1.0"""
119    return '#' + "%02X" % floor(factor * rgb[0]) \
120           + "%02X" % floor(factor * rgb[1]) \
121           + "%02X" % floor(factor * rgb[2])  # make the colour string
122
123
124def make_rotation_log(options):
125    """makes a string recording the axes and angles of each rotation, so an object can be repeated"""
126    return options.r1_ax + str('%.2f' % options.r1_ang) + ':' + \
127           options.r2_ax + str('%.2f' % options.r2_ang) + ':' + \
128           options.r3_ax + str('%.2f' % options.r3_ang) + ':' + \
129           options.r1_ax + str('%.2f' % options.r4_ang) + ':' + \
130           options.r2_ax + str('%.2f' % options.r5_ang) + ':' + \
131           options.r3_ax + str('%.2f' % options.r6_ang)
132
133def normalise(vector):
134    """return the unit vector pointing in the same direction as the argument"""
135    length = sqrt(numpy.dot(vector, vector))
136    return numpy.array(vector) / length
137
138def get_normal(pts, face):
139    """normal vector for the plane passing though the first three elements of face of pts"""
140    return numpy.cross(
141        (numpy.array(pts[face[0] - 1]) - numpy.array(pts[face[1] - 1])),
142        (numpy.array(pts[face[0] - 1]) - numpy.array(pts[face[2] - 1])),
143    ).flatten()
144
145def get_unit_normal(pts, face, cw_wound):
146    """
147    Returns the unit normal for the plane passing through the
148    first three points of face, taking account of winding
149    """
150    # if it is clockwise wound, reverse the vector direction
151    winding = -1 if cw_wound else 1
152    return winding * normalise(get_normal(pts, face))
153
154def rotate(matrix, rads, axis):
155    """choose the correct rotation matrix to use"""
156    if axis == 'x':
157        trans_mat = numpy.array([
158            [1, 0, 0], [0, cos(rads), -sin(rads)], [0, sin(rads), cos(rads)]])
159    elif axis == 'y':
160        trans_mat = numpy.array([
161            [cos(rads), 0, sin(rads)], [0, 1, 0], [-sin(rads), 0, cos(rads)]])
162    elif axis == 'z':
163        trans_mat = numpy.array([
164            [cos(rads), -sin(rads), 0], [sin(rads), cos(rads), 0], [0, 0, 1]])
165    return numpy.matmul(trans_mat, matrix)
166
167class Style(object):  # container for style information
168    def __init__(self, options):
169        self.th = options.th
170        self.fill = '#ff0000'
171        self.col = '#000000'
172        self.r = 2
173        self.f_opac = str(options.f_opac / 100.0)
174        self.s_opac = str(options.s_opac / 100.0)
175        self.linecap = 'round'
176        self.linejoin = 'round'
177
178
179class WavefrontObj(object):
180    """Wavefront based 3d object defined by the vertices and the faces (eg a polyhedron)"""
181    name = property(lambda self: self.meta.get('name', None))
182
183    def __init__(self, filename):
184        self.meta = {
185            'name': os.path.basename(filename).rsplit('.', 1)[0]
186        }
187        self.vtx = []
188        self.edg = []
189        self.fce = []
190        self._parse_file(filename)
191
192    def _parse_file(self, filename):
193        if not os.path.isfile(filename):
194            raise IOError("Can't find wavefront object file {}".format(filename))
195        with open(filename, 'r') as fhl:
196            for line in fhl:
197                self._parse_line(line.strip())
198
199    def _parse_line(self, line):
200        if line.startswith('#'):
201            if ':' in line:
202                name, value = line.split(':', 1)
203                self.meta[name.lower()] = value
204        elif line:
205            (kind, line) = line.split(None, 1)
206            kind_name = 'add_' + kind
207            if hasattr(self, kind_name):
208                getattr(self, kind_name)(line)
209
210    @staticmethod
211    def _parse_numbers(line, typ=str):
212        # Ignore any slash options and always pick the first one
213        return [typ(v.split('/')[0]) for v in line.split()]
214
215    def add_v(self, line):
216        """Add vertex from parsed line"""
217        vertex = self._parse_numbers(line, float)
218        if len(vertex) == 3:
219            self.vtx.append(vertex)
220
221    def add_l(self, line):
222        """Add line from parsed line"""
223        vtxlist = self._parse_numbers(line, int)
224        # we need at least 2 vertices to make an edge
225        if len(vtxlist) > 1:
226            # we can have more than one vertex per line - get adjacent pairs
227            self.edg.append(pairwise(vtxlist))
228
229    def add_f(self, line):
230        """Add face from parsed line"""
231        vtxlist = self._parse_numbers(line, int)
232        # we need at least 3 vertices to make an edge
233        if len(vtxlist) > 2:
234            self.fce.append(vtxlist)
235
236    def get_transformed_pts(self, trans_mat):
237        """translate vertex points according to the matrix"""
238        transformed_pts = []
239        for vtx in self.vtx:
240            transformed_pts.append((numpy.matmul(trans_mat, numpy.array(vtx).T)).T.tolist())
241        return transformed_pts
242
243    def get_edge_list(self):
244        """make an edge vertex list from an existing face vertex list"""
245        edge_list = []
246        for face in self.fce:
247            for j, edge in enumerate(face):
248                # Ascending order of certices (for duplicate detection)
249                edge_list.append(sorted([edge, face[(j + 1) % len(face)]]))
250        return [list(x) for x in sorted(set(tuple(x) for x in edge_list))]
251
252class Poly3D(inkex.GenerateExtension):
253    """Generate a polyhedron from a wavefront 3d model file"""
254    def add_arguments(self, pars):
255        pars.add_argument("--tab", default="object")
256
257        # MODEL FILE SETTINGS
258        pars.add_argument("--obj", default='cube')
259        pars.add_argument("--spec_file", default='great_rhombicuboct.obj')
260        pars.add_argument("--cw_wound", type=inkex.Boolean, default=True)
261        pars.add_argument("--type", default='face')
262        # VEIW SETTINGS
263        pars.add_argument("--r1_ax", default="x")
264        pars.add_argument("--r2_ax", default="x")
265        pars.add_argument("--r3_ax", default="x")
266        pars.add_argument("--r4_ax", default="x")
267        pars.add_argument("--r5_ax", default="x")
268        pars.add_argument("--r6_ax", default="x")
269        pars.add_argument("--r1_ang", type=float, default=0.0)
270        pars.add_argument("--r2_ang", type=float, default=0.0)
271        pars.add_argument("--r3_ang", type=float, default=0.0)
272        pars.add_argument("--r4_ang", type=float, default=0.0)
273        pars.add_argument("--r5_ang", type=float, default=0.0)
274        pars.add_argument("--r6_ang", type=float, default=0.0)
275        pars.add_argument("--scl", type=float, default=100.0)
276        # STYLE SETTINGS
277        pars.add_argument("--show", type=self.arg_method('gen'))
278        pars.add_argument("--shade", type=inkex.Boolean, default=True)
279        pars.add_argument("--f_r", type=int, default=255)
280        pars.add_argument("--f_g", type=int, default=0)
281        pars.add_argument("--f_b", type=int, default=0)
282        pars.add_argument("--f_opac", type=int, default=100)
283        pars.add_argument("--s_opac", type=int, default=100)
284        pars.add_argument("--th", type=float, default=2)
285        pars.add_argument("--lv_x", type=float, default=1)
286        pars.add_argument("--lv_y", type=float, default=1)
287        pars.add_argument("--lv_z", type=float, default=-2)
288        pars.add_argument("--back", type=inkex.Boolean, default=False)
289        pars.add_argument("--z_sort", type=self.arg_method('z_sort'), default=self.z_sort_min)
290
291    def get_filename(self):
292        """Get the filename for the spec file"""
293        name = ""
294        if self.options.obj == 'from_file':
295            name = self.options.spec_file
296        else:
297            name = self.options.obj + '.obj'
298        moddir = self.ext_path()
299        return os.path.join(moddir, 'Poly3DObjects', name)
300
301    def generate(self):
302        if numpy is None:
303            raise inkex.AbortExtension("numpy is required.")
304        so = self.options
305
306        obj = WavefrontObj(self.get_filename())
307
308        scale = self.svg.unittouu('1px')  # convert to document units
309        st = Style(so)  # initialise style
310
311        # we will put all the rotations in the object name, so it can be repeated in
312        poly = Group.new(obj.name + ':' + make_rotation_log(so))
313        (pos_x, pos_y) = self.svg.namedview.center
314        poly.transform.add_translate(pos_x, pos_y)
315        poly.transform.add_scale(scale)
316
317        # TRANSFORMATION OF THE OBJECT (ROTATION, SCALE, ETC)
318        trans_mat = numpy.identity(3, float)  # init. trans matrix as identity matrix
319        for i in range(1, 7):  # for each rotation
320            axis = getattr(so, 'r{}_ax'.format(i))
321            angle = getattr(so, 'r{}_ang'.format(i)) * pi / 180
322            trans_mat = rotate(trans_mat, angle, axis)
323        # scale by linear factor (do this only after the transforms to reduce round-off)
324        trans_mat = trans_mat * so.scl
325
326        # the points as projected in the z-axis onto the viewplane
327        transformed_pts = obj.get_transformed_pts(trans_mat)
328        so.show(obj, st, poly, transformed_pts)
329        return poly
330
331    def gen_vtx(self, obj, st, poly, transformed_pts):
332        """Generate Vertex"""
333        for i, pts in enumerate(transformed_pts):
334            draw_circle(st.r, pts[0], pts[1], st.th, '#000000', 'Point' + str(i), poly)
335
336    def gen_edg(self, obj, st, poly, transformed_pts):
337        """Generate edges"""
338        # we already have an edge list
339        edge_list = obj.edg
340        if obj.fce:
341            # we must generate the edge list from the faces
342            edge_list = obj.get_edge_list()
343
344        draw_edges(edge_list, transformed_pts, st, poly)
345
346    def gen_fce(self, obj, st, poly, transformed_pts):
347        """Generate face"""
348        so = self.options
349        # colour tuple for the face fill
350        fill_col = (so.f_r, so.f_g, so.f_b)
351        # unit light vector
352        lighting = normalise((so.lv_x, -so.lv_y, so.lv_z))
353        # we have a face list
354        if obj.fce:
355            z_list = []
356
357            for i, face in enumerate(obj.fce):
358                # get the normal vector to the face
359                norm = get_unit_normal(transformed_pts, face, so.cw_wound)
360                # get the angle between the normal and the lighting vector
361                angle = acos(numpy.dot(norm, lighting))
362                z_sort_param = so.z_sort(transformed_pts, face)
363
364                # include all polygons or just the front-facing ones as needed
365                if so.back or norm[2] > 0:
366                    # record the maximum z-value of the face and angle to
367                    # light, along with the face ID and normal
368                    z_list.append((z_sort_param, angle, norm, i))
369
370            z_list.sort(key=lambda x: x[0])  # sort by ascending sort parameter of the face
371            draw_faces(z_list, transformed_pts, obj, so.shade, fill_col, st, poly)
372
373        else:  # we cannot generate a list of faces from the edges without a lot of computation
374            raise inkex.AbortExtension("Face data not found.")
375
376    @staticmethod
377    def z_sort_max(pts, face):
378        """returns the largest z_value of any point in the face"""
379        return max([pts[facet - 1][2] for facet in face])
380
381    @staticmethod
382    def z_sort_min(pts, face):
383        """returns the smallest z_value of any point in the face"""
384        return min([pts[facet - 1][2] for facet in face])
385
386    @staticmethod
387    def z_sort_cent(pts, face):
388        """returns the centroid z_value of any point in the face"""
389        return sum([pts[facet - 1][2] for facet in face]) / len(face)
390
391if __name__ == '__main__':
392    Poly3D().run()
393