summaryrefslogtreecommitdiffstats
path: root/share/extensions/polyhedron_3d.py
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:24:48 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:24:48 +0000
commitcca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch)
tree146f39ded1c938019e1ed42d30923c2ac9e86789 /share/extensions/polyhedron_3d.py
parentInitial commit. (diff)
downloadinkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.tar.xz
inkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.zip
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'share/extensions/polyhedron_3d.py')
-rwxr-xr-xshare/extensions/polyhedron_3d.py439
1 files changed, 439 insertions, 0 deletions
diff --git a/share/extensions/polyhedron_3d.py b/share/extensions/polyhedron_3d.py
new file mode 100755
index 0000000..40f1c68
--- /dev/null
+++ b/share/extensions/polyhedron_3d.py
@@ -0,0 +1,439 @@
+#!/usr/bin/env python
+# coding=utf-8
+#
+# Copyright (C) 2007 John Beard john.j.beard@gmail.com
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+This extension draws 3d objects from a Wavefront .obj 3D file stored in a local folder
+Many settings for appearance, lighting, rotation, etc are available.
+
+ ^y
+ |
+ __--``| |_--``| __--
+ __--`` | __--``| |_--``
+ | z | | |_--``|
+ | <----|--------|-----_0-----|----------------
+ | | |_--`` | |
+ | __--`` <-``| |_--``
+ |__--`` x |__--``|
+ IMAGE PLANE SCENE|
+ |
+
+ Vertices are given as "v" followed by three numbers (x,y,z).
+ All files need a vertex list
+ v x.xxx y.yyy z.zzz
+
+ Faces are given by a list of vertices
+ (vertex 1 is the first in the list above, 2 the second, etc):
+ f 1 2 3
+
+ Edges are given by a list of vertices. These will be broken down
+ into adjacent pairs automatically.
+ l 1 2 3
+
+ Faces are rendered according to the painter's algorithm and perhaps
+ back-face culling, if selected. The parameter to sort the faces by
+ is user-selectable between max, min and average z-value of the vertices
+"""
+
+import os
+from math import acos, cos, floor, pi, sin, sqrt
+
+import inkex
+from inkex.utils import pairwise
+from inkex import Group, Circle
+from inkex.paths import Move, Line
+from inkex.localization import inkex_gettext as _
+
+try:
+ import numpy
+except:
+ numpy = None
+
+
+def draw_circle(r, cx, cy, width, fill, name, parent):
+ """Draw an SVG circle"""
+ circle = parent.add(Circle(cx=str(cx), cy=str(cy), r=str(r)))
+ circle.style = {"stroke": "#000000", "stroke-width": str(width), "fill": fill}
+ circle.label = name
+
+
+def draw_line(x1, y1, x2, y2, width, name, parent):
+ elem = parent.add(inkex.PathElement())
+ elem.style = {
+ "stroke": "#000000",
+ "stroke-width": str(width),
+ "fill": "none",
+ "stroke-linecap": "round",
+ }
+ elem.set("inkscape:label", name)
+ elem.path = [Move(x1, y1), Line(x2, y2)]
+
+
+def draw_poly(pts, face, st, name, parent):
+ """Draw polygone"""
+ style = {
+ "stroke": "#000000",
+ "stroke-width": str(st.th),
+ "stroke-linejoin": st.linejoin,
+ "stroke-opacity": st.s_opac,
+ "fill": st.fill,
+ "fill-opacity": st.f_opac,
+ }
+ path = inkex.Path()
+ for facet in face:
+ if not path: # for first point
+ path.append(Move(pts[facet - 1][0], -pts[facet - 1][1]))
+ else:
+ path.append(Line(pts[facet - 1][0], -pts[facet - 1][1]))
+ path.close()
+
+ poly = parent.add(inkex.PathElement())
+ poly.label = name
+ poly.style = style
+ poly.path = path
+
+
+def draw_edges(edge_list, pts, st, parent):
+ for edge in edge_list: # for every edge
+ pt_1 = pts[edge[0] - 1][0:2] # the point at the start
+ pt_2 = pts[edge[1] - 1][0:2] # the point at the end
+ name = "Edge" + str(edge[0]) + "-" + str(edge[1])
+ draw_line(pt_1[0], -pt_1[1], pt_2[0], -pt_2[1], st.th, name, parent)
+
+
+def draw_faces(faces_data, pts, obj, shading, fill_col, st, parent):
+ for face in faces_data: # for every polygon that has been sorted
+ if shading:
+ st.fill = get_darkened_colour(
+ fill_col, face[1] / pi
+ ) # darken proportionally to angle to lighting vector
+ else:
+ st.fill = get_darkened_colour(fill_col, 1) # do not darken colour
+
+ face_no = face[3] # the number of the face to draw
+ draw_poly(pts, obj.fce[face_no], st, "Face:" + str(face_no), parent)
+
+
+def get_darkened_colour(rgb, factor):
+ """return a hex triplet of colour, reduced in lightness 0.0-1.0"""
+ return (
+ "#"
+ + "%02X" % floor(factor * rgb[0])
+ + "%02X" % floor(factor * rgb[1])
+ + "%02X" % floor(factor * rgb[2])
+ ) # make the colour string
+
+
+def make_rotation_log(options):
+ """makes a string recording the axes and angles of each rotation, so an object can be repeated"""
+ return (
+ options.r1_ax
+ + str("%.2f" % options.r1_ang)
+ + ":"
+ + options.r2_ax
+ + str("%.2f" % options.r2_ang)
+ + ":"
+ + options.r3_ax
+ + str("%.2f" % options.r3_ang)
+ + ":"
+ + options.r1_ax
+ + str("%.2f" % options.r4_ang)
+ + ":"
+ + options.r2_ax
+ + str("%.2f" % options.r5_ang)
+ + ":"
+ + options.r3_ax
+ + str("%.2f" % options.r6_ang)
+ )
+
+
+def normalise(vector):
+ """return the unit vector pointing in the same direction as the argument"""
+ length = sqrt(numpy.dot(vector, vector))
+ return numpy.array(vector) / length
+
+
+def get_normal(pts, face):
+ """normal vector for the plane passing though the first three elements of face of pts"""
+ return numpy.cross(
+ (numpy.array(pts[face[0] - 1]) - numpy.array(pts[face[1] - 1])),
+ (numpy.array(pts[face[0] - 1]) - numpy.array(pts[face[2] - 1])),
+ ).flatten()
+
+
+def get_unit_normal(pts, face, cw_wound):
+ """
+ Returns the unit normal for the plane passing through the
+ first three points of face, taking account of winding
+ """
+ # if it is clockwise wound, reverse the vector direction
+ winding = -1 if cw_wound else 1
+ return winding * normalise(get_normal(pts, face))
+
+
+def rotate(matrix, rads, axis):
+ """choose the correct rotation matrix to use"""
+ if axis == "x":
+ trans_mat = numpy.array(
+ [[1, 0, 0], [0, cos(rads), -sin(rads)], [0, sin(rads), cos(rads)]]
+ )
+ elif axis == "y":
+ trans_mat = numpy.array(
+ [[cos(rads), 0, sin(rads)], [0, 1, 0], [-sin(rads), 0, cos(rads)]]
+ )
+ elif axis == "z":
+ trans_mat = numpy.array(
+ [[cos(rads), -sin(rads), 0], [sin(rads), cos(rads), 0], [0, 0, 1]]
+ )
+ return numpy.matmul(trans_mat, matrix)
+
+
+class Style(object): # container for style information
+ def __init__(self, options):
+ self.th = options.th
+ self.fill = "#ff0000"
+ self.col = "#000000"
+ self.r = 2
+ self.f_opac = str(options.f_opac / 100.0)
+ self.s_opac = str(options.s_opac / 100.0)
+ self.linecap = "round"
+ self.linejoin = "round"
+
+
+class WavefrontObj(object):
+ """Wavefront based 3d object defined by the vertices and the faces (eg a polyhedron)"""
+
+ name = property(lambda self: self.meta.get("name", None))
+
+ def __init__(self, filename):
+ self.meta = {"name": os.path.basename(filename).rsplit(".", 1)[0]}
+ self.vtx = []
+ self.edg = []
+ self.fce = []
+ self._parse_file(filename)
+
+ def _parse_file(self, filename):
+ if not os.path.isfile(filename):
+ raise IOError(_("Can't find wavefront object file {}").format(filename))
+ with open(filename, "r") as fhl:
+ for line in fhl:
+ self._parse_line(line.strip())
+
+ def _parse_line(self, line):
+ if line.startswith("#"):
+ if ":" in line:
+ name, value = line.split(":", 1)
+ self.meta[name.lower()] = value
+ elif line:
+ (kind, line) = line.split(None, 1)
+ kind_name = "add_" + kind
+ if hasattr(self, kind_name):
+ getattr(self, kind_name)(line)
+
+ @staticmethod
+ def _parse_numbers(line, typ=str):
+ # Ignore any slash options and always pick the first one
+ return [typ(v.split("/")[0]) for v in line.split()]
+
+ def add_v(self, line):
+ """Add vertex from parsed line"""
+ vertex = self._parse_numbers(line, float)
+ if len(vertex) == 3:
+ self.vtx.append(vertex)
+
+ def add_l(self, line):
+ """Add line from parsed line"""
+ vtxlist = self._parse_numbers(line, int)
+ # we need at least 2 vertices to make an edge
+ if len(vtxlist) > 1:
+ # we can have more than one vertex per line - get adjacent pairs
+ self.edg.extend(pairwise(vtxlist, start=False))
+
+ def add_f(self, line):
+ """Add face from parsed line"""
+ vtxlist = self._parse_numbers(line, int)
+ # we need at least 3 vertices to make an edge
+ if len(vtxlist) > 2:
+ self.fce.append(vtxlist)
+
+ def get_transformed_pts(self, trans_mat):
+ """translate vertex points according to the matrix"""
+ transformed_pts = []
+ for vtx in self.vtx:
+ transformed_pts.append(
+ (numpy.matmul(trans_mat, numpy.array(vtx).T)).T.tolist()
+ )
+ return transformed_pts
+
+ def get_edge_list(self):
+ """make an edge vertex list from an existing face vertex list"""
+ edge_list = []
+ for face in self.fce:
+ for j, edge in enumerate(face):
+ # Ascending order of certices (for duplicate detection)
+ edge_list.append(sorted([edge, face[(j + 1) % len(face)]]))
+ return [list(x) for x in sorted(set(tuple(x) for x in edge_list))]
+
+
+class Poly3D(inkex.GenerateExtension):
+ """Generate a polyhedron from a wavefront 3d model file"""
+
+ def add_arguments(self, pars):
+ pars.add_argument("--tab", default="common")
+
+ # MODEL FILE SETTINGS
+ pars.add_argument("--obj", default="cube")
+ pars.add_argument("--spec_file", default="great_rhombicuboct.obj")
+ pars.add_argument("--cw_wound", type=inkex.Boolean, default=False)
+ pars.add_argument("--type", default="face")
+ # VEIW SETTINGS
+ pars.add_argument("--r1_ax", default="x")
+ pars.add_argument("--r2_ax", default="x")
+ pars.add_argument("--r3_ax", default="x")
+ pars.add_argument("--r4_ax", default="x")
+ pars.add_argument("--r5_ax", default="x")
+ pars.add_argument("--r6_ax", default="x")
+ pars.add_argument("--r1_ang", type=float, default=0.0)
+ pars.add_argument("--r2_ang", type=float, default=0.0)
+ pars.add_argument("--r3_ang", type=float, default=0.0)
+ pars.add_argument("--r4_ang", type=float, default=0.0)
+ pars.add_argument("--r5_ang", type=float, default=0.0)
+ pars.add_argument("--r6_ang", type=float, default=0.0)
+ pars.add_argument("--scl", type=float, default=100.0)
+ # STYLE SETTINGS
+ pars.add_argument("--show", type=self.arg_method("gen"))
+ pars.add_argument("--shade", type=inkex.Boolean, default=True)
+ pars.add_argument("--f_r", type=int, default=255)
+ pars.add_argument("--f_g", type=int, default=0)
+ pars.add_argument("--f_b", type=int, default=0)
+ pars.add_argument("--f_opac", type=int, default=100)
+ pars.add_argument("--s_opac", type=int, default=100)
+ pars.add_argument("--th", type=float, default=2)
+ pars.add_argument("--lv_x", type=float, default=1)
+ pars.add_argument("--lv_y", type=float, default=1)
+ pars.add_argument("--lv_z", type=float, default=-2)
+ pars.add_argument("--back", type=inkex.Boolean, default=False)
+ pars.add_argument(
+ "--z_sort", type=self.arg_method("z_sort"), default=self.z_sort_min
+ )
+
+ def get_filename(self):
+ """Get the filename for the spec file"""
+ name = ""
+ if self.options.obj == "from_file":
+ name = self.options.spec_file
+ else:
+ name = self.options.obj + ".obj"
+ moddir = self.ext_path()
+ return os.path.join(moddir, "Poly3DObjects", name)
+
+ def generate(self):
+ if numpy is None:
+ raise inkex.AbortExtension(_("This extension requires the numpy library."))
+ so = self.options
+
+ obj = WavefrontObj(self.get_filename())
+
+ scale = self.svg.unittouu("1px") # convert to document units
+ st = Style(so) # initialise style
+
+ # we will put all the rotations in the object name, so it can be repeated in
+ poly = Group.new(obj.name + ":" + make_rotation_log(so))
+ (pos_x, pos_y) = self.svg.namedview.center
+ poly.transform.add_translate(pos_x, pos_y)
+ poly.transform.add_scale(scale)
+
+ # TRANSFORMATION OF THE OBJECT (ROTATION, SCALE, ETC)
+ trans_mat = numpy.identity(3, float) # init. trans matrix as identity matrix
+ for i in range(1, 7): # for each rotation
+ axis = getattr(so, "r{}_ax".format(i))
+ angle = getattr(so, "r{}_ang".format(i)) * pi / 180
+ trans_mat = rotate(trans_mat, angle, axis)
+ # scale by linear factor (do this only after the transforms to reduce round-off)
+ trans_mat = trans_mat * so.scl
+
+ # the points as projected in the z-axis onto the viewplane
+ transformed_pts = obj.get_transformed_pts(trans_mat)
+ so.show(obj, st, poly, transformed_pts)
+ return poly
+
+ def gen_vtx(self, obj, st, poly, transformed_pts):
+ """Generate Vertex"""
+ for i, pts in enumerate(transformed_pts):
+ draw_circle(st.r, pts[0], pts[1], st.th, "#000000", "Point" + str(i), poly)
+
+ def gen_edg(self, obj, st, poly, transformed_pts):
+ """Generate edges"""
+ # we already have an edge list
+ edge_list = obj.edg
+ if obj.fce:
+ # we must generate the edge list from the faces
+ edge_list = obj.get_edge_list()
+
+ draw_edges(edge_list, transformed_pts, st, poly)
+
+ def gen_fce(self, obj, st, poly, transformed_pts):
+ """Generate face"""
+ so = self.options
+ # colour tuple for the face fill
+ fill_col = (so.f_r, so.f_g, so.f_b)
+ # unit light vector
+ lighting = normalise((so.lv_x, -so.lv_y, so.lv_z))
+ # we have a face list
+ if obj.fce:
+ z_list = []
+
+ for i, face in enumerate(obj.fce):
+ # get the normal vector to the face
+ norm = get_unit_normal(transformed_pts, face, so.cw_wound)
+ # get the angle between the normal and the lighting vector
+ angle = acos(numpy.dot(norm, lighting))
+ z_sort_param = so.z_sort(transformed_pts, face)
+
+ # include all polygons or just the front-facing ones as needed
+ if so.back or norm[2] > 0:
+ # record the maximum z-value of the face and angle to
+ # light, along with the face ID and normal
+ z_list.append((z_sort_param, angle, norm, i))
+
+ z_list.sort(
+ key=lambda x: x[0]
+ ) # sort by ascending sort parameter of the face
+ draw_faces(z_list, transformed_pts, obj, so.shade, fill_col, st, poly)
+
+ else: # we cannot generate a list of faces from the edges without a lot of computation
+ raise inkex.AbortExtension(_("Face data not found."))
+
+ @staticmethod
+ def z_sort_max(pts, face):
+ """returns the largest z_value of any point in the face"""
+ return max([pts[facet - 1][2] for facet in face])
+
+ @staticmethod
+ def z_sort_min(pts, face):
+ """returns the smallest z_value of any point in the face"""
+ return min([pts[facet - 1][2] for facet in face])
+
+ @staticmethod
+ def z_sort_cent(pts, face):
+ """returns the centroid z_value of any point in the face"""
+ return sum([pts[facet - 1][2] for facet in face]) / len(face)
+
+
+if __name__ == "__main__":
+ Poly3D().run()