diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js
index 1e2c2f689c..15dbcad5ca 100644
--- a/src/webgl/p5.Geometry.js
+++ b/src/webgl/p5.Geometry.js
@@ -925,6 +925,228 @@ p5.Geometry = class Geometry {
return this;
}
+ /**
+ * The `saveObj()` function exports `p5.Geometry` objects as
+ * 3D models in the Wavefront .obj file format.
+ * This way, you can use the 3D shapes you create in p5.js in other software
+ * for rendering, animation, 3D printing, or more.
+ *
+ * The exported .obj file will include the faces and vertices of the `p5.Geometry`,
+ * as well as its texture coordinates and normals, if it has them.
+ *
+ * @method saveObj
+ * @param {String} [fileName='model.obj'] The name of the file to save the model as.
+ * If not specified, the default file name will be 'model.obj'.
+ * @example
+ *
+ *
+ * let myModel;
+ * let saveBtn;
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * myModel = buildGeometry(() => {
+ * for (let i = 0; i < 5; i++) {
+ * push();
+ * translate(
+ * random(-75, 75),
+ * random(-75, 75),
+ * random(-75, 75)
+ * );
+ * sphere(random(5, 50));
+ * pop();
+ * }
+ * });
+ *
+ * saveBtn = createButton('Save .obj');
+ * saveBtn.mousePressed(() => myModel.saveObj());
+ *
+ * describe('A few spheres rotating in space');
+ * }
+ *
+ * function draw() {
+ * background(0);
+ * noStroke();
+ * lights();
+ * rotateX(millis() * 0.001);
+ * rotateY(millis() * 0.002);
+ * model(myModel);
+ * }
+ *
+ *
+ */
+ saveObj(fileName = 'model.obj') {
+ let objStr= '';
+
+
+ // Vertices
+ this.vertices.forEach(v => {
+ objStr += `v ${v.x} ${v.y} ${v.z}\n`;
+ });
+
+ // Texture Coordinates (UVs)
+ if (this.uvs && this.uvs.length > 0) {
+ for (let i = 0; i < this.uvs.length; i += 2) {
+ objStr += `vt ${this.uvs[i]} ${this.uvs[i + 1]}\n`;
+ }
+ }
+
+ // Vertex Normals
+ if (this.vertexNormals && this.vertexNormals.length > 0) {
+ this.vertexNormals.forEach(n => {
+ objStr += `vn ${n.x} ${n.y} ${n.z}\n`;
+ });
+
+ }
+ // Faces, obj vertex indices begin with 1 and not 0
+ // texture coordinate (uvs) and vertexNormal indices
+ // are indicated with trailing ints vertex/normal/uv
+ // ex 1/1/1 or 2//2 for vertices without uvs
+ this.faces.forEach(face => {
+ let faceStr = 'f';
+ face.forEach(index =>{
+ faceStr += ' ';
+ faceStr += index + 1;
+ if (this.vertexNormals.length > 0 || this.uvs.length > 0) {
+ faceStr += '/';
+ if (this.uvs.length > 0) {
+ faceStr += index + 1;
+ }
+ faceStr += '/';
+ if (this.vertexNormals.length > 0) {
+ faceStr += index + 1;
+ }
+ }
+ });
+ objStr += faceStr + '\n';
+ });
+
+ const blob = new Blob([objStr], { type: 'text/plain' });
+ p5.prototype.downloadFile(blob, fileName , 'obj');
+
+ }
+
+ /**
+ * The `saveStl()` function exports `p5.Geometry` objects as
+ * 3D models in the STL stereolithography file format.
+ * This way, you can use the 3D shapes you create in p5.js in other software
+ * for rendering, animation, 3D printing, or more.
+ *
+ * The exported .stl file will include the faces, vertices, and normals of the `p5.Geometry`.
+ *
+ * By default, this method saves a text-based .stl file. Alternatively, you can save a more compact
+ * but less human-readable binary .stl file by passing `{ binary: true }` as a second parameter.
+ *
+ * @method saveStl
+ * @param {String} [fileName='model.stl'] The name of the file to save the model as.
+ * If not specified, the default file name will be 'model.stl'.
+ * @param {Object} [options] Optional settings. Options can include a boolean `binary` property, which
+ * controls whether or not a binary .stl file is saved. It defaults to false.
+ * @example
+ *
+ *
+ * let myModel;
+ * let saveBtn1;
+ * let saveBtn2;
+ * function setup() {
+ * createCanvas(200, 200, WEBGL);
+ * myModel = buildGeometry(() => {
+ * for (let i = 0; i < 5; i++) {
+ * push();
+ * translate(
+ * random(-75, 75),
+ * random(-75, 75),
+ * random(-75, 75)
+ * );
+ * sphere(random(5, 50));
+ * pop();
+ * }
+ * });
+ *
+ * saveBtn1 = createButton('Save .stl');
+ * saveBtn1.mousePressed(function() {
+ * myModel.saveStl();
+ * });
+ * saveBtn2 = createButton('Save binary .stl');
+ * saveBtn2.mousePressed(function() {
+ * myModel.saveStl('model.stl', { binary: true });
+ * });
+ *
+ * describe('A few spheres rotating in space');
+ * }
+ *
+ * function draw() {
+ * background(0);
+ * noStroke();
+ * lights();
+ * rotateX(millis() * 0.001);
+ * rotateY(millis() * 0.002);
+ * model(myModel);
+ * }
+ *
+ *
+ */
+ saveStl(fileName = 'model.stl', { binary = false } = {}){
+ let modelOutput;
+ let name = fileName.substring(0, fileName.lastIndexOf('.'));
+ let faceNormals = [];
+ for (let f of this.faces) {
+ const U = p5.Vector.sub(this.vertices[f[1]], this.vertices[f[0]]);
+ const V = p5.Vector.sub(this.vertices[f[2]], this.vertices[f[0]]);
+ const nx = U.y * V.z - U.z * V.y;
+ const ny = U.z * V.x - U.x * V.z;
+ const nz = U.x * V.y - U.y * V.x;
+ faceNormals.push(new p5.Vector(nx, ny, nz).normalize());
+ }
+ if (binary) {
+ let offset = 80;
+ const bufferLength =
+ this.faces.length * 2 + this.faces.length * 3 * 4 * 4 + 80 + 4;
+ const arrayBuffer = new ArrayBuffer(bufferLength);
+ modelOutput = new DataView(arrayBuffer);
+ modelOutput.setUint32(offset, this.faces.length, true);
+ offset += 4;
+ for (const [key, f] of Object.entries(this.faces)) {
+ const norm = faceNormals[key];
+ modelOutput.setFloat32(offset, norm.x, true);
+ offset += 4;
+ modelOutput.setFloat32(offset, norm.y, true);
+ offset += 4;
+ modelOutput.setFloat32(offset, norm.z, true);
+ offset += 4;
+ for (let vertexIndex of f) {
+ const vert = this.vertices[vertexIndex];
+ modelOutput.setFloat32(offset, vert.x, true);
+ offset += 4;
+ modelOutput.setFloat32(offset, vert.y, true);
+ offset += 4;
+ modelOutput.setFloat32(offset, vert.z, true);
+ offset += 4;
+ }
+ modelOutput.setUint16(offset, 0, true);
+ offset += 2;
+ }
+ } else {
+ modelOutput = 'solid ' + name + '\n';
+
+ for (const [key, f] of Object.entries(this.faces)) {
+ const norm = faceNormals[key];
+ modelOutput +=
+ ' facet norm ' + norm.x + ' ' + norm.y + ' ' + norm.z + '\n';
+ modelOutput += ' outer loop' + '\n';
+ for (let vertexIndex of f) {
+ const vert = this.vertices[vertexIndex];
+ modelOutput +=
+ ' vertex ' + vert.x + ' ' + vert.y + ' ' + vert.z + '\n';
+ }
+ modelOutput += ' endloop' + '\n';
+ modelOutput += ' endfacet' + '\n';
+ }
+ modelOutput += 'endsolid ' + name + '\n';
+ }
+ const blob = new Blob([modelOutput], { type: 'text/plain' });
+ p5.prototype.downloadFile(blob, fileName, 'stl');
+ }
+
/**
* Flips the geometry’s texture u-coordinates.
*
@@ -2079,3 +2301,4 @@ p5.Geometry = class Geometry {
}
};
export default p5.Geometry;
+
diff --git a/test/unit/io/saveModel.js b/test/unit/io/saveModel.js
new file mode 100644
index 0000000000..b15d76685f
--- /dev/null
+++ b/test/unit/io/saveModel.js
@@ -0,0 +1,113 @@
+suite('saveModel',function() {
+ var myp5;
+ setup(function(done) {
+ new p5(function(p) {
+ p.setup = function() {
+ myp5 = p;
+ done();
+ };
+ });
+ });
+ teardown(function() {
+ myp5.remove();
+ });
+ testWithDownload(
+ 'should download an .obj file with expected contents',
+ async function(blobContainer) {
+ //.obj content as a string
+ const objContent = `v 100 0 0
+ v 0 -100 0
+ v 0 0 -100
+ v 0 100 0
+ v 100 0 0
+ v 0 0 -100
+ v 0 100 0
+ v 0 0 100
+ v 100 0 0
+ v 0 100 0
+ v 0 0 -100
+ v -100 0 0
+ v -100 0 0
+ v 0 -100 0
+ v 0 0 100
+ v 0 0 -100
+ v 0 -100 0
+ v -100 0 0
+ v 0 100 0
+ v -100 0 0
+ v 0 0 100
+ v 0 0 100
+ v 0 -100 0
+ v 100 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vt 0 0
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ vn 0 0 1
+ f 1 2 3
+ f 4 5 6
+ f 7 8 9
+ f 10 11 12
+ f 13 14 15
+ f 16 17 18
+ f 19 20 21
+ f 22 23 24
+ `;
+
+ const objBlob = new Blob([objContent], { type: 'text/plain' });
+
+ myp5.downloadFile(objBlob, 'model', 'obj');
+
+ let myBlob = blobContainer.blob;
+
+ let text = await myBlob.text();
+
+ assert.strictEqual(text, objContent);
+ },
+ true
+ );
+
+});