From cceb22e95a42034d302a38446ac6456bb08c7206 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:43:54 -0400 Subject: [PATCH] fix(templates): apply rotation as well as scale before grounding the origin ai-mesh-cleanup and the pipeline template applied transforms only when scale was non-identity. origin_to_base() shifts geometry along local Z, so an imported glTF node with a rotation and unit scale (common) was grounded along the wrong axis and moved in world space: a cube rotated 90 deg on X at z=5 shifted -1 in Y and Z. The gate is now rot_scale_is_identity(), which checks the whole 3x3 of matrix_basis, so rotation is applied too and local Z equals world Z before grounding. origin_to_base() states the precondition and the skill explains why steps 3 and 4 must run in that order. Closes #389 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- skills/ai-mesh-cleanup/SKILL.md | 22 ++++++++++++++----- .../ai-asset-pipeline-template/pipeline.py | 16 ++++++++++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/skills/ai-mesh-cleanup/SKILL.md b/skills/ai-mesh-cleanup/SKILL.md index dbd567a7..f0b84209 100644 --- a/skills/ai-mesh-cleanup/SKILL.md +++ b/skills/ai-mesh-cleanup/SKILL.md @@ -41,9 +41,16 @@ def scene_units_are_meters(scene): return abs(units.scale_length - 1.0) < 1e-6 -def scale_is_identity(obj, tol=1e-6): - sx, sy, sz = obj.scale - return abs(sx - 1.0) < tol and abs(sy - 1.0) < tol and abs(sz - 1.0) < tol +def rot_scale_is_identity(obj, tol=1e-6): + # Rotation and scale both: origin_to_base() shifts along local Z, which is + # world Z only once rotation is applied. A GLB node often carries a + # rotation with identity scale. + m = obj.matrix_basis.to_3x3() + return all( + abs(m[i][j] - (1.0 if i == j else 0.0)) < tol + for i in range(3) + for j in range(3) + ) def apply_transforms(objs): @@ -61,6 +68,7 @@ def apply_transforms(objs): def origin_to_base(obj): + # Precondition: rotation and scale applied (local Z == world Z). mesh = obj.data n = len(mesh.vertices) flat = [0.0] * (n * 3) @@ -112,18 +120,20 @@ if not scene_units_are_meters(scene): scene.unit_settings.system = "METRIC" scene.unit_settings.scale_length = 1.0 -apply_transforms([o for o in imported_meshes() if not scale_is_identity(o)]) +apply_transforms([o for o in imported_meshes() if not rot_scale_is_identity(o)]) ``` `export_apply=True` on glTF applies **modifiers**, not object scale. Unapplied object scale lands on the glTF node. Witness: [`examples/unapplied-scale-gltf/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/unapplied-scale-gltf). ### 3. Apply transforms -`transform_apply` needs a real object in context. Use `temp_override`, not `bpy.context.copy()`. After apply, `obj.scale` is `(1, 1, 1)` and `obj.data` vertex positions hold the world size. +`transform_apply` needs a real object in context. Use `temp_override`, not `bpy.context.copy()`. After apply, `obj.scale` is `(1, 1, 1)`, rotation is zero, and `obj.data` vertex positions hold the world size and orientation. + +Gate the apply on rotation **and** scale (`rot_scale_is_identity`). Imported glTF nodes often carry a rotation with identity scale; skipping the apply then makes step 4 ground the mesh along its local Z, which moves it in world space (a cube rotated 90° on X at z=5 shifted by −1 in Y and Z). ### 4. Set origin -Origin at the lowest Z of the mesh (sit-on-ground) via `foreach_get` / `foreach_set`, not a Python loop on `mesh.vertices`. See [`examples/prop-origin-transform/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/prop-origin-transform) for origin-to-base plus `matrix_parent_inverse`. +Origin at the lowest Z of the mesh (sit-on-ground) via `foreach_get` / `foreach_set`, not a Python loop on `mesh.vertices`. This works in local space, so run it only after step 3. See [`examples/prop-origin-transform/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/prop-origin-transform) for origin-to-base plus `matrix_parent_inverse`. ### 5. Recalculate normals diff --git a/templates/ai-asset-pipeline-template/pipeline.py b/templates/ai-asset-pipeline-template/pipeline.py index 80b26320..a98dfad4 100644 --- a/templates/ai-asset-pipeline-template/pipeline.py +++ b/templates/ai-asset-pipeline-template/pipeline.py @@ -102,9 +102,16 @@ def scene_units_are_meters(scene): return abs(units.scale_length - 1.0) < 1e-6 -def scale_is_identity(obj, tol=1e-6): - sx, sy, sz = obj.scale - return abs(sx - 1.0) < tol and abs(sy - 1.0) < tol and abs(sz - 1.0) < tol +def rot_scale_is_identity(obj, tol=1e-6): + # Rotation and scale both: origin_to_base() shifts along local Z, which is + # world Z only once rotation is applied. A GLB node often carries a + # rotation with identity scale. + m = obj.matrix_basis.to_3x3() + return all( + abs(m[i][j] - (1.0 if i == j else 0.0)) < tol + for i in range(3) + for j in range(3) + ) def apply_object_transform(obj): @@ -115,6 +122,7 @@ def apply_object_transform(obj): def origin_to_base(obj): + # Precondition: rotation and scale applied (local Z == world Z). mesh = obj.data n = len(mesh.vertices) if n == 0: @@ -356,7 +364,7 @@ def main(): print("Set scene units to metric meters") hero = max(meshes, key=evaluated_triangle_count) - if not scale_is_identity(hero): + if not rot_scale_is_identity(hero): apply_object_transform(hero) print(f"Applied object scale/rotation on {hero.name}") origin_to_base(hero)