base/latex3d.mjs

import { isStr, clTLatex } from '../core.mjs';
import { THREE, getMaterialArgs, getHelveticaFont } from './base3d.mjs';
import { isPlainText, translateLaTeX, produceLatex } from './latex.mjs';
import { ObjectPainter } from './ObjectPainter.mjs';

class TextParseWrapper {

   constructor(kind, parent, font_size) {
      this.kind = kind ?? 'g';
      this.childs = [];
      this.x = 0;
      this.y = 0;
      this.font_size = parent?.font_size ?? font_size;
      this.stroke_width = parent?.stroke_width ?? 5;
      parent?.childs.push(this);
   }

   append(kind) {
      if (kind === 'svg:g')
         return new TextParseWrapper('g', this);
      if (kind === 'svg:text')
         return new TextParseWrapper('text', this);
      if (kind === 'svg:path')
         return new TextParseWrapper('path', this);
      console.warn('missing handle for svg', kind);
   }

   style(name, value) {
      if ((name === 'stroke-width') && value)
         this.stroke_width = Number.parseInt(value);
      return this;
   }

   property(name, value) {
      if (value === undefined)
         return this[name];
      this[name] = value;
      return this;
   }

   attr(name, value) {
      const get = () => {
         if (!value)
            return '';
         const res = value[0];
         value = value.slice(1);
         return res;
      }, getN = skip => {
         let p = 0;
         while (((value[p] >= '0') && (value[p] <= '9')) || (value[p] === '-'))
            p++;
         const res = Number.parseInt(value.slice(0, p));
         value = value.slice(p);
         if (skip)
            get();
         return res;
      };

      if ((name === 'font-size') && value)
         this.font_size = Number.parseInt(value);
      else if ((name === 'transform') && isStr(value) && (value.indexOf('translate') === 0)) {
         const arr = value.slice(value.indexOf('(') + 1, value.lastIndexOf(')')).split(',');
         this.x += arr[0] ? Number.parseInt(arr[0]) * 0.01 : 0;
         this.y -= arr[1] ? Number.parseInt(arr[1]) * 0.01 : 0;
      } else if ((name === 'x') && (this.kind === 'text'))
         this.x += Number.parseInt(value) * 0.01;
      else if ((name === 'y') && (this.kind === 'text'))
         this.y -= Number.parseInt(value) * 0.01;
      else if ((name === 'fill') && (this.kind === 'text'))
         this.fill = value;
      else if ((name === 'd') && (this.kind === 'path') && (value !== 'M0,0')) {
         if (get() !== 'M')
            return console.error('Not starts with M');
         let x1 = getN(true), y1 = getN(), next;
         const pnts = [], add_line = (x2, y2) => {
            const angle = Math.atan2(y2 - y1, x2 - x1),
                  dx = 0.5 * this.stroke_width * Math.sin(angle),
                  dy = -0.5 * this.stroke_width * Math.cos(angle);
            // front side
            pnts.push(x1 - dx, y1 - dy, 0, x2 - dx, y2 - dy, 0, x2 + dx, y2 + dy, 0, x1 - dx, y1 - dy, 0, x2 + dx, y2 + dy, 0, x1 + dx, y1 + dy, 0);
            // back side
            pnts.push(x1 - dx, y1 - dy, 0, x2 + dx, y2 + dy, 0, x2 - dx, y2 - dy, 0, x1 - dx, y1 - dy, 0, x1 + dx, y1 + dy, 0, x2 + dx, y2 + dy, 0);
            x1 = x2;
            y1 = y2;
         };

         while ((next = get())) {
            switch (next) {
               case 'L':
                  add_line(getN(true), getN());
                  continue;
               case 'l':
                  add_line(x1 + getN(true), y1 + getN());
                  continue;
               case 'H':
                  add_line(getN(), y1);
                  continue;
               case 'h':
                  add_line(x1 + getN(), y1);
                  continue;
               case 'V':
                  add_line(x1, getN());
                  continue;
               case 'v':
                  add_line(x1, y1 + getN());
                  continue;
               case 'a': {
                  const rx = getN(true), ry = getN(true),
                        angle = getN(true) / 180 * Math.PI, flag1 = getN(true);
                  getN(true); // skip unused flag2
                  const x2 = x1 + getN(true),
                        y2 = y1 + getN(),
                        x0 = x1 + rx * Math.cos(angle),
                        y0 = y1 + ry * Math.sin(angle);
                  let angle2 = Math.atan2(y0 - y2, x0 - x2);
                  if (flag1 && (angle2 < angle))
                     angle2 += 2 * Math.PI;
                  else if (!flag1 && (angle2 > angle))
                     angle2 -= 2 * Math.PI;

                  for (let cnt = 0; cnt < 10; ++cnt) {
                     const a = angle + (angle2 - angle) / 10 * (cnt + 1);
                     add_line(x0 - rx * Math.cos(a), y0 - ry * Math.sin(a));
                  }
                  continue;
               }
               default:
                  console.log('not supported path operator', next);
            }
         }

         if (pnts.length) {
            const pos = new Float32Array(pnts);
            this.geom = new THREE.BufferGeometry();
            this.geom.setAttribute('position', new THREE.BufferAttribute(pos, 3));
            this.geom.scale(0.01, -0.01, 0.01);
            this.geom.computeVertexNormals();
         }
      }
      return this;
   }

   text(v) {
      if (this.kind === 'text')
         this._text = v;
   }

   collect(geoms, geom_args, as_array) {
      if (this._text) {
         geom_args.size = Math.round(0.01 * this.font_size);
         const geom = new THREE.TextGeometry(this._text, geom_args);
         if (as_array) {
            // this is latex parsing
            // while three.js uses full height, make it more like normal fonts
            geom.scale(1, 0.9, 1);
            geom.translate(0, 0.0005 * this.font_size, 0);
         }
         geom.translate(this.x, this.y, 0);
         geom._fill = this.fill;
         geoms.push(geom);
      }
      if (this.geom) {
         this.geom.translate(this.x, this.y, 0);
         this.geom._fill = this.fill;
         geoms.push(this.geom);
      }

      this.childs.forEach(chld => {
         chld.x += this.x;
         chld.y += this.y;
         chld.collect(geoms, geom_args, as_array);
      });
   }

} // class TextParseWrapper


function createLatexGeometry(painter, lbl, size, as_array, use_latex = true) {
   const geom_args = { font: getHelveticaFont(), size, height: 0, curveSegments: 5 },
         font_size = size * 100,
         node = new TextParseWrapper('g', null, font_size),
         arg = { font_size, latex: use_latex ? 1 : 0, x: 0, y: 0, text: lbl, align: ['start', 'top'], fast: true, font: { size: font_size, isMonospace: () => false, aver_width: 0.9 } },
         geoms = [];

   if (THREE.REVISION > 162)
      geom_args.depth = 0;
   else
      geom_args.height = 0;

   if (!isPlainText(lbl)) {
      produceLatex(painter, node, arg);
      node.collect(geoms, geom_args, as_array);
   }

   if (!geoms.length) {
      geom_args.size = size;
      const res = new THREE.TextGeometry(translateLaTeX(lbl), geom_args);
      return as_array ? [res] : res;
   }

   if (as_array)
      return geoms;

   if (geoms.length === 1)
      return geoms[0];

   let total_size = 0;
   geoms.forEach(geom => { total_size += geom.getAttribute('position').array.length; });

   const pos = new Float32Array(total_size),
         norm = new Float32Array(total_size);
   let indx = 0;

   geoms.forEach(geom => {
      const p1 = geom.getAttribute('position').array,
            n1 = geom.getAttribute('normal').array;
      for (let i = 0; i < p1.length; ++i, ++indx) {
         pos[indx] = p1[i];
         norm[indx] = n1[i];
      }
   });

   const fullgeom = new THREE.BufferGeometry();
   fullgeom.setAttribute('position', new THREE.BufferAttribute(pos, 3));
   fullgeom.setAttribute('normal', new THREE.BufferAttribute(norm, 3));
   return fullgeom;
}


/** @summary Build three.js object for the TLatex
  * @private */
function build3dlatex(obj, opt, painter, fp) {
   if (!painter)
      painter = new ObjectPainter(null, obj, opt);
   const handle = painter.createAttText({ attr: obj }),
         valign = handle.align % 10,
         halign = (handle.align - valign) / 10,
         text_size = handle.size > 1 ? handle.size : 2 * handle.size * (fp?.size_z3d || 100),
         arr3d = createLatexGeometry(painter, obj.fTitle, text_size || 10, true, fp || (obj._typename === clTLatex)),
         bb = new THREE.Box3().makeEmpty();

   arr3d.forEach(geom => {
      geom.computeBoundingBox();
      bb.expandByPoint(geom.boundingBox.max);
      bb.expandByPoint(geom.boundingBox.min);
   });

   let dx = 0, dy = 0;
   if (halign === 2)
      dx = 0.5 * (bb.max.x + bb.min.x);
   else if (halign === 3)
      dx = bb.max.x;

   if (valign === 2)
      dy = 0.5 * (bb.max.y + bb.min.y);
   else if (valign === 3)
      dy = bb.max.y;

   const obj3d = new THREE.Object3D(),
         materials = [],
         getMaterial = color => {
            if (!color)
               color = 'black';
            if (!materials[color])
               materials[color] = new THREE.MeshBasicMaterial(getMaterialArgs(color, { vertexColors: false }));
            return materials[color];
         };

   arr3d.forEach(geom => {
      geom.translate(-dx, -dy, 0);
      obj3d.add(new THREE.Mesh(geom, getMaterial(geom._fill || handle.color)));
   });

   return arr3d.length === 1 ? obj3d.children[0] : obj3d;
}

export { createLatexGeometry, build3dlatex };