Skip to content

Commit

Permalink
Xml based 3mf master (#131)
Browse files Browse the repository at this point in the history
Co-authored-by: Davor Hrg <[email protected]>
  • Loading branch information
Kaladum and hrgdavor authored Dec 25, 2024
1 parent f5c4c6c commit a875760
Show file tree
Hide file tree
Showing 19 changed files with 476 additions and 227 deletions.
4 changes: 2 additions & 2 deletions file-format/3mf-export-compact/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion file-format/3mf-export-compact/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"type": "module",
"sideEffects": false,
"name": "@jscadui/3mf-export",
"name": "@jscadui/3mf-export-compact",
"version": "0.5.0",
"description": "3mf export",
"main": "index.js",
Expand Down
45 changes: 45 additions & 0 deletions file-format/3mf-export-compact/testSpeed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @ts-nocheck

// if not in browser
import { to3dmodel, to3mfZipContentSimple } from './index.js'

function multiply(arr, mult, func){
let out = new func(arr.length * mult)
for(let i=0; i<mult; i++){
out.set(arr, i * arr.length)
}
return out
}

let verticesData = [
-1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, 1, -1, -1, 1, 1, -1, 1, 1, 1, 1, -1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1,
-1, -1, 1, -1, 1, -1, -1, 1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, 1, -1, 1, 1, -1, 1, -1, -1, -1, -1, 1, 1, -1, 1, 1,
1, 1, -1, 1, 1,
]

let indicesData = [
0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7, 8, 9, 10, 8, 10, 11, 12, 13, 14, 12, 14, 15, 16, 17, 18, 16, 18, 19, 20, 21, 22,
20, 22, 23,
]

function oneRun(name,vertices, indices){

let start = performance.now()
const zipContents = to3dmodel({
meshes: [{ vertices, indices, id: 1 }],
header: { application: 'jscad.app', title: 'jscad model' }
})
console.log(name)
console.log('count', vertices.length+indices.length, 'vertices', vertices.length, 'indices', indices.length)
console.log(Math.round(performance.now() - start), 'len',zipContents.length)
console.log('')
}

let vertices = multiply(verticesData, 99,Float32Array)
let indices = multiply(indicesData, 99,Uint32Array)
oneRun('warmup',vertices, indices)
vertices = multiply(verticesData, 9999,Float32Array)
indices = multiply(indicesData, 9999,Uint32Array)
oneRun('test1',vertices, indices)
oneRun('test2',vertices, indices)

20 changes: 3 additions & 17 deletions file-format/3mf-export/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 3mf export MVP
[![npm version](https://badge.fury.io/js/@jscadui%2F3mf-export.svg)](https://www.npmjs.com/package/@jscadui%2F3mf-export) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

This is a set of functions to generate 3mf content for exporting 3d models and optionally embeding a thumbnail.
This is a set of functions to generate 3mf content for exporting 3d models and optionally embedding a thumbnail.

reference: https://github.com/3MFConsortium/spec_core/blob/master/3MF%20Core%20Specification.md

Expand Down Expand Up @@ -32,20 +32,6 @@ Sample code to generate 3mf file is in [testGen.js]. It uses `fflate` which is i
devDependency so others can generate the zip file with own preferred library.

To demonstrate that it is simple to include a thumbnail, I am using one already generated by my old jscad prototype
where this 3mf code is from. Final usage will of course be to tak a snapshot from canvas while exporting the mesh.
where this 3mf code is from. Final usage will of course be to take a snapshot from canvas while exporting the mesh.
Sample of such code is left in [testGen.js] but unused.
![testThumbnail.png](testThumbnail.png)

# array of strings and `Array.join('')`

`Array.join('')`

After multiple explorations and tests it looks like is the best choice to combine large number of strings. It can calculate size of final string in advance. It is faster or at least very similar to other, and can handle more than 65536 strings.

`String.concat()`

Also can calculate size of new string in advance. At the time of writing this library it had similar performance in Chrome but was much slower than `Array.join('')`. It has one big drawback because it is limited to 65536 elements due to using varargs input.

` str +=`

is slowest as it has to copy old data again and again for each step.
![testThumbnail.png](testThumbnail.png)
184 changes: 113 additions & 71 deletions file-format/3mf-export/index.js
Original file line number Diff line number Diff line change
@@ -1,123 +1,165 @@
// this implementation exports to 3mf by filling array of strings and doing join
// at the encoding tests for large files have shown significant speedup related
// to using string concatenation
import { makeItem } from './src/makeItem.js'
import { pushHeader } from './src/pushHeader.js'
import { pushObjectWithComponents } from './src/pushObjectComponent.js'
import { pushObjectWithMesh } from './src/pushObjectMesh.js'
// This import should be
// import {XMLBuilder} from 'fast-xml-parser'
// but then tree shaking is not working correctly. Therefore this hack is required.
import XMLBuilder from 'fast-xml-parser/src/xmlbuilder/json2xml.js'

import { genItem } from './src/makeItem.js'
import { genModel } from './src/pushHeader.js'
import { genObjectWithComponents } from './src/pushObjectComponent.js'
import { genObjectWithMesh } from './src/pushObjectMesh.js'
import { fileForContentTypes } from './src/staticFiles.js'

export * from './src/staticFiles.js'

/**
* @typedef Mesh3MF
* @prop {string} id
* @prop {number} id
* @prop {Float32Array} vertices
* @prop {Uint32Array} indices
* @prop {string} [name]
*
* @typedef Mesh3MFSimpleExt
* @prop {mat4} transform
* @prop {import('./src/defMatrix.js').mat4} [transform]
*
* @typedef {Mesh3MF & Mesh3MFSimpleExt} Mesh3MFSimple
*
* @typedef Component3MF
* @prop {string} id
* @prop {number} id
* @prop {string} name
* @prop {Array<Child3MF>} children
*
* @typedef Child3MF
* @prop {string} objectID
* @prop {mat4} transform
* @prop {number} objectID
* @prop {import('./src/defMatrix.js').mat4} [transform]
*
* @typedef To3MF
* @prop {Array<Mesh3MF>} meshes - manually declare meshes
* @prop {Array<Component3MF>} [components] - components can combine items
* @prop {Array<Child3MF>} items - output items, each pointing to component or mesh with objectID
* @prop {number} precision
* @prop {import('./src/pushHeader.js').Header} header
* @prop {number} [precision]
* @prop {import('./src/pushHeader.js').Header} [header]
*/

/**
* @param {To3MF} options
* @returns string
* @returns {string}
*/
export function to3dmodel({ meshes = [], components = [], items = [], precision = 17, header }) {
// items to be placed on the scene (build section of 3mf)
const out = []
/** @type {import('./xml-schema-3mf.js').Xml3mf} */
const data = {
'?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
model: genModel(
header ?? {},
[
//Mesh objects
...meshes.map(({ id, vertices, indices, name }) => ({
object: genObjectWithMesh(id, vertices, indices, precision, name)
})),
//Component objects
...components.map(({ id, children, name }) => ({
object: genObjectWithComponents(id, children, name)
})),
],
{ item: items.map(v => genItem(v.objectID, v.transform)) },
)
}

// <model> tag is opened here
pushHeader(out, header)
const builder = new XMLBuilder({
ignoreAttributes: false,
format: true,
suppressEmptyNode: true
})

// #region resources
out.push(' <resources>\n')
return builder.build(data)
}

if (items.length == 0) {
console.error('3MF empty build! Include items or simple.')
}
/**
* Simple export provided meshes that have transform attached to them and autocreate items and pass to to3dmodel.
* @param {Array<Mesh3MFSimple>} meshes
* @param {import('./src/pushHeader.js').Header} [header]
* @param {number} [precision]
* @returns {string}
*/
export function to3dmodelSimple(meshes, header, precision) {
/** @type {Child3MF[]} */
const items = meshes.map(({ id, transform }) => ({ objectID: id, transform }))

meshes.forEach(({ id, vertices, indices, name }) => pushObjectWithMesh(out, id, vertices, indices, precision, name))
return to3dmodel({ meshes, items, header, precision })
}

components.forEach(({ id, children, name }) => {
pushObjectWithComponents(out, id, children, name)
})
/**
* @typedef {[string,Uint8Array,boolean]} ZipContent FileName, FileContent, CanBeCompressed
*/

out.push(' </resources>\n')
// #endregion
/**
* @param {To3MF} options
* @param {Uint8Array | undefined} [thumbnailPng]
* @returns {ZipContent[]}
*/
export function to3mfZipContent(options, thumbnailPng) {
/** @type {ZipContent[]} */
const result = []
const utf8Encoder = new TextEncoder()

out.push(`<build>\n`)
items.forEach(({ objectID, transform }) => {
out.push(makeItem(objectID, transform))
})
out.push('</build>\n')
result.push([fileForContentTypes.name, utf8Encoder.encode(fileForContentTypes.content), true])

out.push('</model>\n') // model tag was opened in the pushHeader()
const fileForRelThumbnail = new FileForRelThumbnail()
fileForRelThumbnail.add3dModel('3D/3dmodel.model')

return out.join('')
if (thumbnailPng !== undefined) {
result.push(['Metadata/thumbnail.png', thumbnailPng, false])
fileForRelThumbnail.addThumbnail('Metadata/thumbnail.png')
}

result.push(['3D/3dmodel.model', utf8Encoder.encode(to3dmodel(options)), true])
result.push([fileForRelThumbnail.name, utf8Encoder.encode(fileForRelThumbnail.content), true])

return result
}

/** Simple export provided meshes that have transform attached to them and autocreate items and pass to to3dmodel.
*
* @param {Array<Mesh3MFSimple>} meshes
* @param {import('./src/pushHeader.js').Header} header
* @param {number} precision
* @returns string
/**
* @param {{meshes:Array<Mesh3MFSimple>,header?:import('./src/pushHeader.js').Header,precision?:number}} options
* @param {Uint8Array | undefined} [thumbnailPng]
* @returns {ZipContent[]}
*/
export function to3dmodelSimple(meshes, header, precision) {
const items = []
meshes.forEach(({ id, transform }) => {
items.push({ objectID: id, transform })
})
return to3dmodel({ meshes, items, header, precision })
export function to3mfZipContentSimple({ meshes, header, precision }, thumbnailPng) {
const items = meshes.map(({ id, transform }) => ({ objectID: id, transform }))

return to3mfZipContent({ meshes, items, header, precision }, thumbnailPng)
}

/** File that describes file relationships inside a 3mf */
export class FileForRelThumbnail {
constructor() {
this.idSeq = 0
this.lines = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`,
]
}
get name() {
return '_rels/.rels'
}
/**
*
* @param {*} target file path
* @param {*} xmlType xml schema url
idSeq = 0
name = '_rels/.rels'

/** @type {import('./xml-schema-3mf.js').Xml3mfRelationFile} */
data = {
'?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
Relationships: {
'@_xmlns': 'http://schemas.openxmlformats.org/package/2006/relationships',
Relationship: []
},
}

/**
* @param {string} target file path
* @param {string} xmlType xml schema url
*/
addRel(target, xmlType) {
this.lines.push(` <Relationship Target="${target}" Id="rel-${++this.idSeq}" Type="${xmlType}" />`)
}
add3dModel(path) {
this.addRel(path, 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel')
}
addThumbnail(path) {
this.addRel(path, 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail')
this.data.Relationships.Relationship.push(
{ '@_Target': target, "@_Id": `rel-${++this.idSeq}`, '@_Type': xmlType }
)
}

/** @param {string} path */
add3dModel = (path) => this.addRel(path, 'http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel')

/** @param {string} path */
addThumbnail = (path) => this.addRel(path, 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail')

get content() {
return this.lines.join('\n') + `\n</Relationships>`
const builder = new XMLBuilder({ ignoreAttributes: false, format: true, suppressEmptyNode: true })
return builder.build(this.data)
}
}
5 changes: 5 additions & 0 deletions file-format/3mf-export/module-fix.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//This is required for the import tree shaking hack
declare module 'fast-xml-parser/src/xmlbuilder/json2xml.js' {
import { XMLBuilder } from 'fast-xml-parser'
export default XMLBuilder
}
5 changes: 4 additions & 1 deletion file-format/3mf-export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@
"@types/node": "18.11.9",
"@trivago/prettier-plugin-sort-imports": "~3.3.0"
},
"license": "MIT"
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^4.5.0"
}
}
5 changes: 5 additions & 0 deletions file-format/3mf-export/src/defMatrix.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
/**
* @typedef {number []} mat4
*/

/* prettier-ignore */
/** @type {mat4} */
export const defMatrix = [
1, 0, 0, 0,
0, 1, 0, 0,
Expand Down
15 changes: 8 additions & 7 deletions file-format/3mf-export/src/makeItem.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { defMatrix } from './defMatrix.js'
import { matrix2str } from './matrix2str.js'

/**
*
* @param {string} id - must start with 1, can not be zero by spec
* @param {mat4} matrix
* @returns
* @param {number} id - must start with 1, can not be zero by spec
* @param {import('./defMatrix.js').mat4} [matrix]
* @returns {import('../xml-schema-3mf.js').Xml3mfItem}
*/
export const makeItem = (id = 1, matrix = defMatrix) =>
` <item objectid="${id}" transform="${matrix2str(matrix)}" />\n`
export const genItem = (id, matrix) => ({
'@_objectid': id,
'@_transform': matrix !== undefined ? matrix2str(matrix) : undefined,
})
Loading

0 comments on commit a875760

Please sign in to comment.