As promised, Paul Kassebaum is back this week with an in-depth discussion of how to get from a mathematical object in MATLAB to a solid object you can hold in your hand. Paul is a maker in the truest sense of the word. If there are a hundred weird and unexpected obstacles between him and the thing he wants to create, he works through them methodically and documents his work carefully. I get incredibly frustrated trying to tune up my lawn mower. Paul, on the other hand, could probably build a working lawn mower from a pair of scissors, a box of binder clips, and a broken copy machine.
3D Printing from MATLAB
by Paul Kassebaum
Many 3D-printed objects are designed by combining and sculpting primitive shapes such as spheres and cones using CAD software. This post will show how to use MATLAB to create 3D-printable objects based on equations or data, a task that most CAD programs are not designed to do.
This post will be structured as follows. First, we will explain the file format read by 3D printers, called stereo-lithography files, or STL-files. We will then show how to generate these files starting from matrix-based surface plots created from such sources as photographs and solutions to partial differential equations such as the beloved L-shaped membrane. Finally, we will be able to hold a MATLAB plot in our hands.
STL-files describe a closed surface in terms of triangular faces. These files consist of a list of triangles, with each triangle described by the cartesian coordinates of its three vertices and a normal vector oriented outward from the closed surface. Here is an excerpt from an ASCII STL-file:
facet normal nx ny nz outer loop vertex v1x v1y v1z vertex v2x v2y v2z vertex v3x v3y v3z endloop endfacet
In practice, it might look like this:
facet normal 6.6998060E-01 -6.6246430E-01 3.3506277E-01 outer loop vertex 7.9166667E-01 2.5000000E-01 9.4328269E-01 vertex 7.5000000E-01 2.5000000E-01 1.0265980E+00 vertex 7.9166667E-01 2.0833333E-01 8.6090207E-01 endloop endfacet
Before being useful for a 3D printer, a surface described by an STL-file must be sliced into layers defining the path traced out by the printer. Many 3D printers come with their own slicing program to perform this translation, so we will not address this stage. It is worth noting that depending upon the type of printer being used, the orientation of the surface may factor into the quality of the print.
The most natural way to create surface plots in MATLAB is to use a matrix of height values. Consider the surface plot of the MathWorks logo, based on the L-shaped membrane.
n = 12; % number of partitions in each dimension. [X,Y] = meshgrid(linspace(0,1,2*n+1)); L = (40/51/0.9)*membrane(1,n); surf(X,Y,L); colormap pink; set(gca,'dataAspectRatio', [1 1 1]);
The first step in translating a matrix-based plot into an STL-file is to break up each square element in the mesh into two triangular elements. We can make use of the function DELAUNAY to create a Delaunay triangulation of the rectilinear mesh.
faces = delaunay(X,Y); patches = trisurf(faces,X,Y,L); set(gca,'dataAspectRatio', [1 1 1]);
Notice that this mesh has no thickness, so it cannot yet be used to make a solid 3D print. We can make a shell out of this surface as follows. First, project all the vertices of the triangles downward along normal vectors to create a second surface with no thickness beneath the original. Then connect the two surfaces along their boundaries to define a third surface.
Let us begin by creating a 3-by-3-by-n tensor called ‘facets’ that will allow us to easily index through the 3 cartesian coordinates of the 3 vertices of the n triangles that make up our surface.
vertices = get(patches,'vertices'); facets = vertices'; facets = reshape(facets(:,faces'), 3, 3, );
We will say that the normal at a vertex is the average of the normals to the facets that share this vertex. So, we will need to know the normals for each facet. We can calculate the normals of each facet by taking the cross-product of two edges of the facet, taking note of their orientation to ensure that the normals of the whole surface are consistent.
% SQUEEZE compacts empty dimensions. edge1 = squeeze(facets(:,2,:) - facets(:,1,:)); edge2 = squeeze(facets(:,3,:) - facets(:,1,:)); normals = edge1([2 3 1],:) .* edge2([3 1 2],:)... - edge2([2 3 1],:) .* edge1([3 1 2],:); normals = bsxfun(@times,... normals, 1 ./ sqrt(sum(normals .* normals, 1)));
We can compute the normal at each vertex by averaging the normals of its neighboring facets.
meanNormal = zeros(3,length(vertices)); % pre-allocate memory. for k = 1:length(vertices) % Find all faces shared by a vertex [sharedFaces,~] = find(faces == k); % Compute the mean normal of all faces shared by a vertex meanNormal(:,k) = mean(normals(:,sharedFaces),2); end meanNormal = bsxfun(@times, meanNormal,... 1 ./ sqrt(sum(meanNormal .* meanNormal, 1)));
Now we merely need to copy and shift all of the vertices downward along their normals. we will call these new vertices ‘underVertices’.
shellThickness = 0.05; underVertices = vertices - shellThickness*meanNormal';
Let us see what we have got so far.
underFaces = delaunay(underVertices(:,1),underVertices(:,2)); trisurf(underFaces,... underVertices(:,1),... underVertices(:,2),... underVertices(:,3)); set(gca,'dataAspectRatio', [1 1 1],... 'xLim',[0 1],'yLim',[0 1]);
Notice that there are some extra facets that we did not intend. These arise from the 2D Delaunay triangulation, which connects vertices contained within the convex hull of the whole set of vertices by default. This did not pose a problem in our original surface because the boundaries consisted of a square, which is its own convex hull. The boundary of our new surface is the original square boundary deformed by the translation of each vertex through the normal vectors we calculated. Thus, our new surface has a boundary that is not its own convex hull. MATLAB’s DELAUNAYTRI class allows us to specify the boundary in 2D, which solves our problem.
To resolve this problem, we will first find the indices of the boundaries of the original surface, which will also index the boundaries of the lower surface. These boundary indices will serve to constrain the Delaunay triangulation and will be used to connect the two surfaces with a new surface. First, let us find the boundary indices.
boundaryIndices = ... [find(vertices(:,2) == min(vertices(:,2))); % min y find(vertices(:,1) == max(vertices(:,1))); % max x find(vertices(:,2) == max(vertices(:,2))); % max y find(vertices(:,1) == min(vertices(:,1)))];% min x
Next we will rearrange the indices so they parameterize the boundary. That is, we traverse the boundary in a counterclockwise sense as the index increases.
boundaryIndices = [... boundaryIndices(1:floor(end/4-1)); % semi open interval [1, end/4). boundaryIndices(floor(end/4+1):floor(end/2));%[end/4, end/2) boundaryIndices(floor(end*3/4-1):-1:floor(end/2+1));%[end/2,end*3/4) boundaryIndices(end-1:-1:floor(end*3/4+1))]; %[end*3/4, end)
The DELAUNAYTRI constructor expects each constrained edge to be defined in terms of its terminal vertices. We can create a sequence of edges by staggering the boundary vertices.
constrainedEdges = [boundaryIndices(1:end-1), boundaryIndices(2:end)]; underFaces = DelaunayTri(... [underVertices(:,1),underVertices(:,2)],constrainedEdges);
The DELAUNAYTRI constructor has created a triangulation that consists of two parts: one region within our constrained edges, and another outside of them. Since we only care about the inside region, we will pick it out.
inside = underFaces.inOutStatus; % 1 = in, 0 = out. underFaces = underFaces.Triangulation(inside,:);
The normals of the lower surface have the same orientation as the original surface because of the way we constructed it. However, we will be making a closed surface by connecting these two, at which point the normals of the lower surface will need to be reversed to point outward. So, let us flip the lower surface’s normals.
underFaces = fliplr(underFaces);
Let us see what we have got so far.
trisurf(underFaces,... underVertices(:,1),... underVertices(:,2),... underVertices(:,3)); set(gca,'dataAspectRatio', [1 1 1],... 'xLim',[0 1],'yLim',[0 1]);
This looks much better. Now we can move on to connect these two surfaces with a third surface that we will call ‘wall’. The wall will be made up of the boundary vertices of the top and bottom surfaces. We will triangulate these vertices by defining each facet of the wall to have one vertex on one surface and two vertices on the other surface.
wallVertices = [vertices(boundaryIndices,:); underVertices(boundaryIndices,:)]; % Number of wall vertices on each surface (nwv). nwv = length(wallVertices)/2; % Allocate memory for wallFaces. wallFaces = zeros(2*(nwv-1),3); % Define the faces. for k = 1:nwv-1 wallFaces(k ,:) = [k+1 ,k ,k+nwv]; wallFaces(k+nwv-1,:) = [k+nwv,k+1+nwv,k+1]; end
Let us see what we have got so far.
trisurf(wallFaces,... wallVertices(:,1),... wallVertices(:,2),... wallVertices(:,3)); set(gca,'dataAspectRatio', [1 1 1],... 'xLim',[0 1],'yLim',[0 1]);
Now let us assemble our three surfaces into one closed surface that we will call ‘shell’.
% Shift indices to concatenate with the original surface. underFaces = underFaces + length(vertices); wallFaces = wallFaces + 2*length(vertices); % Concatenate the results. shellVertices = [vertices; underVertices; wallVertices]; shellFaces = [faces; underFaces; wallFaces];
Most 3D printers require that all of the vertex coordinates be non-negative, so we will shift our shell up to satisfy this convention.
minZ = min(shellVertices(:,3)); shellVertices = shellVertices... - repmat([0 0 minZ],length(shellVertices),1);
Let us look at the final result.
trisurf(shellFaces,... shellVertices(:,1),... shellVertices(:,2),... shellVertices(:,3)); set(gca,'dataAspectRatio', [1 1 1],... 'xLim',[0 1],'yLim',[0 1]);
The final stage is to convert this surface into an STL-file. We refer to the excellent entry in the File Exchange called stlwrite for converting our surface to an STL-file. Stlwrite is suitable for closed surfaces defined by data, but not for surfaces with boundaries like the one we started with here, because stlwrite will generate a mesh with no thickness.
% Name your STL-file filename = 'MathWorksLogo.stl'; % Create the facets. shellFacets = shellVertices'; shellFacets = reshape(shellFacets(:,shellFaces'), 3, 3, ); % Compute their normals. edge1 = squeeze(shellFacets(:,2,:) - shellFacets(:,1,:)); edge2 = squeeze(shellFacets(:,3,:) - shellFacets(:,1,:)); shellNormals = edge1([2 3 1],:) .* edge2([3 1 2],:)... - edge2([2 3 1],:) .* edge1([3 1 2],:); shellNormals = bsxfun(@times,... shellNormals, 1 ./ sqrt(sum(shellNormals .* shellNormals, 1))); % Associate each facet with its normal. shellFacets = cat(2, reshape(shellNormals, 3, 1, ), shellFacets); % Open the file for writing fid = fopen(filename,'wb+'); % Write the file contents. % Write HEADER. fprintf(fid,'solid %s\r\n',filename); % Write DATA. fprintf(fid,[... 'facet normal %.7E %.7E %.7E\r\n' ... 'outer loop\r\n' ... 'vertex %.7E %.7E %.7E\r\n' ... 'vertex %.7E %.7E %.7E\r\n' ... 'vertex %.7E %.7E %.7E\r\n' ... 'endloop\r\n' ... 'endfacet\r\n'], shellFacets); % Write FOOTER. fprintf(fid,'endsolid %s\r\n',filename); % Close the file. fclose(fid);
With your STL-file in hand, you can now make your print! If you do not have your own 3D printer, you can look for your nearest maker-space to see if they have one, or send your STL-file off to a 3D printing service provider such as Shapeways, Ponoko, Sculpteo, I.Materialize, ZoomRP, or RedEye. I am a member of the maker-space Artisan’s Asylum, which has a nice 3D printer, among many other tools. Here is a photo of my completed L-shaped membrane.
Good luck and have fun turning your MATLAB plots into tangible objects!
Comments are closed.
10 CommentsOldest to Newest
I just want to thanks for this explanation. I’m from Argentina. I was driving crazy when I wanted to print (3D) the matlab’s surfaces. Just one question: Is there an easier way to do it?
Thank you very much again!
Denisse, there’s an easy way now. I’ve made a file exchange entry (https://www.mathworks.com/matlabcentral/fileexchange/42876-surf2solid-make-a-solid-volume-from-a-surface-for-3d-printing) which wraps up many of the (very useful) details Paul has given into one function:
n = 12; % number of partitions in each dimension.
[X,Y] = meshgrid(linspace(0,1,2*n+1));
L = (40/51/0.9)*membrane(1,n);
FV_closed = surf2solid(X,Y,L,’thickness’,-0.05);
The resulting “yourfile.stl” should be suitable for 3D printing. Does that help?
Sven, awesome work on surf2solid on the File Exchange. Well written, great functionality, and beautiful logo ;) By the way, Sven is also an author of stlwrite. Taken together, these two functions make it a breeze to turn your MATLAB surf plots into 3D prints.
I’m working on opening a new makerspace in Boston and/or near Rt.128 & I90 that will have several 3D printers–equipment inventory similar to TechShop. I’d welcome any input from MathWorks folks on classes to offer and collaborating to promote STEM curriculum in surrounding school systems.
Appreciate any feedback,
Thanks for the link Elad. You’re right, that’s an excellent post. You can find surf2solid here: https://www.mathworks.com/matlabcentral/fileexchange/42876-surf2solid-make-a-solid-volume-from-a-surface-for-3d-printing
Beautiful article; I have been looking for something like this for quite a while.
Is there a similar method to convert a set of points that describe the surface of a 3D object into a .stl? i.e. an object that cannot be described by Surf (like a sphere, sculpture or car).
I have 3D data that is pretty well distributed as it was generated in Matlab. I can get it to plot a nice looking object using plot3, but that is it.
Check out this documentation page for an example of fitting data to splines to create surfaces. In the example of the sphere, the function ‘fnplt’ generates a figure and an axes. You can grab the underlying patch object as follows:
ah = gca;
s = get(ah,’children’);
p = surf2patch(s);
faces = get(p,’faces’);
vertices = get(p,’vertices’);
Now you can use the faces and vertices to generate an STL file using stlwrite.
Thanks for posting this.
I have been trying to print a thick 3 D mesh instead of a surface as that saves me a lot of surface area on the material. Any suggestions on how to go on about it?
@Phalgun I think a lot of people share your need, so I’ve written and published a function on the File Exchange that you can use to create 3D printable meshed surfaces, which I’ve called punctureSurface.m. If you find it useful and make a print of your own using it, please let me know: I’d love to see it :)
Thanks Paul. I’ve just downloaded the script. I’ll have to familiarize myself with the script and see if I can plot a 3D mesh surface with my data. I’ll update the forum as soon as I can successfully make one.