io/3mf-serializer/src/index.js

/*
JSCAD Object to 3MF (XML) Format Serialization

## License

Copyright (c) 2022 JSCAD Organization https://github.com/jscad

All code released under MIT license

Notes:
1) geom2 conversion to:
     none
2) geom3 conversion to:
     mesh
3) path2 conversion to:
     none
*/

/**
 * Serializer of JSCAD geometries to 3D manufacturing format (XML)
 *
 * The serialization of the following geometries are possible.
 * - serialization of 3D geometry (geom3) to 3MF object (a unique mesh containing both vertices and triangles)
 *
 * Colors are added to base materials when found on the 3D geometry, i.e. attribute color.
 * Names are added to meshs when found on the 3D geometry, i.e. attribute name.
 *
 * @module io/3mf-serializer
 * @example
 * const { serializer, mimeType } = require('@jscad/3mf-serializer')
 */


const zipSync = require('fflate').zipSync
const strToU8 = require('fflate').strToU8

const stringify = require('onml/lib/stringify')

const { colors, geometries, modifiers } = require('@jscad/modeling')
const { flatten, toArray } = require('@jscad/array-utils')


const mimeType = 'model/3mf'
const fileExtension = '3mf'

/**
 * Serialize the give objects to 3MF contents (XML) or 3MF packaging (OPC).
 * @see https://3mf.io/specification/
 * @param {Object} [options] - options for serialization
 * @param {String} [options.unit='millimeter'] - unit of design; millimeter, inch, feet, meter or micrometer
 * @param {Boolean} [options.metadata=true] - add metadata to 3MF contents, such at CreationDate
 * @param {Array} [options.defaultcolor=[0,0,0,1]] - default color for objects
 * @param {Boolean} [options.compress=true] - package and compress the contents
 * @param {Object|Array} objects - objects to serialize into 3D manufacturing format
 * @returns {Array} serialized contents, 3MF contents (XML) or 3MF packaging (ZIP)
 * @example
 * const geometry = primitives.cube()
 * const package = serializer({unit: 'meter'}, geometry) // 3MF package, ZIP format
 */
const serialize = (options, ...objects) => {
  const defaults = {
    unit: 'millimeter', // micron, millimeter, centimeter, inch, foot, meter
    metadata: true,
    defaultcolor: [255/255, 160/255, 0, 1], // JSCAD Orange
    compress: true
  }
  options = Object.assign({}, defaults, options)

  objects = flatten(objects)

  // convert only 3D geometries
  let objects3d = objects.filter((object) => geometries.geom3.isA(object))

  if (objects3d.length === 0) throw new Error('only 3D geometries can be serialized to 3MF')
  if (objects.length !== objects3d.length) console.warn('some objects could not be serialized to 3MF')

  // convert to triangles
  objects = toArray(modifiers.generalize({ snap: true, triangulate: true }, objects3d))

  // construct the contents of the 3MF 'model'
  const body = ['model',
    {
      unit: options.unit,
      'xml:lang': 'und'
    },
    ['metadata', { name: 'Application' }, 'JSCAD']
  ]
  if (options.metadata) {
    body.push(['metadata', { name: 'CreationDate' }, new Date().toISOString()])
  }
  body.push(translateResources(objects, options))
  body.push(translateBuild(objects, options))

  // convert the contents to 3MF (XML) format
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
${stringify(body, 2)}`

  // compress and package the contents if requested
  if (options.compress) {
    const data = {
      '3D': {
        '3dmodel.model': strToU8(xml)
      },
      '_rels': {
        '.rels': strToU8(rels)
      },
      '[Content_Types].xml': strToU8(contenttype)
    }
    const opts = {
      comment: 'created by JSCAD'
    }
    const zipData = zipSync(data, opts)
    return [zipData.buffer]
  }
  return [xml]
}

const contenttype = `<?xml version="1.0" encoding="UTF-8" ?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml">
  </Default>
  <Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml">
  </Default>
</Types>`

const rels = `<?xml version="1.0" encoding="UTF-8" ?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel">
  </Relationship>
</Relationships>`

const translateResources = (objects, options) => {
  let resources = ['resources', {}, translateMaterials(objects, options)]
  resources = resources.concat(translateObjects(objects, options))
  return resources
}

const translateMaterials = (objects, options) => {
  let basematerials = ['basematerials', { id: '0' }]

  const materials = []
  objects.forEach((object, i) => {
    let srgb = colors.rgbToHex(options.defaultcolor).toUpperCase()
    if (object.color) {
      srgb = colors.rgbToHex(object.color).toUpperCase()
    }
    materials.push(['base', { name: `mat${i}`, displaycolor: srgb }])
  })

  basematerials = basematerials.concat(materials)
  return basematerials
}

const translateObjects = (objects, options) => {
  const contents = []
  objects.forEach((object, i) => {
    if (geometries.geom3.isA(object)) {
      const polygons = geometries.geom3.toPolygons(object)
      if (polygons.length > 0) {
        options.id = i
        contents.push(convertToObject(object, options))
      }
    }
  })
  return contents
}

const translateBuild = (objects, options) => {
  let build = ['build', { }]

  const items = []
  objects.forEach((object, i) => {
    items.push(['item', { objectid: `${i + 1}` }])
  })

  build = build.concat(items)
  return build
}

/*
 * This section converts each 3D geometry to object / mesh
 */

const convertToObject = (object, options) => {
  const name = object.name ? object.name : `Part ${options.id}`
  const contents = ['object', { id: `${options.id + 1}`, type: 'model', pid: '0', pindex: `${options.id}`, name: name }, convertToMesh(object, options)]
  return contents
}

const convertToMesh = (object, options) => {
  const contents = ['mesh', {}, convertToVertices(object, options), convertToVolumes(object, options)]
  return contents
}

/*
 * This section converts each 3D geometry to mesh vertices
 */

const convertToVertices = (object, options) => {
  const contents = ['vertices', {}]

  const vertices = []
  const polygons = geometries.geom3.toPolygons(object)
  polygons.forEach((polygon) => {
    for (let i = 0; i < polygon.vertices.length; i++) {
      vertices.push(convertToVertex(polygon.vertices[i], options))
    }
  })

  return contents.concat(vertices)
}

const convertToVertex = (vertex, options) => {
  const contents = ['vertex', { x: vertex[0], y: vertex[1], z: vertex[2] }]
  return contents
}

/*
 * This section converts each 3D geometry to mesh triangles
 */

const convertToVolumes = (object, options) => {
  let n = 0
  const polygons = geometries.geom3.toPolygons(object)

  let contents = ['triangles', {}]
  polygons.forEach((polygon) => {
    if (polygon.vertices.length < 3) {
      return
    }

    const triangles = convertToTriangles(polygon, n)

    contents = contents.concat(triangles)

    n += polygon.vertices.length
  })
  return contents
}

const convertToTriangles = (polygon, index) => {
  const contents = []

  // making sure they are all triangles (triangular polygons)
  for (let i = 0; i < polygon.vertices.length - 2; i++) {
    const triangle = ['triangle', { v1: index, v2: (index + i + 1), v3: (index + i + 2) }]
    contents.push(triangle)
  }
  return contents
}

module.exports = {
  serialize,
  mimeType,
  fileExtension
}