io/x3d-serializer/src/index.js

/*
JSCAD Object to X3D (XML) Format Serialization

## License

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

All code released under MIT license

Notes:
1) geom2 conversion to:
     Polyline2D with lineSegment and Color
2) geom3 conversion to:
     IndexedTriangleSet with Coordinates and Colors
3) path2 conversion to:
     Polyline2D with lineSegment and Color
*/

/**
 * Serializer of JSCAD geometries to X3D source data (XML).
 *
 * The serialization of the following geometries are possible.
 * - serialization of 3D geometries (geom3) to X3D IndexedTriangleSet (a unique mesh containing coordinates)
 * - serialization of 2D geometries (geom2) to X3D Polyline2D
 * - serialization of 2D paths (path2) to X3D Polyline2D
 *
 * Material (color) is added to X3D shapes when found on the geometry.
 *
 * @module io/x3d-serializer
 * @example
 * const { serializer, mimeType } = require('@jscad/x3d-serializer')
 */

const { geometries, modifiers } = require('@jscad/modeling')
const { geom2, geom3, path2, poly2, poly3 } = geometries

const { flatten } = require('@jscad/array-utils')

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

// http://www.web3d.org/x3d/content/X3dTooltips.html
// http://www.web3d.org/x3d/content/examples/X3dSceneAuthoringHints.html#Meshes
// https://x3dgraphics.com/examples/X3dForWebAuthors/Chapter13GeometryTrianglesQuadrilaterals/

const mimeType = 'model/x3d+xml'
const defNames = new Map()

/**
 * Serialize the give objects to X3D elements (XML).
 * @param {Object} options - options for serialization, REQUIRED
 * @param {Array} [options.color=[0,0,1,1]] - default color for objects
 * @param {Number} [options.shininess=8/256] - x3d shininess for specular highlights
 * @param {Boolean} [options.smooth=false] - use averaged vertex normals
 * @param {Number} [options.decimals=1000] - multiplier before rounding to limit precision
 * @param {Boolean} [options.metadata=true] - add metadata to 3MF contents, such at CreationDate
 * @param {String} [options.unit='millimeter'] - unit of design; millimeter, inch, feet, meter or micrometer
 * @param {Function} [options.statusCallback] - call back function for progress ({ progress: 0-100 })
 * @param {Object|Array} objects - objects to serialize as X3D
 * @returns {Array} serialized contents, X3D format (XML)
 * @alias module:io/x3d-serializer.serialize
 * @example
 * const geometry = primitives.cube()
 * const x3dData = serializer({unit: 'meter'}, geometry)
 */
const serialize = (options, ...objects) => {
  const defaults = {
    color: [0, 0, 1, 1.0], // default colorRGBA specification
    shininess: 8 / 256,
    smooth: false,
    decimals: 1000,
    metadata: true,
    unit: 'millimeter', // millimeter, inch, feet, meter or micrometer
    statusCallback: null
  }
  options = Object.assign({}, defaults, options)

  objects = flatten(objects)

  objects = objects.filter((object) => geom3.isA(object) || geom2.isA(object) || path2.isA(object))

  if (objects.length === 0) throw new Error('expected one or more geom3/geom2/path2 objects')

  options.statusCallback && options.statusCallback({ progress: 0 })

  // construct the contents of the XML
  let body = ['X3D',
    {
      profile: 'Interchange',
      version: '3.3',
      'xmlns:xsd': 'http://www.w3.org/2001/XMLSchema-instance',
      'xsd:noNamespaceSchemaLocation': 'http://www.web3d.org/specifications/x3d-3.3.xsd'
    }
  ]
  if (options.metadata) {
    body.push(['head', {},
      ['meta', { name: 'creator', content: 'Created by JSCAD' }],
      ['meta', { name: 'reference', content: 'https://www.openjscad.xyz' }],
      ['meta', { name: 'created', content: new Date().toISOString() }]
    ])
  } else {
    body.push(['head', {},
      ['meta', { name: 'creator', content: 'Created by JSCAD' }]
    ])
  }
  body = body.concat(convertObjects(objects, options))

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

  options && options.statusCallback && options.statusCallback({ progress: 100 })

  return [contents]
}

const convertObjects = (objects, options) => {
  const shapes = []
  objects.forEach((object, i) => {
    options.statusCallback && options.statusCallback({ progress: 100 * i / objects.length })

    if (geom3.isA(object)) {
      // convert to triangles
      object = modifiers.generalize({ snap: true, triangulate: true }, object)
      const polygons = geom3.toPolygons(object)
      if (polygons.length > 0) {
        shapes.push(convertGeom3(object, options))
      }
    }
    if (geom2.isA(object)) {
      shapes.push(convertGeom2(object, options))
    }
    if (path2.isA(object)) {
      shapes.push(convertPath2(object, options))
    }
  })
  const transform = ['Transform', { rotation: '1 0 0 -1.5708' }, ...shapes]
  const scene = ['Scene', {}, transform]
  return [scene]
}

/*
 * Convert the given object (path2) to X3D source
 */
const convertPath2 = (object, options) => {
  const points = path2.toPoints(object).slice()
  if (points.length > 1 && object.isClosed) points.push(points[0])
  const shape = ['Shape', shapeAttributes(object), convertPolyline2D(poly2.create(points), options)]
  if (object.color) {
    shape.push(convertAppearance(object, 'emissiveColor', options))
  }
  return shape
}

/*
 * Convert the given object (geom2) to X3D source
 */
const convertGeom2 = (object, options) => {
  const outlines = geom2.toOutlines(object)
  const group = ['Group', {}]
  outlines.forEach((outline) => {
    if (outline.length > 1) outline.push(outline[0]) // close the outline for conversion
    const shape = ['Shape', shapeAttributes(object), convertPolyline2D(poly2.create(outline), options)]
    if (object.color) {
      shape.push(convertAppearance(object, 'emissiveColor', options))
    }
    group.push(shape)
  })
  return group
}

/*
 * generate attributes for Shape node
 */

const shapeAttributes = (object, attributes = {}) => {
  if (object.id) {
    Object.assign(attributes, { DEF: checkDefName(object.id) })
  }
  return attributes
}

const checkDefName = (defName) => {
  const count = defNames.get(defName) || 0
  defNames.set(defName, count + 1)
  if (count > 0) console.warn(`Warning: object.id set as DEF but not unique. ${defName} set ${count + 1} times.`)
  return defName
}

/*
 * Convert the given object (poly2) to X3D source
 */
const convertPolyline2D = (object, options) => {
  const lineSegments = object.vertices.map((p) => `${p[0]} ${p[1]}`).join(' ')
  return ['Polyline2D', { lineSegments }]
}

/*
 * Convert color to Appearance
 */
const convertAppearance = (object, colorField, options) => {
  const colorRGB = object.color.slice(0, 3)
  const color = colorRGB.join(' ')
  const transparency = roundToDecimals(1.0 - object.color[3], options)
  const materialFields = { [colorField]: color, transparency }
  if (colorField === 'diffuseColor') {
    Object.assign(
      materialFields,
      { specularColor: '0.2 0.2 0.2', shininess: options.shininess })
  }
  return ['Appearance', ['Material', materialFields]]
}

/*
 * Convert the given object (geom3) to X3D source
 */
const convertGeom3 = (object, options) => {
  const shape = ['Shape', shapeAttributes(object), convertMesh(object, options)]
  let appearance = ['Appearance', {}, ['Material']]
  if (object.color) {
    appearance = convertAppearance(object, 'diffuseColor', options)
  }
  shape.push(appearance)
  return shape
}

const convertMesh = (object, options) => {
  const mesh = convertToTriangles(object, options)
  const lists = polygons2coordinates(mesh, options)

  const indexList = lists[0].join(' ')
  const pointList = lists[1].join(' ')
  const colorList = lists[2].join(' ')

  const faceset = [
    'IndexedTriangleSet',
    { ccw: 'true', colorPerVertex: 'false', normalPerVertex: options.smooth, solid: 'false', index: indexList },
    ['Coordinate', { point: pointList }]
  ]
  if (!object.color) {
    faceset.push(['Color', { color: colorList }])
  }
  return faceset
}

const convertToTriangles = (object, options) => {
  const triangles = []
  const polygons = geom3.toPolygons(object)
  polygons.forEach((poly) => {
    const firstVertex = poly.vertices[0]
    for (let i = poly.vertices.length - 3; i >= 0; i--) {
      const triangle = poly3.fromPoints([
        firstVertex,
        poly.vertices[i + 1],
        poly.vertices[i + 2]
      ])

      let color = options.color
      if (object.color) color = object.color
      if (poly.color) color = poly.color
      triangle.color = color

      triangles.push(triangle)
    }
  })
  return triangles
}

const convertToColor = (polygon, options) => {
  let color = options.color
  if (polygon.color) color = polygon.color

  return `${color[0]} ${color[1]} ${color[2]}`
}

const roundToDecimals = (float, options) => Math.round(float * options.decimals) / options.decimals

/*
 * This function converts the given polygons into three lists
 * - indexList : index of each vertex in the triangle (tuples)
 * - pointList : coordinates of each vertex (X Y Z)
 * - colorList : color of each triangle (R G B)
 */
const polygons2coordinates = (polygons, options) => {
  const indexList = []
  const pointList = []
  const colorList = []

  const vertexTagToCoordIndexMap = new Map()
  polygons.forEach((polygon) => {
    const polygonVertexIndices = []
    const numvertices = polygon.vertices.length
    for (let i = 0; i < numvertices; i++) {
      const vertex = polygon.vertices[i]
      const id = `${vertex[0]},${vertex[1]},${vertex[2]}`

      // add the vertex to the list of points (and index) if not found
      if (!vertexTagToCoordIndexMap.has(id)) {
        const x = roundToDecimals(vertex[0], options)
        const y = roundToDecimals(vertex[1], options)
        const z = roundToDecimals(vertex[2], options)
        pointList.push(`${x} ${y} ${z}`)
        vertexTagToCoordIndexMap.set(id, pointList.length - 1)
      }
      // add the index (of the vertex) to the list for this polygon
      polygonVertexIndices.push(vertexTagToCoordIndexMap.get(id))
    }
    indexList.push(polygonVertexIndices.join(' '))
    colorList.push(convertToColor(polygon, options))
  })
  vertexTagToCoordIndexMap.clear()

  return [indexList, pointList, colorList]
}

module.exports = {
  serialize,
  mimeType
}