Plotting 4D Graphs With QOpenGLWidget

A 4D graph can be used for plotting a graph of a complex function, with complex numbers as its input and output. A complex number can be represented as a pair of two real numbers:

  • A real part and an imaginary part.
  • A magnitude and an argument.

An image has a width and a height, but you can add an illusion of depth using a linear transform and changes in hue. A common way to add the fourth dimension is to use colors to represent its value. If you use a color for the fourth dimension, you want to add a legend to tell the viewer how to estimate the value of the fourth coordinate.

A good example of one such graph is the graph of the Lambert function found in the Wikipedia.

The Lambert W function is defined as follows:

x=ye^y\Rightarrow y=W_n(x)

where n \in \mathbb{Z} is the branch number.

In this post, I will discuss the use of Qt5 modules in creating my version of the graph:

The graph is not perfect and complete, but in this post I will explain how to create the components of the graph.

Combining OpenGL and 2D Painting

In Qt5, the OpenGL widget is a painting device like any other widget. So, you can use a painter of class QPainter to paint 2D shapes on it. The painter is useful if you want to add text to an image. Remember to unbind every GL object (vertex array objects, buffers, textures) before painting with the painter.

Before painting with the GL functions, call the painter’s method beginNativePainting. After finishing the use of GL functions, call endNativePainting

If you want the text rotation, you can use SVG. A simple way to add SVG text is using the QSvgRenderer class, that can create an SVG image from a file or a string.

Single Points

You can draw single points in OpenGL. To do so, you must enable GL_PROGRAM_POINT_SIZE using glEnable in your calling program, and define glPointSize in the vertex shader.

Matrices

The main types of matrices used in my example are:

  • Frustum – A perspective matrix defined using the minimum and maximum values of coordinates.
  • Viewport – a matrix that transforms the coordinates used by GL into the coordinates of the input rectangle. That transform will be used for placing the axis ticks snd texts.

The Program

The main window is a Qt Widget, so

The QMake File: qmake.pro:

The following definition will be used for creating the make file.

TARGET=executable
SOURCES=main.cpp
HEADERS=graph.h
HEADERS+=shaders.h
HEADERS+=svg.h
  
DESTDIR=bin
QT=core
QT+=gui
QT+=svg

greaterThan(QT_MAJOR_VERSION, 4): QT+=widgets

The main Header File

The header files ‘graph.h’ contains some definition to be used by the main program and the widget extending the QT Widget:

#include <QtWidgets/QOpenGLWidget>
#include <QOpenGLBuffer>
#include <QOpenGLVertexArrayObject>
#include <QOpenGLShader>
#include <QOpenGLShaderProgram>
#include <QOpenGLTexture>
#include <QPainter>
#include <QtSvg>
#include <QStyleOptionGraphicsItem>

struct graph_point {
  GLfloat coords[3];
  GLfloat hue;
};

struct vertex2D {
  GLfloat coords[2];
  GLfloat hue;
};

struct axis_ticks {
  GLfloat rect_coords[3];
  GLfloat texture_coords[2];
};

class GraphWidget:public QOpenGLWidget{
private:
  QOpenGLVertexArrayObject m_vao;
  QOpenGLBuffer m_gradient_vbo;
  QOpenGLBuffer m_point_vbo;
  QOpenGLBuffer m_ibo;
  
  QOpenGLShader *m_vertexShader;
  QOpenGLShader *m_fragmentShader;
  QOpenGLShaderProgram *m_program;
  QPainter m_painter;
  QFont m_font;
  int fontSize;
  int m_width, m_height;
  float legend_left;
  QMatrix4x4 transform;
  QMatrix4x4 viewport;

  void addLegend(void);
  void populatePointBuffer(void);
  void paintGraph(void);
  void draw3DLine(QVector4D fromVec, QVector4D toVec);
  void addAxes(void);
  void add_x_ticks(void);
  void add_y_ticks(void);
  void add_z_ticks(void);
  void add_z_text(void);
  
public:
  GraphWidget(QWidget *parent=nullptr);
  ~GraphWidget();
  void initializeGL();
  void paintGL();
  void resizeGL(int w, int h);
};

HSV to RGB

The fourth coordinate of our graph represents arg(W(z)), an angle. In the graph we’ll use HSV (Hue, Saturation, Value) because the hue is given by the value of an angle. A shader-language function to covert from HSV to RGB is defined in file “shaders.h“:

#define TO_RGB_FUNC					\
  "#version 140\n"					\
  "vec4 to_rgb(float hue){ \n"				\
  "float s=1.0;\n"					\
  "float v=1.0;\n"					\
  "float c=v*s;\n"					\
  "float   x=c*(1-abs(mod(3*hue,2)-1));\n"		\
  "float   m=v-c;\n"					\
  "vec3  tempRGB=hue< -2./3?  vec3(0,x,c):\n"		\
  "              hue< -1./3?  vec3(x,0,c): \n"		\
  "              hue<     0?  vec3(c,0,x): \n"		\
  "              hue<  1./3?  vec3(c,x,0): \n"		\
  "              hue<  2./3?  vec3(x,c,0): \n"		\
  "		              vec3(0,c,x); \n"		\
  "\n"							\
  "return vec4(tempRGB+vec3(m,m,m),1);\n"		\
  "}\n"

The shader fragment for both the graph points and the legend is defined as follows:

#define FRAGMENT_SHADER				\
  TO_RGB_FUNC					\
  "varying float f_hue;\n"			\
  "void main(void){\n"				\
  "    gl_FragColor=to_rgb(f_hue);\n"		\
  "}\n"

Main Program Includes and Definitions

#include <stdio.h>

#include <graph.h>
#include <QApplication>
#include "shaders.h"
#include "svg.h"
#include <complex>
#include <iostream>


#define MIN_WIDTH 640
#define MIN_HEIGHT 480

using namespace std;

Constructor and Destructor

GraphWidget::GraphWidget(QWidget *parent):
  QOpenGLWidget(parent),
  m_vao(),
  m_gradient_vbo(),
  m_point_vbo(),
  m_ibo(QOpenGLBuffer::IndexBuffer),
  m_font()
{
  setGeometry(0,0,MIN_WIDTH,MIN_HEIGHT);
  setMinimumWidth(MIN_WIDTH);
  setMinimumHeight(MIN_HEIGHT);
}

GraphWidget::~GraphWidget(){
  makeCurrent();
  m_point_vbo.destroy();
  m_gradient_vbo.destroy();
  m_ibo.destroy();
  m_vao.destroy();
  doneCurrent();
  
}

// NUM_ARGS - Args of complex numbers.
// NUM_MAGNS - Number of magnitudes.
#define NUM_ARGS 140
#define NUM_MAGNS 200

Initializing, Populating the Point Buffer

Here we’ll define the variables that are initialize once. The following function, initializeGL, is called once upon the widget’s initization:

void GraphWidget::initializeGL(){
  m_vertexShader=new QOpenGLShader(QOpenGLShader::Vertex);
  m_fragmentShader=new QOpenGLShader(QOpenGLShader::Fragment);
  makeCurrent();
  glEnable(GL_BLEND);
  glEnable(GL_DEPTH_TEST);
  glEnable( GL_PROGRAM_POINT_SIZE );
  glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

  m_vao.create();

  m_gradient_vbo.create();
  m_gradient_vbo.bind();
  m_gradient_vbo.allocate(4*sizeof(vertex2D));
  
  m_point_vbo.create();
  m_point_vbo.bind();
  m_point_vbo.allocate(NUM_MAGNS*NUM_ARGS*sizeof(graph_point));
  populatePointBuffer();

  GLshort indices[6]={0,1,2,2,3,0};
  m_ibo.create();
  m_ibo.bind();
  m_ibo.allocate(indices,sizeof(indices));
  m_ibo.release();
  m_gradient_vbo.release();
  m_point_vbo.release();
  m_program=new QOpenGLShaderProgram();
}

A call to glEnable( GL_PROGRAM_POINT_SIZE ); enables drawing single points.

The function populatePointBuffer uses the method ‘write‘ of class QOpenGLBuffer to write points to the vertex buffer. I use the buffer this way so I can use functions of complex variables.

void GraphWidget::populatePointBuffer(void){
  int vboOffset=0;
  graph_point vec[NUM_MAGNS];
  for (float magn=0;magn<1.4;magn+=1.4/NUM_MAGNS){
    int pos=0;
    for (float arg=-1;arg<1;arg+=2./NUM_ARGS){
      // The arg of W(Z) divided by pi.
      complex<float> iarg;
      iarg=complex<float>(0,M_PI*arg);
      complex<float> w=magn*exp(iarg);
      complex<float> z=w*exp(w);
      vec[pos].hue=arg;
      vec[pos].coords[0]=imag(z);
      vec[pos].coords[1]=magn;
      vec[pos].coords[2]=real(z);
      pos+=1;
    }
    m_point_vbo.write(vboOffset,vec,sizeof(vec));
    vboOffset+=sizeof(vec);
  }
}

Resizing

The function resizeGL is called as a response to a widget resize event. In this function the values that change due to a resize event are set. The properties set here are:

  • The font size for the painter
  • The transform matrix
  • The viewport matrix
void GraphWidget::resizeGL(int w, int h){
  m_width=w;
  m_height=h;
  fontSize=12.* min((float)m_width/MIN_WIDTH,(float)m_height/MIN_HEIGHT);
  m_font.setPixelSize(fontSize);
  float z_far=0.05;
  float z_near=0.01;
  float a=2./(m_height-2*fontSize);
  float b=1.8-a*m_height;
  float y_floor=b-2.*a*fontSize;
  
  // leftInPixels,RightInPixels - left and right boundaries
  //                              of the graph.
  float leftInPixels=12*fontSize+7;
  float rightInPixels=m_width - 12 - 15*fontSize;
  a=2/(rightInPixels-leftInPixels);
  b=-1-a*leftInPixels;
  float x_left=b;
  float x_right=a*m_width+b;
  
  transform.setToIdentity();

  transform.frustum(x_left,x_right,y_floor,1.8,z_near,z_far);
  transform.translate(0,0,-(z_far+z_near)/2);
  transform.scale(1,1,(z_far-z_near)/2);
  viewport.setToIdentity();
  viewport.viewport(rect());
}

Painting

The function paintGL is called as a response to paint events. Following is the code of paintGL:

void GraphWidget::paintGL(){
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
  m_painter.begin(this);
  m_painter.setPen(QColor(255,255,255,255));
  m_painter.setFont(m_font);
  addLegend();
  addAxes();

  paintGraph();

  m_painter.end();
}

Let us look at the functions called by the paint event handler:

Add Legend

The function addLegend uses OpenGL function to create the HSV gradient and the painter to add ticks and text values:

void GraphWidget::addLegend(void){
  QString str="arg(W(Z))";

  // Drawing the header

  QRect boundingRect=m_painter.boundingRect(rect(),0,str);

  m_painter.beginNativePainting();

  m_vao.bind();
  QMatrix4x4 mat;
  mat.ortho(rect());
  GLfloat top=boundingRect.height()+20;
  GLfloat bottom=top+0.75*m_height;
  GLfloat left=m_width-boundingRect.width()-10;
  GLfloat right=left+0.25*boundingRect.width();
  legend_left = left; // Add it to the private members.
  vertex2D rect_vertices[4]={{{left,top},1},{{right,top},1},
  			     {{right,bottom},-1},{{left,bottom},-1}};
  m_gradient_vbo.bind();
  m_ibo.bind();
  GLshort indices[6]={0,1,2,2,3,0};
  m_ibo.write(0,indices,sizeof(indices));
  m_gradient_vbo.write(0,rect_vertices,sizeof(rect_vertices));

  m_program->addShaderFromSourceCode(QOpenGLShader::Vertex,LEGEND_VERTEX_SHADER);
  m_program->addShaderFromSourceCode(QOpenGLShader::Fragment,FRAGMENT_SHADER);
  m_program->bind();
  m_program->link();
  m_program->setAttributeBuffer("legend_coords",
				GL_FLOAT,
				0,
				2,
				sizeof(vertex2D));
  m_program->setAttributeBuffer("legend_hue",
				GL_FLOAT,
				sizeof(rect_vertices[0].coords),
				1,
				sizeof(vertex2D));
  m_program->setUniformValue("ortho",mat);
  
  m_vao.release();
  m_vao.bind();
  m_program->enableAttributeArray("legend_coords");
  m_program->enableAttributeArray("legend_hue");
  glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_SHORT,0);

  m_vao.release();
  m_program->disableAttributeArray("legend_coords");
  m_program->disableAttributeArray("legend_hue");
  m_program->release();
  m_program->removeAllShaders();
  m_gradient_vbo.release();
  m_ibo.release();

  m_painter.endNativePainting();
  m_painter.drawText(m_width-boundingRect.width()-10,boundingRect.height()+10,str);

  // Adding legend ticks
  QString vals[7]={"pi","2pi/3","pi/3","0","-pi/3","-2pi/3","-pi"};
  float curr_height=top;
  float dist=(bottom-top)/6;
  for (int i=0;i<7;i++){
    m_painter.drawLine(right,curr_height,right+10,curr_height);
    m_painter.drawText(right+15,curr_height+0.4*boundingRect.height(),vals[i]);
    curr_height+=dist;
  }
}

Following is the macro in shaders.h, which defines the vertex shader for the legend:

#define LEGEND_VERTEX_SHADER					\
  "attribute vec2 legend_coords;\n"				\
  "attribute float legend_hue;\n"				\
  "uniform mat4 ortho;\n"					\
  "varying float f_hue;\n"					\
  "void main(void){\n"						\
  "    gl_Position=ortho*vec4(legend_coords,0,1);\n"		\
  "    f_hue=legend_hue;\n"					\
  "}"

Add Axes

The function addAxes uses the painter and the viewport matrix to draw axes and add the ticks. You hove noticed that the matrices are of size 4×4 and the vectors are of length 4; when a matrix is multiplied by 4, you should divide coordinates x,y by coordinate w to find the vector’s location on the 2D window. Special treatment is added to the slanted z-axis, where SVG is used:

void GraphWidget::addAxes(void){

  QVector4D startVec,endVec;

  // Draw the x-axis
  startVec=viewport*transform*QVector4D(-1,-0.2,1.1,1);
  endVec=viewport*transform*QVector4D(1,-0.2,1.1,1);
  draw3DLine(startVec,endVec);
  add_x_ticks();

  // Draw the y-axis
  startVec=viewport*transform*QVector4D(-1,-0.2,1.1,1);
  endVec=viewport*transform*QVector4D(-1,1.4,1.1,1);
  draw3DLine(startVec,endVec);
  add_y_ticks();

  // Draw the z-axis
  startVec=viewport*transform*QVector4D(1,-0.2,-1,1);
  endVec=viewport*transform*QVector4D(1,-0.2,1.1,1);
  draw3DLine(startVec,endVec);
  add_z_ticks();
}

void GraphWidget::draw3DLine(QVector4D fromVec, QVector4D toVec){
  m_painter.drawLine(fromVec.x()/fromVec.w(),
		     m_height-fromVec.y()/fromVec.w(),
		     toVec.x()/toVec.w(),
		     m_height-toVec.y()/toVec.w());
}

void GraphWidget::add_x_ticks(){
  QVector4D start_point,end_point;
  QString vals[]={"-1","-0.5","0","0.5","1"};
  int i=0;
  for (float loc=-1;loc<=1;loc+=0.5){
    start_point=viewport*transform*QVector4D(loc,-0.2,1.1,1);
    start_point.setX(start_point.x()/start_point.w());
    start_point.setY(m_height-start_point.y()/start_point.w());
    end_point=start_point;
    end_point.setY(end_point.y()+5);
    m_painter.drawLine(start_point.x(),start_point.y(), end_point.x(),end_point.y());
    m_painter.drawText(start_point.x(),end_point.y()+fontSize+1,vals[i++]);
  }
  const char *text="Im(Z)";
  QVector4D start_vector=viewport*transform*QVector4D(-1,-0.2,1.1,1);
  QVector4D end_vector=viewport*transform*QVector4D(1,-0.2,1.1,1);
  float text_start=(start_vector.x()+end_vector.x())/(2.*end_vector.w())-2*fontSize;
  float bottom=m_height-end_vector.y()/end_vector.w()+2*fontSize+5;
  m_painter.drawText(text_start,bottom,text);
}

void GraphWidget::add_y_ticks(){
  QVector4D start_point,end_point;
  QString vals[]={"0.0","0.2","0.4","0.6","0.8","1.0","1.2","1.4"};
  int i=0;
  for (float loc=0;loc<=1.41;loc+=0.2){
    start_point=viewport*transform*QVector4D(-1,loc,1.1,1);
    start_point.setX(start_point.x()/start_point.w());
    start_point.setY(m_height-start_point.y()/start_point.w());
    end_point=start_point;
    end_point.setX(end_point.x()-5);
    m_painter.drawLine(start_point.x(),start_point.y(), end_point.x(),end_point.y());
    m_painter.drawText(end_point.x()-2*fontSize,end_point.y()+0.5*fontSize,vals[i++]);

  }
  const char *text="|W(z)|";
  QVector4D start_vector=viewport*transform*QVector4D(-1,-0.2,1.1,1);
  QVector4D end_vector=viewport*transform*QVector4D(1,1,1.1,1);
  float text_y_position=(start_vector.y()+end_vector.y())/(2.*end_vector.w())-2*fontSize;
  float text_rightmost=start_vector.x()/start_vector.w()-6*fontSize;
  m_painter.drawText(text_rightmost,text_y_position,text);
}

void GraphWidget::add_z_ticks(){
  QVector4D start_point,end_point;
  QString vals[]={"-1","-0.5","0","0.5","1"};
  int i=0;
  for (float loc=-1;loc<=1;loc+=0.5){
    start_point=viewport*transform*QVector4D(1,-0.2,loc,1);
    start_point.setX(start_point.x()/start_point.w());
    start_point.setY(m_height-start_point.y()/start_point.w());
    end_point=start_point;
    end_point.setY(end_point.y()-5);
    m_painter.drawLine(start_point.x(),start_point.y(), end_point.x(),end_point.y());
    if (i!=1){
      m_painter.drawText(end_point.x()-fontSize/2.,end_point.y()-1,vals[i]);
    }
    i+=1;
  }
  add_z_text();
}

void GraphWidget::add_z_text(){
  char *svg_str=(char *)calloc(strlen(SVG_TEXT_STR)+1,sizeof(char));
  const char *text="Re(Z)";
  QVector4D start_point,end_point;
  start_point=viewport*transform*QVector4D(1,-0.2,1,1);
  start_point.setX(start_point.x()/start_point.w());
  start_point.setY(m_height-start_point.y()/start_point.w());
  end_point=viewport*transform*QVector4D(1,-0.2,-1,1);
  end_point.setX(end_point.x()/end_point.w());
  end_point.setY(m_height-end_point.y()/end_point.w());
  qreal inRadians=qAtan2(end_point.y()-start_point.y(),end_point.x()-start_point.x());
  qreal inDegrees=qRadiansToDegrees(inRadians)+180;
  float centerX=(start_point.x()+end_point.x())/2.;
  float centerY=(start_point.y()+end_point.y())/2.;
  float lenOfText=(strlen(text)-1.5)*fontSize;
  float textX=centerX-lenOfText;
  float textY=centerY+fontSize;
  
  
  sprintf(svg_str,SVG_TEXT_STR,textX,textY,fontSize,inDegrees,centerX,centerY,text);
  cout<<svg_str<<endl;
  cout<<"Length of svg_str"<<strlen(svg_str)<<endl;
  cout<<"Lenght of SVG_TEXT_STR"<<strlen(SVG_TEXT_STR)<<endl;
  
  QSvgRenderer renderer((QByteArray(svg_str)));
  renderer.render(&m_painter,rect());

}

The function add_z_text renders the SVG text using an object of call QSvgRendered. It accepts a byte array made from a string. It can also take a file name. The macro SVG_TEXT_STR is defined in file “svg.h” as follows:/code

#define SVG_TEXT_STR							\
  "<svg>"								\
  "    <text x='%.0f'" 							\
  "          y='%.0f'"							\
  "          font-size='%dpx'"						\
  "          stroke='white'"						\
  "          fill='white'"						\
  "          transform='rotate(%.0f ,  %.0f ,  %.0f)'>%s</text>"	\
  "</svg>"

Painting the Graph

Following is the function that paints the graph:

void GraphWidget::paintGraph(){
  m_painter.beginNativePainting();
  m_vao.bind();
  m_point_vbo.bind();
  m_program->addShaderFromSourceCode(QOpenGLShader::Vertex, GRAPH_VERTEX_SHADER);
  m_program->addShaderFromSourceCode(QOpenGLShader::Fragment, FRAGMENT_SHADER);
  m_program->bind();
  m_program->link();
  m_program->setAttributeBuffer("graph_coords",
				GL_FLOAT,
				0,
				3,
				sizeof(graph_point));
  m_program->setAttributeBuffer("hue",
				GL_FLOAT,
				3*sizeof(GLfloat),
				1,
				sizeof(graph_point));
  m_program->setUniformValue("transform",transform);
  m_vao.release();
  m_vao.bind();
  m_program->enableAttributeArray("graph_coords");
  m_program->enableAttributeArray("hue");
  glDrawArrays(GL_POINTS,0,NUM_ARGS*NUM_MAGNS);
  m_program->removeAllShaders();
  m_program->release();
  m_point_vbo.release();
  m_vao.release();
  m_painter.endNativePainting();
}

The Main Function

Following is the code of the main function:

int main(int argc, char *argv[]){
  QApplication app(argc, argv);
  GraphWidget w;
  w.show();

  return app.exec();
}

Advertisement

Drawing Simple 3D Shapes in HTML5

HTML5 includes some features that allows developers to draw 3D shapes by drawing bi dimensional shapes and applying 3d transform, such as rotateX, rotateY and rotateZ. For convenience, you can shift the origin of axes using the style property If you don’t want the 3D image too flat (for example, all the faces of a cube having the same size) use perspective and perspective-origin style properties.

You can use the matrix3d style properties instead of the named transforms if, for example, you don’t want to compute angles.

The Style Properties

A style property can be defined by adding the attribute style to an HTML element, defining a CSS class or accessing a DOM node.

In this section I will explain the properties using a little Javascript program that draws a regular tetrahedron.

enter image description here

Drawing a tetrahedron is done by drawing 4 isosceles triangles and rotating each of them once or twice.

“perspective” and “perspective-origin”

The distance and angle from which the shape is viewed.

“perspective” holds the distance

“perspective-origin” – a position value.

For example:

var main_div = d3.select('body')
 .append('div')
 .style('position','absolute')
 .style('top','50px')
 .style('left','50px')
 .style('perspective','50px')
 .style('perspective-origin','bottom left');
 

Transform Values: “rotateX”, “rotateY”, “rotate” and “transform-origin”

Rotate an axis. Keep the value of the rotated axis coordinate unchanged, and change the rest. The axis is rotated around the position defined by “transform-origin”

The following code adds the data for creating 4 triangles, and rotates 1 triangle 120 degrees to the right and 1 triangle 120 degrees to the left. Rotation is done around the bottom face’s centroid.

main_div.selectAll('div').
 data([{color: 'red', transform: null,upperVertexInd: true},
 {color: 'black', transform: 'rotateX(90deg)', 'origin':'
 100px 100px 0'},
 {color: 'blue', transform: 'rotateY(120deg)',origin: cent
 roidString,upperVertexInd: true},
 {color: 'green', transform: 'rotateY(-120deg)',origin: ce
 ntroidString,upperVertexInd: true}])
 .enter()
 .append('div')
 .style('position','absolute')
 .style('top',0)
 .style('left',0)
 .style('transform',d=>d.transform)
 .style('transform-origin',d=>d.origin)
 .style('transform-style','preserve-3d')
 



(To be more precise, it rotates the DIV elements)

enter image description here

Tarnsform Values: “matrix3d”

This matrix is used if you want to use a transformation out of the comfort zone. For example, a rotation transform with cosines and sines of the angle. The argument list contains 16 values, which are the cells of a square matrix of order 4 (4 rows and 4 columns).

This matrix will be applied on (x,y,z,w) vector to get the target vector. When rotating a 2d vector )point), our original z-coordinate will be 0, and w will be 1.

To specify the matrix:

\left( \begin{matrix} a_0 \ a_4 \ a_8 \ a_{12} \\a_1 \ a_5 \ a_9 \ a_{13} \\a_2 \ a_6 \ a_{10} a_{14} \\a_3 \ a_7 \ a_{11} \ a_{15}\end{matrix} \right)

use

matrix3d(a_0,a_1,a_2,...,a_{15})

In my example, I will rotate 3 triangles, so their top vertex will go to a line perpendicular to the tetrahedron base, and passing through the base’s median.

The median of a triangle is the point where median cross its other, dividing each median at the ratio 1:2.

So, if each side of a triangle is of length 1. The height is \sqrt(3)\over2

Since, the height is the length of the median, the distance from a side to the centroid is the height divided by 3, and the requested sine is latex13latex 1 \over 3

The cosine is \sqrt {1 - {1 \over 3}^2} = {\sqrt 8 \over 3}

enter image description here

so, we will compute the matrix as follows:

var rotateXCos = Math.sqrt(8) / 3;
 var rotateXSin = 1 / 3;
 var rotateXMat3d = [1,0,0,0,
 0,rotateXCos,rotateXSin,0,
 0,-rotateXSin,rotateXCos,0,
 0,0,0,1];
 var matrixTransformString = 'matrix3d(' + rotateXMat3d + ')';
 

Now, the code to draw the tetrahedron with *d3.js( is:

 var side=100;
 var len=100;
 var height=side * Math.sqrt(3)/2;
 var centroidZValue = -height / 3; // The point where medians meet.
 var rotateXCos = Math.sqrt(8) / 3;
 var rotateXSin = 1 / 3;
 var rotateXMat3d = [1,0,0,0,
 0,rotateXCos,rotateXSin,0,
 0,-rotateXSin,rotateXCos,0,
 0,0,0,1];
 var matrixTransformString = 'matrix3d(' + rotateXMat3d + ')';
 var centroidString = '150px 0 ' + centroidZValue + 'px';
 var main_div = d3.select('body')
 .append('div')
 .style('position','absolute')
 .style('top','50px')
 .style('left','50px')
 .style('perspective','50px')
 .style('perspective-origin','bottom left');
 main_div.selectAll('div').
 data([{color: 'red', transform: null,upperVertexInd: true},
 {color: 'black', transform: 'rotateX(90deg)', 'origin':'
 100px 100px 0'},
 {color: 'blue', transform: 'rotateY(120deg)',origin: cent
 roidString,upperVertexInd: true},
 {color: 'green', transform: 'rotateY(-120deg)',origin: ce
 ntroidString,upperVertexInd: true}])
 .enter()
 .append('div')
 .style('position','absolute')
 .style('top',0)
 .style('left',0)
 .style('transform',d=>d.transform)
 .style('transform-origin',d=>d.origin)
 .style('transform-style','preserve-3d')
 .append('div')
 .style('transform-style','preserve-3d')
 .style('position','absolute')
 .style('top',0)
 .style('left',0)
 .style('transform',function(d){
 return d.upperVertexInd?matrixTransformString:false;
 })
 .style('transform-origin',function(d){
 return d.upperVertexInd?'0 100px 0':false;
 })
 .append('svg')
 .append('polygon')
 .attr('points',[100,100,150,100-height,200,100])
 .style('fill','none')
 .style('stroke',d=>d.color);