Search code examples
javascriptmathmatrix3dcss-transforms

3D CSS3 transforms handling with DOMMatrix issue


Context

I'm trying to handle geometric operations applied to HTML elements by CSS3 3d transforms through DOMMatrix API to retrieve any coord i want in any coord system i want.

The Issue

Everything work fine with 2d transforms (translate, rotate, scale, skew) but when i start working in 3d with for exemple the "perspective" transform or a "rotateX" transform then nothing no longer work.

And i don't well understand why because in fact everything should be handled by the DOMMatrix operations like successives multiplications etc ..

Small exemple

// 2D working transforms --->
//const selfTransform = 'rotate(25deg) skewY(50deg) scale(0.6)';
// const selfTransform = 'rotate(45deg) skewX(50deg) scale(0.6)';
// <---

// 3D not working transforms --->
const selfTransform = 'perspective(500px) rotateX(60deg) rotateY(15deg)';
// <---

const DOM_a = document.getElementById('a');
DOM_a.style.transform = selfTransform;

// Generate the transform string
const transform = 
  // Translate to #a 0, 0
  'translate3d(100px, 100px, 0px) '
  // Translate to #a center, center
  + 'translate3d(100px, 100px, 0px) '
  // Apply #a transform
  + selfTransform
  // Translate back to #a 0, 0
  + 'translate3d(-100px, -100px, 0px)';

// Create the 3d projective transform matrix
const tm = new DOMMatrix(transform);

// Create the point representing the #a bottom left point coords in #A landmark
const blCoordsInA = new DOMPoint(
  0,
  200
);

// Find the #a bottom left point coords in the window landmrk by applying the transform
const blCoordsInWindow = blCoordsInA.matrixTransform(tm);
console.log(blCoordsInWindow);

const blLeft = blCoordsInWindow.x;
const blTop = blCoordsInWindow.y;


// Visualize the point by moving the black circle
const DOM_marker = document.getElementById('marker');
DOM_marker.style.left = blLeft + 'px';
DOM_marker.style.top = blTop + 'px';
#a {
  position: absolute;
  top: 100px;
  left: 100px;
  
  width: 200px;
  height: 200px;
  
  background-color: red;
  transform-origin: center center;
}

#marker {
  position: absolute;
  top: 0px;
  left: 0px;
  
  width: 6px;
  height: 6px;
  margin-left: -3px;
  margin-top: -3px;
  
  background-color: black;
  border-radius: 50%;
}
<div id="a">
</div>

<div id="marker">
</div>

As you can see in this exemple i'm trying to find the coordinates of the bottom left corner of the red div in the "general window landmark". Knowing the transform of the red div and the position of the point in the red div i normally should be able to find out the coords of thhis point in the window just by multiplying the coordinates of the point by the transform matrix i generate.

In my exemple if you define a 2D transform for the var "selfTransform" everything work as it should, the black dot is positionned on the bottom left corner. But if you set a 3d transform with some perspective then it's no longer working and i don't get why.

Any idea ?

Plot twist

In this exemple you can find out that some 3d transforms work well like "rotateX({randomRadianValue})" but it no longer work at the moment where there are cumulative transforms in the DOM tree, i mean if i want to retrieve the coords of a point that is in a div that also is in a tree where other div have 3d transforms all the results are fucked up. But if i'm only using 2d transforms then everything work perfeclty regardless of the depth of the element in the transformed element tree...


Solution

  • One thing that gets in the way is "where your corner points are". If you have a 200x200 rect and you're rotating over the center then corners (as far as the 3D transform is concerned) aren't (0,0), (0,200), (200,200), and (200,0), but instead are (-100,-100), (-100,100), (100,100), and (100,-100).

    So if we capture those transform origin values before we do anything, and then make sure to work those offset into the position for our marker point, things work a lot better:

    // capture our "starting offset":
    const [tx=0, ty=0, tz=0] = getComputedStyle(plane)[`transform-origin`]
      .split(` `)
      .map(parseFloat);
    
    function redraw() {
      a = angle.value;
      label.textContent = a;
      // Set up a transform...
      const transform = `
        perspective(250px)
        rotateX(${2*a  }deg)
        rotateY(${  a/3}deg)
        rotateZ(${  a}deg)
      `;
    
      // And get the matrix equivalent.
      const M = new DOMMatrix(transform);
    
      // Apply the transform to our plane...
      plane.style.transform = transform;
      
      // Draw all four corner points
      document.querySelectorAll(`.point`).forEach(p => p.remove());
      [[-tx, -ty], [-tx,ty], [tx,ty], [tx,-ty]].forEach(coords => {
        // First, transform the 2D corner point into a 4D homogeneous coordinate:
        const { x, y, z, w } = M.transformPoint(new DOMPoint(...coords, -tz));
    
        // then create a div that we're going to place at the
        // corner point's projected location:
        const p = document.createElement(`div`);
        p.classList.add(`point`);
        content.appendChild(p);
    
        // x and y (as well as z but we won't use that) are
        // still homogeneous values, so we need to perform a
        // homogeneous divide to turn them into "real" x and y,
        // and we also need to remember to compensate for
        // the fact that (0,0) is actually at (tx, ty).
        const { style } = p;
        style.setProperty(`--x`, `${x/w + tx}px`);
        style.setProperty(`--y`, `${y/w + ty}px`);
      });
    }
    
    angle.addEventListener(`input`, () => redraw());
    redraw();
    body {
      padding: 2em;
      padding-left: 4em;
      div:has(#slider) {
        margin-bottom: 2em;
        #slider {
          position: relative;
          margin-right: 14em;
          #angle {
            position: absolute;
            width: 15em;
          }
          label[for="angle"] {
            position: absolute;
            top: 1.5em;
            left: 7em;
          }
        }
      }
      #content {
        position: relative;
        width: 200px;
        height: 200px;
        background: yellow;
        #plane {
          position: absolute;
          width: 200px;
          height: 200px;
          background-color: red;
        }
        .point {
          position: absolute;
          --x: 0px;
          --y: 0px;
          top: calc(var(--y) - 3px);
          left: calc(var(--x) - 3px);
          width: 6px;
          height: 6px;
          background-color: black;
          border-radius: 50%;
        }
      }
    }
    <div>
      0 degrees
      <span id="slider">
          <label id="label" for="angle">0</label>
          <input id="angle" type="range" min="0" max="360" step="0.1" value="25">
        </span> 360 degrees
    </div>
    
    <div id="content">
      <div id="plane"></div>
    </div>

    But do note that things like CSS padding and margin can really mess with the placement of those points so you generally want to make sure that you're working in a "sterile" parent container with respect to those.