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