I really need help calculating the center of the group among its dynamically changing children

Hi everyone, I’ve been racking my brain for more than 10 days trying to solve this problem. The solution is probably obvious, but I just can’t get there.

I’m building a scene editor, and I’ve gotten to the point where I need to handle multiple selection of multiple meshes. So, looking around a bit, I saw that the most popular solution is to add all the selected meshes to a group, then manipulate them through it.

It wasn’t easy for me, but in the end I managed to get what I wanted.

Now my problem is in the centering of the group relative to the selected instances: every time I add a mesh to the selection, I detach all the meshes from the group and reattach them to the scene, I calculate the new center between the meshes marked for the selection, I position the group at the calculated center, I reattach the meshes to the group.

the problem is that when I add or remove meshes to the selection, both the meshes and the group move.

I add here an example that I created by simplifying only the salient points of the editor as much as possible. Running the example, using the buttons at the top, you can add and remove meshes to the group. By doing this, the problem will soon be visible (even adding the same mesh to the group multiple times, although the object is not actually added, will cause the center to be recalculated, still showing the problem).

My need is that by adding and removing meshes to the group, they remain in place, so that the TransformControls can act on them correctly, and that once released, they maintain their set position, rotation and scale.

The offending function, in the example code, is called centerGroup.
The center calculation works correctly (you can see this by commenting out the last forEach of the function).

I would be immensely grateful if you could modify the example code to solve the problem… I don’t know what to try anymore.

here is the code:

<!DOCTYPE html>
<html>
<head>
    <style>
        html, body { background-color: #333; width: 100%; height: 100vh; margin: 0; padding: 0; font-size: 18px; font-family: Verdana; color: silver; }
        button, input, label { background-color: gray; color: #333; margin: 4px; font-size: 18px; user-select: none; }
        #test { width: 100%; height: 85%; }
    </style>

    <!-- change with your path -->
    <script async src="../libs/es-module-shims-1-6-3.js"></script>

    <!-- change with your path -->
    <script type="importmap">
    {
        "imports": {
        "three": "../libs/three/build/three.module.js",
        "three/addons/": "../libs/three/examples/jsm/"
        }
    }
    </script>    

</head>
<body>
    <button id="addRed">Add Red</button>
    <button id="addGreen">Add Green</button>
    <button id="addBlue">Add Blue</button>
    <button id="addYellow">Add Yellow</button>
    to the group
    <br>
    <button id="removeRed">Remove Red</button>
    <button id="removeGreen">Remove Green</button>
    <button id="removeBlue">Remove Blue</button>
    <button id="removeYellow">Remove Yellow</button>
    from the group
    <br>
    <button id="translate">Translate</button>
    <button id="rotate">Rotate</button>
    <button id="scale">Scale</button>

    <canvas id="test"></canvas>

    <script type="module">
        import * as THREE from 'three';
        import { TransformControls } from 'three/addons/controls/TransformControls.js';

        function animate()
        {
            requestAnimationFrame( animate );
            renderer.render( scene, camera );
        };

        /**
         * selection
         */
        //#region 

        function getLinearizedMeshes()
        {
            return selection;
        };

        function addToSelection( what )
        {
            if ( selection.indexOf( what ) < 0 )
                selection.push( what );
            centerGroup();
        };

        function removeFromSelection( what )
        {
            const pos = selection.indexOf( what );
            if ( pos >= 0 )
                selection.splice( pos, 1 );
            centerGroup();
        };

        //#endregion

        function centerGroup()
        {
            /**
             * I attach all the objects associated with the group to the scene
             */
            group.children.reverse().forEach( ( item ) => {
                scene.attach( item );
            } );
            /**
             * I empty the group
             */
            group.clear( );
            /**
             * I retrieve the list of selected objects
             */
            let selections = getLinearizedMeshes( );
            /**
             * I calculate a box that contains all the selected objects
             */
             const __transformOrigin = new THREE.Vector3();
            const __transformBox = new THREE.Box3();
            __transformBox.makeEmpty( );
            selections.forEach( ( item ) => {
                __transformBox.expandByObject( item );
            } );
            /**
             * I get the center of the box (which will be the center of the group)
             */
            __transformBox.getCenter( __transformOrigin );
            const { x, y, z } = __transformOrigin;
            /**
             * I position the group at the new center
             */
            group.position.set(x, y, z);
            /**
             * I remove all the objects from the scene and attach them to the group
             * Note: the center calculation works correctly (it is evident if I comment 
             * the following lines)
             */
            selections.forEach( ( item ) => {
                group.attach( item );
                scene.remove( item );
            } );
        };


        /**
         * Init
         */
        //#region 

        const width = test.offsetWidth;
        const height = test.offsetHeight;

        let canvas = document.getElementById( "test" );

        const renderer = new THREE.WebGLRenderer();
        renderer.setSize( width, height );

        document.body.replaceChild( renderer.domElement, canvas );

        const scene = new THREE.Scene();

        const camera = new THREE.PerspectiveCamera( 45, width / height, 0.1, 1000 );
        camera.rotation.order = 'YXZ';
        camera.position.x = 0.5;
        camera.position.y = -0.5;
        camera.position.z = 15;

        const axesHelper = new THREE.AxesHelper( 200 );
        scene.add( axesHelper );

        const selection = [];
        const control = new TransformControls( camera, renderer.domElement );
        control.setMode( 'translate' );
        scene.add( control );

        const group = new THREE.Group( );
        scene.add( group );

        control.attach( group );

        //#endregion

        /**
         * Add 4 cuboids to the scene
         */
        //#region 

        const geometry = new THREE.BoxGeometry( 1, 1, 1 );
        const materialRed = new THREE.MeshBasicMaterial( { color: "#990000" } );
        const materialGreen = new THREE.MeshBasicMaterial( { color: "#009900" } );
        const materialBlue = new THREE.MeshBasicMaterial( { color: "#000099" } );
        const materialYellow = new THREE.MeshBasicMaterial( { color: "#999900" } );

        const first = new THREE.Mesh( geometry, materialRed );
        first.position.set( -5, 3, 0 );
        scene.add( first );

        const second = new THREE.Mesh( geometry, materialGreen );
        second.position.set( 4, 4, 0 );
        scene.add( second );

        const third = new THREE.Mesh( geometry, materialBlue );
        third.position.set( -3, -5, 0 );
        scene.add( third );

        const fourth = new THREE.Mesh( geometry, materialYellow );
        fourth.position.set( 4, -3, 0 );
        scene.add( fourth );

        //#endregion

        /**
         * UI
         */
        //#region 

        const modes = [ "translate", "rotate", "scale" ];
        modes.forEach( mode => {
            document.getElementById( mode )
                .addEventListener( "click", ( e ) => control.setMode( e.target.getAttribute( "id" ) ) );
        } );

        const colors = { Red: first, Green: second, Blue: third, Yellow: fourth };
        Object.keys( colors ).forEach(c => {
            document.getElementById( "add" + c )
                .addEventListener( "click", () => addToSelection( colors[ c ] ) );
            document.getElementById( "remove" + c )
                .addEventListener( "click", () => removeFromSelection( colors[ c ] ) );
        } );

        //#endregion

        animate();        
    </script>
</body>
</html>
1 Like

My need is that by adding and removing meshes to the group, they remain in place, so that the TransformControls can act on them correctly, and that once released, they maintain their set position, rotation and scale.


let selectionArray = [ mesh1, mesh2, mesh3 ]

let selectionGroup = new THREE.Group() //positioned at 0,0,0 by default
selectionArray.forEach(e=>selectionGroup.add( e ))

selectionGroup.updateMatrixWorld( true ); //Do the heavyweight (true) updateMatrixWorld to make sure it goes both up and down the tree, updating all the transforms..... (This may not be necessary.. I don't remember if one of the later methods does this implicitly )

let bounds = new THREE.Box3().setFromObject( selectionGroup );  //Compute the bounding box..

//Add or Attach the objects back to the scene (.attach preserves the visual transform)
//We can use .add here because we know the group has an identity transform since it was just created..
// Thus we avoid an extra matrix operation that can cause the matrix to drift over time..

while(selectionGroup.children.length)
     scene.add( selectionGroup.children[ 0 ] )

bounds.getCenter( selectionGroup.position  )   //Read the center of the bounding box into the selectionGroup object position...

//Here we have to use .attach, because now the selectionGroup has been transformed..
//(.attach preserves the visual transform)

selectionArray.forEach(e=>selectionGroup.attach( e ))



now you move selectionGroup around as normal... and when the drag operation is done..


selectionArray.forEach(e=>scene.attach( e )) // Attach the transformed object back to the scene

thanks manthrax,

I adapted your code to mine and studied it… and I understood what was wrong with my code… it was the damn loop

group.children.reverse().forEach( ( item ) => {
    scene.attach( item );
} );

which, I don’t know why, didn’t manage all the items…

replaced with

let i = group.children.length;
while( i-- )
{
    const item = group.children[ i ];
    scene.attach( item );
}

now works!
thank you very much.

in the end I preferred to go back to my code, as, being written by me, I understand it better and it integrates better with the rest of the code…

and also because even with your code, I now have the same problem as mine.

And it’s a big problem, which I don’t know how to solve:

As long as I only use translation and rotation via TransformControls there are no problems and everything goes smoothly, but the moment I add the scale, the problems begin!

if I add objects to the group and then rotate the group, then add other objects and rotate, and then scale, when I then go to add other objects to the group they appear deformed!

How can I correctly apply scale along with rotation to various objects?

some idea?

Are you using .attach or .add ?

.add only changes the parent pointer… it doesn’t recompute the transform, so if you .add a mesh to a group that has been transformed… it will suddenly deform to its own transform Plus what has been applied to the group… You can try to use .attach to get around that perhaps?

I already use attach, just to maintain the transformations (but I also tried using add, and the transformations are rightly not preserved).

The problem with the scale is already noticeable just by using the TransformControls… it is enough to play a bit with rotations and scales, changing from one mode to another (and changing the angle and scale of course) several times, to see the problem… .

probably the problem lies precisely in the fact that with each selection/deselection I remove and reattach all the selected objects: something is lost in the meantime… and I can’t keep all the transformations applied correctly…

ugh!

it doesn’t seem clean. to rip items out of their natural context and throw them into a group is normally considered dirty, or an anti-pattern, you’re pulling the carpet.

to me it feels like working around limitations of transformcontrols, which attach to one child. but now the whole app has to conform to it. usually it’s not a good idea to conform, it’s better to fix the root of the problem which is probably the control.

to give you a perspective, this is using pivotcontrols:

pivot is more customisable and can transform as a matrix4, which you can then apply to whichever item you want. in this case for instance you pick up one object and it moves three more, but you could make it do whatever you want. if it’s not too late, look into the react eco system for three, especially for projects at that scale (manipulating a scene).

if that’s not an option, you’re looking at a fork of transformcontrols, this is what i would focus on if i was you.

Do you have a citation for your assertion that changing the hierarchy is an anti-pattern?

Because in my experience changing the hierarchy of a group of objects is a completely normal and common pattern.

Any cumulative transformations applied to objects will eventually result in matrix drift, which is an issue present regardless of whether you apply the transformation via a matrix, or via .attach (which is doing the same thing)

Perhaps instead of suggesting a switch in technologies, you could link an example showing your proposed solution, or provide some citations?

1 Like

if i have to change the scene graph because a control has a limitation that can and will only create worse problems. the root of the problem is not the scene. as you can see in the sandbox i posted, this can be solved with a matrix, if the control behaves sane.

the scene should be naturally grouped, for instance if it reflects an assembly, or layered authoring UI. mutation as a side effect generally is considered an anti pattern. if A changes B while C reads B, we have a problem. i am not a well read person, i don’t know any quotes, but it’s a sentiment i’ve seen countless of times. and of course i have been bitten by mutation like everybody else has.

Everybody, calm down.

I’m not quite sure I understand the problem. I made a demo. Gray objects are unselected objects. Orange objects are selected objects. Each 5th second a new object is selected. The selected objects rotate continuously (as a group), and when a new object is automatically selected it joins the group and starts to rotate with it.

For visual clarity, the object that will be added to the selection changes its color and when it is ready (i.e. orange), it becomes a member of the selection. There is a red border, it indicates the bounding box of the selection.

Important things in the code:

  • Line 51: selection is a THREE.Group of selected objects
  • Lines 61-93: function select( index ) adds an object to the selection

https://codepen.io/boytchev/full/RwEVGLv

2 Likes

Great example @PavelBoytchev … If I understand correctly, you are following the same approach as OP with adding the selected objects to a group, and then allowing the transformcontrols to transform the group? (after which, in a production scenario, when the objects are de-selected, they would be added back to their original parents?)

Yes, the idea is the same, there are a few minor differences in the implementation:

  • I’m not polluting the global scene, but using a second THREE.Group (I almost manage to do it without a second group, and the code is much shorter and faster, but there was some bug and I was too sleepy to find it, so I gave up)
  • changing the center of the group needs adjustment in the positions of all children – maybe this is what the title of the thread is all about
  • managing joining could be done without attach, by using several matrix operations … but attach already has them + a group is useful if all selected objects are manipulated as a group … so I ended up using attach

I did the demo, because I was not able to understand what is the problem. I expected to experience the problem, but it all went much better than expected. BTW, I’m still unsure whether the demo is relevant to the OP’s question.

1 Like

I think it is relevant. OP was trying to achieve the result using purely matrix operations… which is possible, but error prone, and .attach et al… gets you most of the way there in less steps.

But either way… @Francesco_Iafulli has some more ideas to think about.
Thanks @PavelBoytchev and @drcmda! :slight_smile:

1 Like

ok guys, I thank you infinitely because from this discussion with you and from the research inspired by what you wrote I really learned a lot.

I now have only one problem left that I haven’t been able to solve, and it’s related to the scale

when I scale objects, if the scale axis is parallel to one of the faces, the scaling occurs correctly, while if it is not, the object is stretched during the operation, and when I release the mouse button, the object is made to “jump” somehow to a correct shape (assuming it is)

if you watch the video below for a while you can see what I mean…

Is there a way to avoid this stretching? or if the stretching is correct, is there a way to not make it jump to the new shape? in short, is there a way for the scaling operation to be consistent from start to finish? I couldn’t find a way…

I also leave the source on codepen… thanks again infinitely

CodePen

1 Like