How do you make a custom arrow?

[Edit: For my own testing purposes, it would actually be great if I could just find out what the current orientation of an object is. For instance, if I could just print to the console whatever is the axis of symmetry of the cylinder, that would really help me to figure out what my current code is doing so that I can fix it. But after scanning the documentation, I haven’t seen a function that will tell me the orientation.]

I would like to be able to customize the appearance of an arrow (in particular the width). So I’ve been trying to build it from scratch but it seems like it takes a ton of calculation. I’m wondering if I’m doing this in the best possible way.

The basic behavior that I’d like is to have a function customArrow( fx, fy, fz, ix, iy, iz, thickness, color) where you specify the coordinates of the final position of the arrow (fx,fy,fz) and the initial position (i.e. where the “tail” of the arrow is, ix,iy,iz), along with some other customizations like thickness, color, and maybe other things I’ll add on later.

In order to implement this I’ve tried building the shaft of the arrow as a TubeGeometry object. I believe ThreeJS initializes this along the y-axis. So I then need to both rotate and translate this into the position where I want it to be. This has involved a bunch of math, and I’m sure if I keep working at it, I can eventually get it.

But is there a better way to do this? Here’s what I’ve been trying and right now I can see that it’s wrong, but it gives a sense of how I’m approaching the problem and how much work this seems to involve.

export function customArrow( tx, ty, tz, ox=0, oy=0, oz=0, color='yellow', thickness=.01 ) {
    const ovec = new THREE.Vector3( ox,oy,oz );
    const tvec = new THREE.Vector3( tx,ty,tz );
    const dvec = (tvec.clone()).sub(ovec);
    const mag = dvec.length();

    const tube = new THREE.CylinderGeometry( thickness, thickness, mag, 50);
    const mater = new THREE.MeshBasicMaterial( {color: color} );
    const cylinder = new THREE.Mesh( tube, mater );
    cylinder.position.set(0,0,0);
    
    const quaternion = new THREE.Quaternion();
    const axis = new THREE.Vector3( tz-oz,0,ox-tx );
    const angle = Math.acos( (ty-oy)/mag )
    quaternion.setFromAxisAngle(
        axis, angle
    );
    cylinder.setRotationFromQuaternion(quaternion);
    cylinder.position.set(...(ovec.add(dvec.multiplyScalar(.5)).toArray()));

    const pyramid = new THREE.CylinderGeometry( 0, thickness*2, mag/5, 50);
    const head = new THREE.Mesh(pyramid,mater);
    head.position.set(0,0,0);
    head.setRotationFromQuaternion(quaternion);
    head.position.set(...((tvec.addScaledVector(dvec,-mag/5)).toArray()));

    const group = new THREE.Group();
    group.add(cylinder);
    group.add(head);
    
    return group;
};

Here is a working example of the difficulty I’m facing. I’m using ArrowHelper just to draw the orientation axes, in order to be able to see what cyl looks like.

I’ve also eliminated the function and tried to implement this directly, because I have found that there is something in the geometry that I’m getting wrong. Once I get the geometry right I’ll abstract it into a function.

import * as THREE from 'three';

function main() {

	const canvas = document.querySelector( '#c' );
	const renderer = new THREE.WebGLRenderer( { antialias: true, canvas } );

	const fov = 75;
	const aspect = 1; 
	const near = 0.1;
	const far = 10;
	const camera = new THREE.PerspectiveCamera( fov, aspect, near, far );

	const scene = new THREE.Scene();

	const origin = new THREE.Vector3( 0,0,0 );
	const xdir = new THREE.Vector3( 1,0,0 );
	const ydir = new THREE.Vector3( 0,1,0 );
	const zdir = new THREE.Vector3( 0,0,1 );

	{

		const color = 0xFFFFFF;
		const intensity = 3;
		const light = new THREE.DirectionalLight( 'white', intensity );
		light.position.set( - 1, 2, 4 );
		scene.add( light );

	}
	

	scene.add(
		new THREE.ArrowHelper(xdir, origin, 3, "red"),
		new THREE.ArrowHelper(ydir, origin, 2, "white"),
		new THREE.ArrowHelper(zdir, origin, 1, "blue")
	);

	const mat = new THREE.MeshPhongMaterial( { color: 0x44aa88 } ); // greenish blue

        // Here is where I start drawing the cylinder.

        // Terminal vector coordinates.
	const tx = 3;
	const ty = -1;
	const tz = 1;
        // Original vector coordinates.
	const ox = 2;
	const oy = 0;
	const oz = -1;
	const ovec = new THREE.Vector3(ox,oy,oz);
	const tvec = new THREE.Vector3(tx,ty,tz);
        // Direction vector from the original to the terminal vector.
	const dvec = (tvec.clone()).sub(ovec);
	const mag = dvec.length();

        // The cylinder mesh object.
	const cylGeom = new THREE.CylinderGeometry( .05, .05, mag, 50);
	const cyl = new THREE.Mesh( cylGeom, mat );
	const rotax = new THREE.Vector3(tz-oz,0,ox-tx);
	
        // Constructing the rotation which I would hope takes the y-axis to the direction of the direction vector.  (But it does not.)  I will actually perform the rotation in an animated loop, just to help me see what's going on.
	const angle = Math.acos( (ty-oy)/mag );
	const quat = new THREE.Quaternion();
	
        // Set the position (center of the cylinder) to the midpoint of the original and terminal points.
	cyl.position.set( ...(ovec.addScaledVector(dvec,.5)) );

        // Make two spheres at the end points, just to help see where the cylinder should be.
	const sg = new THREE.SphereGeometry(.1);
	const s1 = new THREE.Mesh( sg, mat );
	const s2 = new THREE.Mesh( sg, mat );
	s2.scale.set( .5,.5,.5 );
	s1.position.set( tx,ty,tz );
	s2.position.set( ox,oy,oz );

	scene.add( cyl,s1,s2 );

        // Parameters for a rotating camera to help view the results.
	var theta = 0;
	const phi = 1;
	const radius = 6;

	var x = radius*Math.cos(theta)*Math.cos(phi);
	var y = radius*Math.sin(theta)*Math.cos(phi);
	var z = radius*Math.sin(phi);

	camera.position.set( x,y,z );

	var anglescale;  // A parameter to allow the angle of the quaternion to increase as the animation proceeds.

	function render( time ) {

		time *= 0.001; // convert time to seconds

		renderer.render( scene, camera );

		requestAnimationFrame( render );

		theta += .001;

		x = radius*Math.cos(theta)*Math.cos(phi);
		y = radius*Math.sin(theta)*Math.cos(phi);
		z = radius*Math.sin(phi);

		camera.position.set( x,y,z );

		camera.lookAt( 0,0,0 );

		anglescale = Math.min(time/10,1);
		quat.setFromAxisAngle(rotax, angle*anglescale);
		cyl.setRotationFromQuaternion(quat);

	}

	requestAnimationFrame( render );

}

main();

When I run this I find that the cylinder does not run between the two spheres, as it should. Clearly something is wrong with the angle of rotation. I calculated this as Math.acos( (ty-oy)/mag ), based on taking the dot-product with the direction vector and <0,1,0> and dividing by the magnitude(s). In case the issue is just a matter of orientation, I also tried using the negative of this angle, but that also didn’t work.)

Noteworthy is the fact that as the cylinder rotates it passes through the two spheres at its endpoints, so this tells me that the axis of rotation is correct.

Also, I only learned about quaternions maybe a day or two ago, so I definitely don’t get them in any kind of deep way. So there is a very good chance that I just don’t understand how to use them.

Can you provide a working example the community could pick at to help solve this? I have a feeling getWorldDirection would be a useful peice of the puzzle here…

It depends on what does “better way” mean. If I were you, I would:

  • define one cylinder and one code geometry, and I will reuse them for all arrows (this will save a lot of memory and processing power)
  • keep the cylinder with (0,0,0) in the bottom center; and the cone with (0,0,0) at the top vertex (this will make calculations much shorter)
  • reduce the number of cylinder and cone sides as much as possible, preferably below 12 (again less memory, higher performance)

If you need an example, I could make one.

Once you have the arrow where the bottom is at the starting point, you could use the lookAt method to orient the arrow at the target

I wouldn’t want to bother you with making the example, but I do have a question: If I make a single cylinder geometry then how could one set a variable thickness? That seems to be set by the creation of a geometry, and at least as far as I have been able to see in the documentation, one cannot set the thickness after initialization.

This is simple:

  • create a cylinder geometry with radius 1 and height 1
  • create a mesh using this geometry
  • set the mesh scale and it will use the geometry affected by this scale

You may have many meshes and each may have different scale, but internally all meshes will use a single geometry.

Edit: proof of concept

4 Likes

Sure, I’ll do that now!

Ah, I finally figured out what it was. I just needed to normalize the rotation axis.

Good!

In case you are curious of a shorter way of customArrow here was my attempt:

function customArrow( fx, fy, fz, ix, iy, iz, thickness, color)
{
	var material = new THREE.MeshLambertMaterial( {color: color} );
	
	var length = Math.sqrt( (ix-fx)**2 + (iy-fy)**2 + (iz-fz)**2 );
	
	var body = new THREE.Mesh( ARROW_BODY, material );
		body.scale.set( thickness, thickness, length-10*thickness );
		
	var head = new THREE.Mesh( ARROW_HEAD, material );
		head.position.set( 0, 0, length );
		head.scale.set( 3*thickness, 3*thickness, 10*thickness );
	
	var arrow = new THREE.Group( );
		arrow.position.set( ix, iy, iz );
		arrow.lookAt( fx, fy, fz );	
		arrow.add( body, head );
	
	return arrow;
}

Live demo: https://codepen.io/boytchev/full/GRPoqvr

4 Likes