Alembic: refactor import and export of transformations
authorSybren A. Stüvel <sybren@blender.org>
Fri, 14 Feb 2020 14:21:19 +0000 (15:21 +0100)
committerSybren A. Stüvel <sybren@blender.org>
Fri, 14 Feb 2020 14:41:17 +0000 (15:41 +0100)
The Alembic importer now works with local coordinates. Previously, the
importer converted transformations from Alembic to world coordinates
before processing them further; this processing often included
re-converting to local coordinates. This change made it possible to
remove some code that assumed that a child transform was only read after
its parent transform.

Blender's Alembic code follows the Maya convention, where in the zero
orientation the camera looks forward instead of down. This extra
rotation is now handled more consistently, and now also properly handles
children of cameras. This fixes T73269.

Unit tests were added to at least ensure that the importer and exporter
are compatible with each other, and that static and animated camera
transforms are handled in the same way.

source/blender/alembic/intern/abc_object.cc
source/blender/alembic/intern/abc_transform.cc
source/blender/alembic/intern/abc_util.cc
source/blender/alembic/intern/abc_util.h
source/blender/blenkernel/intern/constraint.c
source/blender/blenloader/intern/versioning_280.c
tests/python/bl_alembic_io_test.py

index f6f266c808e87fd417e6eb1434cf768fa82d0c36..4799ed557c82b9f92e630e47b8c4bec622bbd94c 100644 (file)
@@ -257,11 +257,19 @@ bool AbcObjectReader::topology_changed(Mesh * /*existing_mesh*/,
 void AbcObjectReader::setupObjectTransform(const float time)
 {
   bool is_constant = false;
+  float transform_from_alembic[4][4];
 
-  this->read_matrix(m_object->obmat, time, m_settings->scale, is_constant);
-  invert_m4_m4(m_object->imat, m_object->obmat);
+  /* If the parent is a camera, apply the inverse rotation to make up for the from-Maya rotation.
+   * This assumes that the parent object also was imported from Alembic. */
+  if (m_object->parent != nullptr && m_object->parent->type == OB_CAMERA) {
+    axis_angle_to_mat4_single(m_object->parentinv, 'X', -M_PI_2);
+  }
+
+  this->read_matrix(transform_from_alembic, time, m_settings->scale, is_constant);
 
-  BKE_object_apply_mat4(m_object, m_object->obmat, false, false);
+  /* Apply the matrix to the object. */
+  BKE_object_apply_mat4(m_object, transform_from_alembic, true, false);
+  BKE_object_to_mat4(m_object, m_object->obmat);
 
   if (!is_constant) {
     bConstraint *con = BKE_constraint_add_for_object(
@@ -311,7 +319,7 @@ Alembic::AbcGeom::IXform AbcObjectReader::xform()
   return IXform();
 }
 
-void AbcObjectReader::read_matrix(float r_mat[4][4],
+void AbcObjectReader::read_matrix(float r_mat[4][4] /* local matrix */,
                                   const float time,
                                   const float scale,
                                   bool &is_constant)
@@ -331,25 +339,19 @@ void AbcObjectReader::read_matrix(float r_mat[4][4],
   }
 
   const Imath::M44d matrix = get_matrix(schema, time);
-  convert_matrix(matrix, m_object, r_mat);
-
-  if (m_inherits_xform) {
-    /* In this case, the matrix in Alembic is in local coordinates, so
-     * convert to world matrix. To prevent us from reading and accumulating
-     * all parent matrices in the Alembic file, we assume that the Blender
-     * parent object is already updated for the current timekey, and use its
-     * world matrix. */
-    if (m_object->parent) {
-      mul_m4_m4m4(r_mat, m_object->parent->obmat, r_mat);
-    }
-    else {
-      /* This can happen if the user deleted the parent object, but also if the Alembic parent was
-       * not imported (because of unknown/unsupported schema, for example). In that case just use
-       * the local matrix as if it is the world matrix. This allows us to import Alembic files from
-       * MeshRoom, see T61935. */
-    }
+  convert_matrix(matrix, r_mat);
+  copy_m44_axis_swap(r_mat, r_mat, ABC_ZUP_FROM_YUP);
+
+  /* Convert from Maya to Blender camera orientation. Children of this camera
+   * will have the opposite transform as their Parent Inverse matrix.
+   * See AbcObjectReader::setupObjectTransform(). */
+  if (m_object->type == OB_CAMERA) {
+    float camera_rotation[4][4];
+    axis_angle_to_mat4_single(camera_rotation, 'X', M_PI_2);
+    mul_m4_m4m4(r_mat, r_mat, camera_rotation);
   }
-  else {
+
+  if (!m_inherits_xform) {
     /* Only apply scaling to root objects, parenting will propagate it. */
     float scale_mat[4][4];
     scale_m4_fl(scale_mat, scale);
index 585d4178e4160b89a8df5e87dd1ef171f4a3cfcb..838e657fee98ae83d5b4d1514b7f5c61b3fffe49 100644 (file)
@@ -42,23 +42,6 @@ using Alembic::AbcGeom::OXform;
 
 /* ************************************************************************** */
 
-static bool has_parent_camera(Object *ob)
-{
-  if (!ob->parent) {
-    return false;
-  }
-
-  Object *parent = ob->parent;
-
-  if (parent->type == OB_CAMERA) {
-    return true;
-  }
-
-  return has_parent_camera(parent);
-}
-
-/* ************************************************************************** */
-
 AbcTransformWriter::AbcTransformWriter(Object *ob,
                                        const OObject &abc_parent,
                                        AbcTransformWriter *parent,
@@ -98,14 +81,22 @@ void AbcTransformWriter::do_write()
   create_transform_matrix(
       ob_eval, yup_mat, m_inherits_xform ? ABC_MATRIX_LOCAL : ABC_MATRIX_WORLD, m_proxy_from);
 
-  /* Only apply rotation to root camera, parenting will propagate it. */
-  if (ob_eval->type == OB_CAMERA && (!m_inherits_xform || !has_parent_camera(ob_eval))) {
+  /* If the parent is a camera, undo its to-Maya rotation (see below). */
+  bool is_root_object = !m_inherits_xform || ob_eval->parent == nullptr;
+  if (!is_root_object && ob_eval->parent->type == OB_CAMERA) {
+    float rot_mat[4][4];
+    axis_angle_to_mat4_single(rot_mat, 'X', M_PI_2);
+    mul_m4_m4m4(yup_mat, rot_mat, yup_mat);
+  }
+
+  /* If the object is a camera, apply an extra rotation to Maya camera orientation. */
+  if (ob_eval->type == OB_CAMERA) {
     float rot_mat[4][4];
     axis_angle_to_mat4_single(rot_mat, 'X', -M_PI_2);
     mul_m4_m4m4(yup_mat, yup_mat, rot_mat);
   }
 
-  if (!ob_eval->parent || !m_inherits_xform) {
+  if (is_root_object) {
     /* Only apply scaling to root objects, parenting will propagate it. */
     float scale_mat[4][4];
     scale_m4_fl(scale_mat, m_settings.global_scale);
index b6743c8b36369ea54edb4112a794f07f9f5410e5..56978374512aac0a70c73304493736ad2375f07c 100644 (file)
@@ -224,21 +224,13 @@ void copy_m44_axis_swap(float dst_mat[4][4], float src_mat[4][4], AbcAxisSwapMod
   mul_m4_m4m4(dst_mat, dst_mat, dst_scale_mat);
 }
 
-void convert_matrix(const Imath::M44d &xform, Object *ob, float r_mat[4][4])
+void convert_matrix(const Imath::M44d &xform, float r_mat[4][4])
 {
   for (int i = 0; i < 4; i++) {
     for (int j = 0; j < 4; j++) {
       r_mat[i][j] = static_cast<float>(xform[i][j]);
     }
   }
-
-  if (ob->type == OB_CAMERA) {
-    float cam_to_yup[4][4];
-    axis_angle_to_mat4_single(cam_to_yup, 'X', M_PI_2);
-    mul_m4_m4m4(r_mat, r_mat, cam_to_yup);
-  }
-
-  copy_m44_axis_swap(r_mat, r_mat, ABC_ZUP_FROM_YUP);
 }
 
 /* Recompute transform matrix of object in new coordinate system
index 5eb0ed7059920969fe7eafca2dbc2a61ebe2eee0..bfeded37c12729dedcc99c3b1bf3ec597398cef3 100644 (file)
@@ -51,6 +51,7 @@ std::string get_id_name(const ID *const id);
 std::string get_id_name(const Object *const ob);
 std::string get_object_dag_path_name(const Object *const ob, Object *dupli_parent);
 
+/* Convert from float to Alembic matrix representations. Does NOT convert from Z-up to Y-up. */
 Imath::M44d convert_matrix(float mat[4][4]);
 
 typedef enum {
@@ -69,7 +70,8 @@ template<class TContainer> bool begins_with(const TContainer &input, const TCont
   return input.size() >= match.size() && std::equal(match.begin(), match.end(), input.begin());
 }
 
-void convert_matrix(const Imath::M44d &xform, Object *ob, float r_mat[4][4]);
+/* Convert from Alembic to float matrix representations. Does NOT convert from Y-up to Z-up. */
+void convert_matrix(const Imath::M44d &xform, float r_mat[4][4]);
 
 template<typename Schema>
 void get_min_max_time_ex(const Schema &schema, chrono_t &min, chrono_t &max)
index 76acaf5c91c576740ce7729e3456bfd4b4e7cf4d..42efd9a705785f98384ab528aa1d83718bb77ba1 100644 (file)
@@ -5271,6 +5271,9 @@ static bConstraint *add_new_constraint(Object *ob,
       }
       break;
     }
+    case CONSTRAINT_TYPE_TRANSFORM_CACHE:
+      con->ownspace = CONSTRAINT_SPACE_LOCAL;
+      break;
   }
 
   return con;
index f9c897471205d6a18f7bb63d34f380139ba6fe13..d306049a7e01a1caef9079c6888aa934db46c919 100644 (file)
@@ -4473,5 +4473,14 @@ void blo_do_versions_280(FileData *fd, Library *UNUSED(lib), Main *bmain)
    */
   {
     /* Keep this block, even when empty. */
+    
+    /* Alembic Transform Cache changed from world to local space. */
+    LISTBASE_FOREACH (Object *, ob, &bmain->objects) {
+      LISTBASE_FOREACH (bConstraint *, con, &ob->constraints) {
+        if (con->type == CONSTRAINT_TYPE_TRANSFORM_CACHE) {
+          con->ownspace = CONSTRAINT_SPACE_LOCAL;
+        }
+      }
+    }
   }
 }
index 41b28cb7c33dd5263ad6df64022e9f572272c69a..0f74b773c328a74d39b88587d6a4eaef682ce96e 100644 (file)
 ./blender.bin --background -noaudio --factory-startup --python tests/python/bl_alembic_io_test.py -- --testdir /path/to/lib/tests/alembic
 """
 
+import math
 import pathlib
 import sys
+import tempfile
 import unittest
 
 import bpy
+from mathutils import Euler, Matrix, Vector
 
 args = None
 
@@ -134,8 +137,6 @@ class SimpleImportTest(AbstractAlembicTest):
             self.assertEqual('Cube' in ob.name, ob.select_get())
 
     def test_change_path_constraint(self):
-        import math
-
         fname = 'cube-rotating1.abc'
         abc = self.testdir / fname
         relpath = bpy.path.relpath(str(abc))
@@ -250,6 +251,105 @@ class VertexColourImportTest(AbstractAlembicTest):
         self.assertAlmostEqualFloatArray(layer.data[99].color, (0.1294117, 0.3529411, 0.7529411, 1.0))
 
 
+class CameraExportImportTest(unittest.TestCase):
+    names = [
+        'CAM_Unit_Transform',
+        'CAM_Look_+Y',
+        'CAM_Static_Child_Left',
+        'CAM_Static_Child_Right',
+        'Static_Child',
+        'CAM_Animated',
+        'CAM_Animated_Child_Left',
+        'CAM_Animated_Child_Right',
+        'Animated_Child',
+    ]
+
+    def setUp(self):
+        self._tempdir = tempfile.TemporaryDirectory()
+        self.tempdir = pathlib.Path(self._tempdir.name)
+
+    def tearDown(self):
+        self._tempdir.cleanup()
+
+    def test_export_hierarchy(self):
+        self.do_export_import_test(flatten=False)
+
+        # Double-check that the export was hierarchical.
+        objects = bpy.context.scene.collection.objects
+        for name in self.names:
+            if 'Child' in name:
+                self.assertIsNotNone(objects[name].parent)
+            else:
+                self.assertIsNone(objects[name].parent)
+
+    def test_export_flattened(self):
+        self.do_export_import_test(flatten=True)
+
+        # Double-check that the export was flat.
+        objects = bpy.context.scene.collection.objects
+        for name in self.names:
+            self.assertIsNone(objects[name].parent)
+
+    def do_export_import_test(self, *, flatten: bool):
+        bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "camera_transforms.blend"))
+
+        abc_path = self.tempdir / "camera_transforms.abc"
+        self.assertIn('FINISHED', bpy.ops.wm.alembic_export(
+            filepath=str(abc_path),
+            renderable_only=False,
+            flatten=flatten,
+        ))
+
+        # Re-import what we just exported into an empty file.
+        bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "empty.blend"))
+        self.assertIn('FINISHED', bpy.ops.wm.alembic_import(filepath=str(abc_path)))
+
+        # Test that the import was ok.
+        bpy.context.scene.frame_set(1)
+        self.loc_rot_scale('CAM_Unit_Transform', (0, 0, 0), (0, 0, 0))
+
+        self.loc_rot_scale('CAM_Look_+Y', (2, 0, 0), (90, 0, 0))
+        self.loc_rot_scale('CAM_Static_Child_Left', (2-0.15, 0, 0), (90, 0, 0))
+        self.loc_rot_scale('CAM_Static_Child_Right', (2+0.15, 0, 0), (90, 0, 0))
+        self.loc_rot_scale('Static_Child', (2, 0, 1), (90, 0, 0))
+
+        self.loc_rot_scale('CAM_Animated', (4, 0, 0), (90, 0, 0))
+        self.loc_rot_scale('CAM_Animated_Child_Left', (4-0.15, 0, 0), (90, 0, 0))
+        self.loc_rot_scale('CAM_Animated_Child_Right', (4+0.15, 0, 0), (90, 0, 0))
+        self.loc_rot_scale('Animated_Child', (4, 0, 1), (90, 0, 0))
+
+        bpy.context.scene.frame_set(10)
+
+        self.loc_rot_scale('CAM_Animated', (4, 1, 2), (90, 0, 25))
+        self.loc_rot_scale('CAM_Animated_Child_Left', (3.864053, 0.936607, 2), (90, 0, 25))
+        self.loc_rot_scale('CAM_Animated_Child_Right', (4.135946, 1.063392, 2), (90, 0, 25))
+        self.loc_rot_scale('Animated_Child', (4, 1, 3), (90, -45, 25))
+
+    def loc_rot_scale(self, name: str, expect_loc, expect_rot_deg):
+        """Assert world loc/rot/scale is OK."""
+
+        objects = bpy.context.scene.collection.objects
+        depsgraph = bpy.context.evaluated_depsgraph_get()
+        ob_eval = objects[name].evaluated_get(depsgraph)
+
+        actual_loc = ob_eval.matrix_world.to_translation()
+        actual_rot = ob_eval.matrix_world.to_euler('XYZ')
+        actual_scale = ob_eval.matrix_world.to_scale()
+
+        self.assertAlmostEqual(expect_loc[0], actual_loc.x, delta=1e-5)
+        self.assertAlmostEqual(expect_loc[1], actual_loc.y, delta=1e-5)
+        self.assertAlmostEqual(expect_loc[2], actual_loc.z, delta=1e-5)
+
+        self.assertAlmostEqual(expect_rot_deg[0], math.degrees(actual_rot.x), delta=1e-5)
+        self.assertAlmostEqual(expect_rot_deg[1], math.degrees(actual_rot.y), delta=1e-5)
+        self.assertAlmostEqual(expect_rot_deg[2], math.degrees(actual_rot.z), delta=1e-5)
+
+        # This test doesn't use scale.
+        self.assertAlmostEqual(1, actual_scale.x, delta=1e-5)
+        self.assertAlmostEqual(1, actual_scale.y, delta=1e-5)
+        self.assertAlmostEqual(1, actual_scale.z, delta=1e-5)
+
+
 def main():
     global args
     import argparse