From f71b7799a32769da956d9313d49debb07b6cc78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Mestres?= Date: Thu, 7 Mar 2024 09:30:07 +0100 Subject: [PATCH] feat(122): Add option to choose export format (STL or OBJ) (#127) * feat(122): Add option to choose export format (STL or OBJ) * try fix flaky test --- app/app.js | 3 + app/controllers/app/generator.ts | 28 ++- app/services/file-exporter.ts | 59 +++++ app/services/stl-exporter.ts | 41 ---- app/templates/app/generator.hbs | 80 ++++--- app/utils/OBJExporter.js | 225 ++++++++++++++++++ .../settings-form/type-select-test.ts | 5 + tests/unit/controllers/app/generator-test.ts | 14 +- tests/unit/services/file-exporter-test.ts | 50 ++++ tests/unit/services/stl-exporter-test.ts | 43 ---- translations/en-us.yaml | 3 +- translations/fr-fr.yaml | 3 +- 12 files changed, 423 insertions(+), 131 deletions(-) create mode 100644 app/services/file-exporter.ts delete mode 100644 app/services/stl-exporter.ts create mode 100644 app/utils/OBJExporter.js create mode 100644 tests/unit/services/file-exporter-test.ts delete mode 100644 tests/unit/services/stl-exporter-test.ts diff --git a/app/app.js b/app/app.js index 24acccf..9a50650 100644 --- a/app/app.js +++ b/app/app.js @@ -29,6 +29,9 @@ import '@esri/calcite-components/dist/components/calcite-combobox'; import '@esri/calcite-components/dist/components/calcite-combobox-item'; import '@esri/calcite-components/dist/components/calcite-card'; import '@esri/calcite-components/dist/components/calcite-popover'; +import '@esri/calcite-components/dist/components/calcite-split-button'; +import '@esri/calcite-components/dist/components/calcite-dropdown-group'; +import '@esri/calcite-components/dist/components/calcite-dropdown-item'; export default class App extends Application { modulePrefix = config.modulePrefix; diff --git a/app/controllers/app/generator.ts b/app/controllers/app/generator.ts index a7fb044..1452dd0 100644 --- a/app/controllers/app/generator.ts +++ b/app/controllers/app/generator.ts @@ -3,13 +3,14 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import FontManagerService from 'text2stl/services/font-manager'; import TextMakerService from 'text2stl/services/text-maker'; -import STLExporterService from 'text2stl/services/stl-exporter'; +import FileExporterService from 'text2stl/services/file-exporter'; import { tracked } from '@glimmer/tracking'; import { trackedFunction } from 'ember-resources/util/function'; import { Registry as Services } from '@ember/service'; import type ApplicationRoute from 'text2stl/routes/app/generator'; import type IntlService from 'ember-intl/services/intl'; +import type { FileType } from 'text2stl/services/file-exporter'; export default class GeneratorController extends Controller { queryParams = ['modelSettings']; @@ -29,7 +30,7 @@ export default class GeneratorController extends Controller { @service declare fontManager: FontManagerService; - @service declare stlExporter: STLExporterService; + @service declare fileExporter: FileExporterService; @service declare intl: IntlService; @@ -68,20 +69,33 @@ export default class GeneratorController extends Controller { @tracked isFontLoading = true; + @tracked fileType: FileType = 'stl'; + + fileTypes: FileType[] = ['stl', 'obj']; + + get exportFileLabel() { + return this.intl.t('export_file', { type: this.fileType.toUpperCase() }); + } + + @action + changeFileType(fileType: FileType) { + this.fileType = fileType; + } + @action - async exportSTL() { + async exportFile() { const { value: mesh } = await this.mesh; if (!mesh) { return; } - this._gtag('event', 'stl_download', { - event_category: 'stl', // eslint-disable-line camelcase - value: this.model.type, + this._gtag('event', 'file_download', { + event_category: 'file', // eslint-disable-line camelcase + value: this.fileType, }); - this.stlExporter.downloadMeshAsSTL(mesh); + this.fileExporter.downloadMeshFile(mesh, this.fileType); } @tracked saveModalVisible = false; diff --git a/app/services/file-exporter.ts b/app/services/file-exporter.ts new file mode 100644 index 0000000..a109591 --- /dev/null +++ b/app/services/file-exporter.ts @@ -0,0 +1,59 @@ +import Service from '@ember/service'; +import { STLExporter } from 'text2stl/utils/STLExporter'; +import { OBJExporter } from 'text2stl/utils/OBJExporter'; + +import type { Mesh } from 'three'; + +export type FileType = 'stl' | 'obj'; + +export default class FileExporterService extends Service { + stlExporter = new STLExporter(); + objExporter = new OBJExporter(); + + meshToSTLBlob(mesh: Mesh, binary: boolean): Blob { + const result = this.stlExporter.parse(mesh, { binary }); + return new Blob([result], { + type: binary ? 'application/octet-stream' : 'text/plain', + }); + } + + meshToOBJBlob(mesh: Mesh): Blob { + const result = this.objExporter.parse(mesh); + return new Blob([result], { + type: 'text/plain', + }); + } + + downloadBlob(blob: Blob, name: string) { + const link = document.createElement('a'); + link.style.display = 'none'; + document.body.appendChild(link); + + link.href = URL.createObjectURL(blob); + link.download = name; + link.click(); + } + + downloadMeshFile(mesh: Mesh, type: FileType = 'stl') { + let blob: Blob; + + switch (type) { + case 'obj': + blob = this.meshToOBJBlob(mesh); + break; + default: + case 'stl': + blob = this.meshToSTLBlob(mesh, true); + break; + } + + this.downloadBlob(blob, `output.${type}`); + } +} + +// DO NOT DELETE: this is how TypeScript knows how to look up your services. +declare module '@ember/service' { + interface Registry { + 'file-exporter': FileExporterService; + } +} diff --git a/app/services/stl-exporter.ts b/app/services/stl-exporter.ts deleted file mode 100644 index 4770e83..0000000 --- a/app/services/stl-exporter.ts +++ /dev/null @@ -1,41 +0,0 @@ -import Service from '@ember/service'; -import { STLExporter } from 'text2stl/utils/STLExporter'; - -import type { Mesh } from 'three'; - -export default class STLExporterService extends Service { - exporter: STLExporter; - - constructor() { - super(); - this.exporter = new STLExporter(); - } - - meshToBlob(mesh: Mesh, binary: boolean): Blob { - const result = this.exporter.parse(mesh, { binary }); - return new Blob([result], { - type: binary ? 'application/octet-stream' : 'text/plain', - }); - } - - downloadBlob(blob: Blob, name: string) { - const link = document.createElement('a'); - link.style.display = 'none'; - document.body.appendChild(link); - - link.href = URL.createObjectURL(blob); - link.download = name; - link.click(); - } - - downloadMeshAsSTL(mesh: Mesh, name: string = 'output.stl', binary: boolean = true) { - this.downloadBlob(this.meshToBlob(mesh, binary), name); - } -} - -// DO NOT DELETE: this is how TypeScript knows how to look up your services. -declare module '@ember/service' { - interface Registry { - 'stl-exporter': STLExporterService; - } -} diff --git a/app/templates/app/generator.hbs b/app/templates/app/generator.hbs index c4027f9..ed5903a 100644 --- a/app/templates/app/generator.hbs +++ b/app/templates/app/generator.hbs @@ -1,75 +1,89 @@ - - - + + - - + + - + -
- + {{!-- {{t 'export_stl'}} - + --}} + + + + {{#each this.fileTypes as |type|}} + + {{t 'export_file_type' type=type}} + + {{/each}} + + +
- - + -
+
- + diff --git a/app/utils/OBJExporter.js b/app/utils/OBJExporter.js new file mode 100644 index 0000000..474341b --- /dev/null +++ b/app/utils/OBJExporter.js @@ -0,0 +1,225 @@ +import { Color, Matrix3, Vector2, Vector3 } from 'three'; + +class OBJExporter { + parse(object) { + let output = ''; + + let indexVertex = 0; + let indexVertexUvs = 0; + let indexNormals = 0; + + const vertex = new Vector3(); + const color = new Color(); + const normal = new Vector3(); + const uv = new Vector2(); + + const face = []; + + function parseMesh(mesh) { + let nbVertex = 0; + let nbNormals = 0; + let nbVertexUvs = 0; + + const geometry = mesh.geometry; + + const normalMatrixWorld = new Matrix3(); + + // shortcuts + const vertices = geometry.getAttribute('position'); + const normals = geometry.getAttribute('normal'); + const uvs = geometry.getAttribute('uv'); + const indices = geometry.getIndex(); + + // name of the mesh object + output += 'o ' + mesh.name + '\n'; + + // name of the mesh material + if (mesh.material && mesh.material.name) { + output += 'usemtl ' + mesh.material.name + '\n'; + } + + // vertices + + if (vertices !== undefined) { + for (let i = 0, l = vertices.count; i < l; i++, nbVertex++) { + vertex.fromBufferAttribute(vertices, i); + + // transform the vertex to world space + vertex.applyMatrix4(mesh.matrixWorld); + + // transform the vertex to export format + output += 'v ' + vertex.x + ' ' + vertex.y + ' ' + vertex.z + '\n'; + } + } + + // uvs + + if (uvs !== undefined) { + for (let i = 0, l = uvs.count; i < l; i++, nbVertexUvs++) { + uv.fromBufferAttribute(uvs, i); + + // transform the uv to export format + output += 'vt ' + uv.x + ' ' + uv.y + '\n'; + } + } + + // normals + + if (normals !== undefined) { + normalMatrixWorld.getNormalMatrix(mesh.matrixWorld); + + for (let i = 0, l = normals.count; i < l; i++, nbNormals++) { + normal.fromBufferAttribute(normals, i); + + // transform the normal to world space + normal.applyMatrix3(normalMatrixWorld).normalize(); + + // transform the normal to export format + output += 'vn ' + normal.x + ' ' + normal.y + ' ' + normal.z + '\n'; + } + } + + // faces + + if (indices !== null) { + for (let i = 0, l = indices.count; i < l; i += 3) { + for (let m = 0; m < 3; m++) { + const j = indices.getX(i + m) + 1; + + face[m] = + indexVertex + + j + + (normals || uvs + ? '/' + (uvs ? indexVertexUvs + j : '') + (normals ? '/' + (indexNormals + j) : '') + : ''); + } + + // transform the face to export format + output += 'f ' + face.join(' ') + '\n'; + } + } else { + for (let i = 0, l = vertices.count; i < l; i += 3) { + for (let m = 0; m < 3; m++) { + const j = i + m + 1; + + face[m] = + indexVertex + + j + + (normals || uvs + ? '/' + (uvs ? indexVertexUvs + j : '') + (normals ? '/' + (indexNormals + j) : '') + : ''); + } + + // transform the face to export format + output += 'f ' + face.join(' ') + '\n'; + } + } + + // update index + indexVertex += nbVertex; + indexVertexUvs += nbVertexUvs; + indexNormals += nbNormals; + } + + function parseLine(line) { + let nbVertex = 0; + + const geometry = line.geometry; + const type = line.type; + + // shortcuts + const vertices = geometry.getAttribute('position'); + + // name of the line object + output += 'o ' + line.name + '\n'; + + if (vertices !== undefined) { + for (let i = 0, l = vertices.count; i < l; i++, nbVertex++) { + vertex.fromBufferAttribute(vertices, i); + + // transform the vertex to world space + vertex.applyMatrix4(line.matrixWorld); + + // transform the vertex to export format + output += 'v ' + vertex.x + ' ' + vertex.y + ' ' + vertex.z + '\n'; + } + } + + if (type === 'Line') { + output += 'l '; + + for (let j = 1, l = vertices.count; j <= l; j++) { + output += indexVertex + j + ' '; + } + + output += '\n'; + } + + if (type === 'LineSegments') { + for (let j = 1, k = j + 1, l = vertices.count; j < l; j += 2, k = j + 1) { + output += 'l ' + (indexVertex + j) + ' ' + (indexVertex + k) + '\n'; + } + } + + // update index + indexVertex += nbVertex; + } + + function parsePoints(points) { + let nbVertex = 0; + + const geometry = points.geometry; + + const vertices = geometry.getAttribute('position'); + const colors = geometry.getAttribute('color'); + + output += 'o ' + points.name + '\n'; + + if (vertices !== undefined) { + for (let i = 0, l = vertices.count; i < l; i++, nbVertex++) { + vertex.fromBufferAttribute(vertices, i); + vertex.applyMatrix4(points.matrixWorld); + + output += 'v ' + vertex.x + ' ' + vertex.y + ' ' + vertex.z; + + if (colors !== undefined) { + color.fromBufferAttribute(colors, i).convertLinearToSRGB(); + + output += ' ' + color.r + ' ' + color.g + ' ' + color.b; + } + + output += '\n'; + } + + output += 'p '; + + for (let j = 1, l = vertices.count; j <= l; j++) { + output += indexVertex + j + ' '; + } + + output += '\n'; + } + + // update index + indexVertex += nbVertex; + } + + object.traverse(function (child) { + if (child.isMesh === true) { + parseMesh(child); + } + + if (child.isLine === true) { + parseLine(child); + } + + if (child.isPoints === true) { + parsePoints(child); + } + }); + + return output; + } +} + +export { OBJExporter }; diff --git a/tests/integration/components/settings-form/type-select-test.ts b/tests/integration/components/settings-form/type-select-test.ts index abdc561..409ea05 100644 --- a/tests/integration/components/settings-form/type-select-test.ts +++ b/tests/integration/components/settings-form/type-select-test.ts @@ -8,6 +8,7 @@ const { } = config; import TextMakerSettings from 'text2stl/models/text-maker-settings'; import { ModelType } from 'text2stl/services/text-maker'; +import wait from 'text2stl/tests/helpers/wait'; module('Integration | Component | settings-form/select-type', function (hooks) { setupRenderingTest(hooks); @@ -27,7 +28,11 @@ module('Integration | Component | settings-form/select-type', function (hooks) { .dom(`calcite-segmented-control-item[data-test-type="${ModelType.TextOnly}"]`) .hasAttribute('data-test-checked', '', 'Correct type is checked'); + // double click is a CI failing workaround await click(`calcite-segmented-control-item[data-test-type="${ModelType.TextWithSupport}"]`); + await wait(1500); + await click(`calcite-segmented-control-item[data-test-type="${ModelType.TextWithSupport}"]`); + await wait(1500); await waitUntil(() => model.type === ModelType.TextWithSupport, { timeout: 5000 }); assert.strictEqual(model.type, ModelType.TextWithSupport, 'It change model type'); }); diff --git a/tests/unit/controllers/app/generator-test.ts b/tests/unit/controllers/app/generator-test.ts index 27da8b6..ba5365a 100644 --- a/tests/unit/controllers/app/generator-test.ts +++ b/tests/unit/controllers/app/generator-test.ts @@ -76,7 +76,7 @@ module('Unit | Controller | app/generator', function (hooks) { }); test('it generate a STL with mesh', async function (assert) { - assert.expect(6); + assert.expect(7); const controller = this.owner.lookup('controller:app/generator') as GeneratorController; const model = mockTextSettings({ @@ -102,8 +102,12 @@ module('Unit | Controller | app/generator', function (hooks) { return mockedMesh; }; - this.owner.lookup('service:stl-exporter').downloadMeshAsSTL = function (mesh: Mesh) { - assert.strictEqual(mesh, mockedMesh, 'it generates STL from mesh'); + this.owner.lookup('service:file-exporter').downloadMeshFile = function ( + mesh: Mesh, + type: string | undefined, + ) { + assert.strictEqual(mesh, mockedMesh, 'it generates FILE from mesh'); + assert.strictEqual(type, 'stl', 'it generates STL by default'); }; controller._gtag = function (type: string, eventName: string, opts: { value: string }) { @@ -113,7 +117,7 @@ module('Unit | Controller | app/generator', function (hooks) { // Wait for the font to be load await waitUntil(() => controller.font.isResolved); - await controller.exportSTL(); - assert.verifySteps(['gtag_event_stl_generation_2', 'gtag_event_stl_download_2']); + await controller.exportFile(); + assert.verifySteps(['gtag_event_stl_generation_2', 'gtag_event_file_download_stl']); }); }); diff --git a/tests/unit/services/file-exporter-test.ts b/tests/unit/services/file-exporter-test.ts new file mode 100644 index 0000000..aa2a093 --- /dev/null +++ b/tests/unit/services/file-exporter-test.ts @@ -0,0 +1,50 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import type FileExporterService from 'text2stl/services/file-exporter'; +import type { STLExporter } from 'three/examples/jsm/exporters/STLExporter'; +import type { Mesh } from 'three'; + +module('Unit | Service | file-exporter', function (hooks) { + setupTest(hooks); + + test(`downloadMeshFile works [STL]`, function (assert) { + assert.expect(5); + const service = this.owner.lookup('service:file-exporter') as FileExporterService; + + service.downloadBlob = async function (blob: Blob, name: string) { + assert.strictEqual(blob.type, 'application/octet-stream', 'blob has correct type'); + assert.strictEqual(name, 'output.stl', 'blob has correct name'); + assert.strictEqual(await blob.text(), 'stl_content', 'blob contains STL'); + }; + + service.stlExporter = { + parse(mesh: string, args: { binary: boolean }) { + assert.strictEqual(mesh, 'a_mesh', 'IT calls STLExporter with mesh'); + assert.true(args.binary, 'It calls STLExporter with correct binary option'); + return 'stl_content'; + }, + } as unknown as STLExporter; + + service.downloadMeshFile('a_mesh' as unknown as Mesh, 'stl'); + }); + + test(`downloadMeshFile works [OBJ]`, function (assert) { + assert.expect(4); + const service = this.owner.lookup('service:file-exporter') as FileExporterService; + + service.downloadBlob = async function (blob: Blob, name: string) { + assert.strictEqual(blob.type, 'text/plain', 'blob has correct type'); + assert.strictEqual(name, 'output.obj', 'blob has correct name'); + assert.strictEqual(await blob.text(), 'obj_content', 'blob contains OBJ'); + }; + + service.objExporter = { + parse(mesh: string) { + assert.strictEqual(mesh, 'a_mesh', 'IT calls OBJExporter with mesh'); + return 'obj_content'; + }, + } as unknown as STLExporter; + + service.downloadMeshFile('a_mesh' as unknown as Mesh, 'obj'); + }); +}); diff --git a/tests/unit/services/stl-exporter-test.ts b/tests/unit/services/stl-exporter-test.ts deleted file mode 100644 index 4f241b4..0000000 --- a/tests/unit/services/stl-exporter-test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'ember-qunit'; -import type STLExporterService from 'text2stl/services/stl-exporter'; -import type { STLExporter } from 'three/examples/jsm/exporters/STLExporter'; -import type { Mesh } from 'three'; - -module('Unit | Service | stl-exporter', function (hooks) { - setupTest(hooks); - - for (const { binary, title } of [ - { binary: true, title: 'binary' }, - { binary: false, title: 'not binary' }, - ]) { - test(`downloadMeshAsSTL works (binary) [${title}]`, function (assert) { - assert.expect(5); - const service = this.owner.lookup('service:stl-exporter') as STLExporterService; - - service.downloadBlob = async function (blob: Blob, name: string) { - assert.strictEqual( - blob.type, - binary ? 'application/octet-stream' : 'text/plain', - 'blob has correct type', - ); - assert.strictEqual(name, 'custom-name.stl', 'blob has correct name'); - assert.strictEqual(await blob.text(), 'stl_content', 'blob contains STL'); - }; - - service.exporter = { - parse(mesh: string, args: { binary: boolean }) { - assert.strictEqual(mesh, 'a_mesh', 'IT calls STLExporter with mesh'); - assert.strictEqual( - args.binary, - binary, - 'It calls STLExporter with correct binary option', - ); - return 'stl_content'; - }, - } as unknown as STLExporter; - - service.downloadMeshAsSTL('a_mesh' as unknown as Mesh, 'custom-name.stl', binary); - }); - } -}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index a1ce851..ad2c103 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -44,7 +44,8 @@ settings_form: handle: heading: Fixation description: Handle or hole settings -export_stl: Export STL +export_file: Export {type} +export_file_type: Export as .{type} file stl_counter: '{count} STL files have been generated' info: label: About Text2STL diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 7948899..64b0824 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -44,7 +44,8 @@ settings_form: handle: heading: Fixation description: Réglage de la poigné ou du trou -export_stl: Exporter STL +export_file: Exporter {type} +export_file_type: Exporter un fichier .{type} stl_counter: '{count} fichiers STL ont été générés' info: label: À propos de Text2STL