0

In Three.js, I'm trying to make an interactive click-and-drag-to-rotate object comprised of a ring and line. I'm running into a weird problem in which the ring doesn't appear until the first rotation. My code is below.

Why doesn't the ring appear until the object is rotated?

Edit: I discovered that the this.ring.rotateX(Math.PI / 2) seems to be what prevents the ring from appearing.

Initialization (ring invisible)

enter image description here

After a tiny rotation (ring visible)

enter image description here

<!doctype html>
<html>

<head>
  <meta charset="utf-8" />
  <script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'></script>
  <!-- TheJim01: Adding CDN jquery for snippet -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
  <!-- <script src="js/jquery.min.js"></script> -->
  <!--    <script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>-->
</head>

<body>
  <canvas id="threejs_covering_canvas" width="1000" height="500"></canvas>

  <script>
    const canvas = $('#threejs_covering_canvas')[0];
    const scene = new THREE.Scene();
    const camera = new THREE.OrthographicCamera(-10, 10, 5, -5, 1, 1000);

    camera.position.z = 5;

    const renderer = new THREE.WebGLRenderer({
      canvas
    });

    document.body.appendChild(renderer.domElement);

    function vecToSurfaceNormalRGB(vec) {

      // normals are in range [-1, 1], so this maps to [0, 1]
      let red = 0.5 * vec.x + 0.5;
      let green = 0.5 * vec.y + 0.5;
      let blue = 0.5 * vec.z + 0.5;

      return new THREE.Color(red, green, blue);
    }

    class KoenderinkCircle extends THREE.Object3D {

      constructor(dir, origin) {

        super();
        this.type = 'KoenderinkCircle';

        this._axis = new THREE.Vector3();

        this._lineGeometry = new THREE.BufferGeometry();
        this._lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0, 0, 1, 0], 3));
        this.line = new THREE.Line(this._lineGeometry, new THREE.LineBasicMaterial({
          color: 0xffff00,
          toneMapped: false,
          linewidth: 4
        }));
        this.add(this.line)

        // THREE.RingBufferGeometry
        this._ringGeometry = new THREE.RingBufferGeometry(0.5, 1, 100);
        this.ring = new THREE.Mesh(this._ringGeometry, new THREE.MeshNormalMaterial({
          side: THREE.DoubleSide
        }));
        // Rotate the ring such that line is normal to plane the ring lies on.
        this.ring.rotateX(Math.PI / 2);
        this.add(this.ring);

        this.position.copy(origin);
        this.updateLineColor();
        this.setDirection(dir)

      }

      setDirection(dir) {

        // dir is assumed to be normalized

        if (dir.y > 0.99999) {

          this.quaternion.set(0, 0, 0, 1);

        } else if (dir.y < -0.99999) {

          this.quaternion.set(1, 0, 0, 0);

        } else {

          this._axis.set(dir.z, 0, -dir.x).normalize();

          const radians = Math.acos(dir.y);

          this.quaternion.setFromAxisAngle(this._axis, radians);

        }

      }

      updateLineColor() {

        let lineDirection = new THREE.Vector3();
        this.ring.getWorldDirection(lineDirection);
        // For some reason, ring points in opposite direction.
        // Multiply by -1 to get correct direction.
        lineDirection.multiplyScalar(-1.)

        // TODO: Why does color work correctly only on the front half, not the back half?
        this.line.material.color.set(vecToSurfaceNormalRGB(lineDirection.normalize()));
      }

    }

    // var koenderinkIndicatorDirection = new THREE.Vector3(1, 1, 0).normalize();
    var koenderinkIndicatorDirection = new THREE.Vector3(0, 1, 0).normalize();
    var koenderinkIndicatorPosition = new THREE.Vector3(-4, 2, 0);
    const koenderinkIndicator = new KoenderinkCircle(
      koenderinkIndicatorDirection,
      koenderinkIndicatorPosition)
    scene.add(koenderinkIndicator);


    var pointer = new THREE.Vector3();
    var mouseStartOrthographicPosition = new THREE.Vector3();
    var mousePenultimateOrthographicPosition = new THREE.Vector3();
    var mouseCurrentOrthographicPosition = new THREE.Vector3();

    var distanceCurrentMinusPenultimate = new THREE.Vector3();
    var distancePenultimateMinusStart = new THREE.Vector3();
    var distanceCurrentMinusStart = new THREE.Vector3();
    var angle;
    var axis = new THREE.Vector3();
    let lineWorldDirection = new THREE.Vector3();
    let koenderinkIndicatorWorldDirection = new THREE.Vector3();
    // This vector will be used to check that rotation doesn't permit pointing
    // line in negative Z direction.
    let negativeZVector = new THREE.Vector3(0, 0, -1);

    const radius = 1;
    const radiusSquared = radius * radius;
    let rotationQuaternion = new THREE.Quaternion();
    let mouseDown = false;

    function setMouseCurrentOrthographicPosition(event) {

      let canvasBoundingBox = canvas.getBoundingClientRect();
      let sketchpadCurrentHeight = canvasBoundingBox.bottom - canvasBoundingBox.top;
      let sketchpadCurrentWidth = canvasBoundingBox.right - canvasBoundingBox.left;

      // Compute mouse location relative to canvasBoundingBox, then normalize to [-1, 1]
      // I think this is correct

      pointer.x = ((event.clientX - canvasBoundingBox.left) / sketchpadCurrentWidth) * 2 - 1;
      pointer.y = -((event.clientY - canvasBoundingBox.top) / sketchpadCurrentHeight) * 2 + 1;

      // First computes location of mouse on z=0 plane
      // See: https://stackoverflow.com/a/13091694/4570472
      // Third argument is irrelevant.
      mouseCurrentOrthographicPosition.set(pointer.x, pointer.y, 0);

      // Projects from ThreeJS-independent "normalized device coordinate space" (i.e. -1 to 1)
      // to "world space" i.e. the coordinates used by ThreeJS
      mouseCurrentOrthographicPosition.unproject(camera);

      return mouseCurrentOrthographicPosition
    }

    canvas.addEventListener('mousedown', function(event) {
      // Save mouseStartOrthographicPosition.
      mouseStartOrthographicPosition.copy(setMouseCurrentOrthographicPosition(event));

      // Initialize the 2nd most recent othographic position.
      mousePenultimateOrthographicPosition.copy(mouseStartOrthographicPosition);
      mouseDown = true;

      // console.log('mousedown set to True');
    });

    canvas.addEventListener('mouseup', function(event) {
      mouseDown = false;
    });

    function rotateIndicator(event) {

      if (mouseDown) {

        setMouseCurrentOrthographicPosition(event);

        distanceCurrentMinusPenultimate.subVectors(mouseCurrentOrthographicPosition, mousePenultimateOrthographicPosition);
        distanceCurrentMinusStart.subVectors(mouseCurrentOrthographicPosition, mouseStartOrthographicPosition);
        distancePenultimateMinusStart.subVectors(mousePenultimateOrthographicPosition, mouseStartOrthographicPosition);

        let cond1 = Math.pow(distancePenultimateMinusStart.x, 2) + Math.pow(distancePenultimateMinusStart.y, 2) + 0.0001 < radiusSquared;
        let cond2 = Math.pow(distanceCurrentMinusStart.x, 2) + Math.pow(distanceCurrentMinusStart.y, 2) + 0.0001 < radiusSquared;

        if (cond1 && cond2) {

          let v0 = new THREE.Vector3(
            distancePenultimateMinusStart.x,
            distancePenultimateMinusStart.y,
            Math.sqrt(radiusSquared - Math.pow(distancePenultimateMinusStart.x, 2) - Math.pow(distancePenultimateMinusStart.y, 2))
          ).normalize()

          let v1 = new THREE.Vector3(
            distanceCurrentMinusStart.x,
            distanceCurrentMinusStart.y,
            Math.sqrt(radiusSquared - Math.pow(distanceCurrentMinusStart.x, 2) - Math.pow(distanceCurrentMinusStart.y, 2))
          ).normalize()

          angle = Math.acos(Math.max(Math.min(v0.dot(v1), 0.999), -0.999));

          axis.crossVectors(v0, v1).normalize()

          rotationQuaternion.setFromAxisAngle(axis, angle);

          // For some reason, the line world direction is rotated 90 degrees towards the camera.
          // i.e. if the line is pointing vertically up, the world direction is (0, 0, 1)
          // if the line is pointing towards the camera, the world direction is (0, -1, 0)
          console.log('koenderinkIndicator axis: ', koenderinkIndicator._axis)
          koenderinkIndicator.getWorldDirection(koenderinkIndicatorWorldDirection);
          console.log('lineWorldDirection Before Quaternion: ', koenderinkIndicatorWorldDirection)

          // Apply rotation to lineWorldDirection
          lineWorldDirection.applyQuaternion(rotationQuaternion)

          // console.log('lineWorldDirection After Quaternion: ', lineWorldDirection)

          // Check whether rotation would result in line facing backwards.
          let dotWithZDirection = lineWorldDirection.dot(negativeZVector)

          // console.log('Dot with Z Direction: ', dotWithZDirection)

          koenderinkIndicator.applyQuaternion(rotationQuaternion);
          koenderinkIndicator.updateLineColor();

        }

        mousePenultimateOrthographicPosition.copy(mouseCurrentOrthographicPosition);

      }
    }

    canvas.addEventListener('mousemove', rotateIndicator);

    function animate() {
      requestAnimationFrame(animate);
      renderer.render(scene, camera);
    }
    animate();
  </script>
</body>

</html>
TheJim01
  • 8,411
  • 1
  • 30
  • 54
Rylan Schaeffer
  • 1,945
  • 2
  • 28
  • 50

1 Answers1

1

Flat objects don't intrinsically have a volume. They are literally 2D. So, your initial view is looking at the ring "on edge," which means you are looking along a plane with no dimensional value, and so the object appears invisible. Moving the view "just a little" is enough to make your viewpoint no longer be on the same plane as the ring, and so the ring appears.

If you want to be able to see the ring from all angles, consider giving it some depth, like a very short cylinder (with height still greater than zero) with a hole through it.

TheJim01
  • 8,411
  • 1
  • 30
  • 54
  • I initially thought this was the explanation myself, but I don't think it can explain this problem. The `rotateX` sets the ring such that its "flat" side faces the camera, yes, but then the constructor calls `this.setDirection(dir)` which (I think) alters the direction of the line as well as the ring. – Rylan Schaeffer Mar 14 '22 at 20:21
  • Ok nevermind you are correct. Thank you! – Rylan Schaeffer Mar 14 '22 at 20:25