Search code examples
c#winformsmathgeometry

Proportionally scaling objects, within another object when rotated


I am writing a Labelling Software and I have encountered a very difficult mathematical problem.

I have objects which I can scale up, down, left, right, up-right, down-left etc. I have wrote all of the logic to handle all of those cases, even for grouped objects. The problem occurs when we have a rotation. Now, rotation for single objects works perfectly fine, the issue is within the logic when trying to proportionally scale all of the objects within the object that is being scaled while rotated.

I have written a bare-bone project which we can use to showcase this problem.

Let me show you an example.

Correct scaling behaviour of a single object, even with rotation:

Correct scaling behaviour of Parent and Children objects without rotation:

Wrong scaling behaviour of Parent and Children objects with rotation:

I've been boggling with this issue for the past couple of days, and I am much further now as you can see in the third animation that the scaling is at least correct when I scale it perfectly diagonally but it took longer than it should, and right now I am stuck because I have no idea how to imagine the calculation in my head for this to correctly behave when scaled in just one direction.

Although this is a bare-boned project, it has a bit going to itself due to the nature of it so I will add a link to the source code if anyone wants to run it for themselves, but here is some core code:

The Object Class:

public Dictionary<OriginName, PointF> Vector4Points { get; private set; }

public List<ObjRectangle> Children = new List<ObjRectangle>();

public OriginName GrabbedResizeOriginName;

private PointF _location;
public PointF Location
{
    get
    {
        return _location;
    }
    set
    {
        PointF lastLocation = _location;

        PointF moveDif = new PointF(value.X - lastLocation.X, value.Y - lastLocation.Y);

        _location = value;

        Origin = new PointF(Origin.X + moveDif.X, Origin.Y + moveDif.Y);

        foreach(ObjRectangle objRectangle in Children) 
        {
            objRectangle.MoveBy(moveDif);
        }

        Vector4Points = Get4VectorPoints();
    }
}

private SizeF _size;
public SizeF Size
{
    get
    {
        return _size;
    }
    set
    {
        float xDif = value.Width - _size.Width;
        float yDif = value.Height - _size.Height;

        switch (GrabbedResizeOriginName)
        {
            case OriginName.TopRight:
                if (Rotation == 0f) // No rotation
                    Location = new PointF(Location.X, Location.Y - yDif);
                else
                {
                    PointF rotatedDelta = Utilities.RotatePoint(new PointF(0, -yDif), new PointF(0, 0), Rotation);
                Location = new PointF(Location.X + rotatedDelta.X, Location.Y + rotatedDelta.Y);
                }
                break;
            case OriginName.BottomLeft:
                if (Rotation == 0f) // No rotation
                    Location = new PointF(Location.X - xDif, Location.Y);
                else
                {
                    PointF rotatedDelta = Utilities.RotatePoint(new PointF(-xDif, 0), new PointF(0, 0), Rotation);
                    Location = new PointF(Location.X + rotatedDelta.X, Location.Y + rotatedDelta.Y);
                }
                break;
            case OriginName.TopLeft:
                if (Rotation == 0f) // No rotation
                    Location = new PointF(Location.X - xDif, Location.Y - yDif);
                else
                {
                    PointF rotatedDelta = Utilities.RotatePoint(new PointF(-xDif, -yDif), new PointF(0, 0), Rotation);
                    Location = new PointF(Location.X + rotatedDelta.X, Location.Y + rotatedDelta.Y);
                }
                    break;
            default:
                break;
        }

        _size = value;
        Vector4Points = Get4VectorPoints();
    }
}

public void MoveBy(PointF moveBy)
{
    Location = new PointF(Location.X + moveBy.X, Location.Y + moveBy.Y);
}

public void ResizeParentAndChildren(SizeF newSize)
{
    float scaleX = newSize.Width / Size.Width;
    float scaleY = newSize.Height / Size.Height;

    foreach (ObjRectangle child in Children)
    {
        child.GrabbedResizeOriginName = GrabbedResizeOriginName;

    // Calculate the offset of the child from the parent's original origin
    PointF childOffset = new PointF(child.Location.X - Location.X, child.Location.Y - Location.Y);

    // Scale the offset based on the scaling factors
    PointF scaledOffset = new PointF(childOffset.X * scaleX, childOffset.Y * scaleY);

    // Calculate the new position of the child relative to the new origin of the parent
    PointF newChildPosition = new PointF(Location.X + scaledOffset.X, Location.Y + scaledOffset.Y);

    // Scale the size of the child
    child.Size = new SizeF(child.Size.Width * scaleX, child.Size.Height * scaleY);

    // Set the new position of the child
    child.Location = newChildPosition;
    }

    Size = newSize;
}

public Dictionary<OriginName, PointF> Get4VectorPoints()
{
    Dictionary<OriginName, PointF> points = new Dictionary<OriginName, PointF>
    {
        { OriginName.TopLeft, Utilities.RotatePoint(new PointF(Location.X, Location.Y), Origin, Rotation) },
        { OriginName.TopRight, Utilities.RotatePoint(new PointF(Location.X + Size.Width, Location.Y), Origin, Rotation) },
        { OriginName.BottomRight, Utilities.RotatePoint(new PointF(Location.X + Size.Width, Location.Y + Size.Height), Origin, Rotation) },
        { OriginName.BottomLeft, Utilities.RotatePoint(new PointF(Location.X, Location.Y + Size.Height), Origin, Rotation) }
    };

    return points;
}

Point Rotation Utility Function:

public static PointF RotatePoint(PointF point, PointF origin, double angleDegrees)
{
    // Convert angle from degrees to radians
    double angleRadians = angleDegrees * Math.PI / 180.0;

    // Translate point so that origin is at (0, 0)
    double translatedX = point.X - origin.X;
    double translatedY = point.Y - origin.Y;

    // Perform rotation
    double rotatedX = translatedX * Math.Cos(angleRadians) - translatedY * Math.Sin(angleRadians);
    double rotatedY = translatedX * Math.Sin(angleRadians) + translatedY * Math.Cos(angleRadians);

     // Translate point back to its original position
     rotatedX += origin.X;
     rotatedY += origin.Y;

     return new PointF((float)rotatedX, (float)rotatedY);
}

On Mouse Move Event resizing code:

if (_resizingObject)
{
    PointF rotatedDelta = Utilities.RotatePoint(mouseDelta, new PointF(0, 0), -geometryContainer.SelectedObject.Rotation);

    float deltaX = rotatedDelta.X;
    float deltaY = rotatedDelta.Y;


    switch (geometryContainer.SelectedObject.GrabbedResizeOriginName)
    {
        case OriginName.TopLeft:
            deltaX = -deltaX;
            deltaY = -deltaY;
            break;
        case OriginName.TopRight:
            deltaY = -deltaY;
            break;
        case OriginName.BottomLeft:
            deltaX = -deltaX;
            break;
        default:
            break;
    }

    geometryContainer.SelectedObject.ResizeParentAndChildren(new SizeF(geometryContainer.SelectedObject.Size.Width + deltaX, geometryContainer.SelectedObject.Size.Height + deltaY));
    Invalidate();
}

Here is a test project download link: https://uploadnow.io/f/GF2PQb7

To add an object, right click on the form.

In order to add a child object, right click inside already existing object.

In order to rotate an object, you have to click on it and use the scroll wheel.

Thank you for answers in advance!


Solution

  • Rather than trying to figure out nested transforms for each element separately, it's much easier to work with groups, and "transform your coordinate system" (or use a singleton transformer that maintains a matrix that you use to transform your "real coordinates" into "draw coordinates"). If you model groups of elements using a tree relation, then each node can update the current coordinate transformation based on its local transform parameters, draw itself, then tells all its child nodes to do the same (which will apply their own transforms on top of the current one), and then as last step, undo their local transform so the coordinate system is back to what it was before we started drawing.

    In JS (using class notation, so it should be near-trivial to implement in C#), with code that assumes you have something that can track transforms through separate translate, rotate, and scale functions:

    class DrawableElement {
      // ...first some boring boilerplate...
      ox = 0; // translation
      oy = 0;
      sx = 1; // scale
      sy = 1;
      angle = 0; // rotation
      children = [];
    
      constructor(x, y) {
        this.x = x;
        this.y = y;
      }
    
      setRotation(a) {
        this.angle = a;
      }
    
      setScale(x, y) {
        if (x) this.sx = x;
        if (y) this.sy = y;
      }
    
      // Then, the parts that matter: 
    
      addChild(child) {
        child.setParent(this);
        this.children.push(child);
      }
    
      setParent(parent) {
        this.parent = parent;
        // Set our parent's position as negative offset,
        // so we can draw ourselves relative to the parent.
        this.ox = -parent.x;
        this.oy = -parent.y;
      }
    
      // This is the part that really matters, as this
      // is the part that handles where/how things get drawn:
      draw() {
        // first, update the coordinate system
        this.applyTransform();
    
        // then draw ourselves
        this.drawSelf();
    
        // then draw our children without resetting the
        // coordinate system, so that their transforms go
        // "on top of" the transform that's already in effect:
        this.children.forEach(r => r.draw());
    
        // then undo (only) our coordinate transform
        this.reverseTransform();
      }
    
      applyTransform() {
        // note that the order matters here. If we scale before
        // rotation, for example, we'll end up skewing instead
        // of scaling...
        translate(this.x + this.ox, this.y + this.oy);
        rotate(this.angle);
        scale(this.sx, this.sy)
      }
    
      reverseTransform() {
        // and of course order matters here, too.
        scale(1/this.sx, 1/this.sy);
        // note that the above *can* lead to rounding errors
        // doing funny things, which a transformer class that
        // can cache and restore transformation matrices won't
        // be susceptible to, at the cost of "a bit more memory".
        rotate(-this.angle);
        translate(-(this.x + this.ox), -(this.y + this.oy));
      }
    }
    

    And then because we want to draw rectangles, we create an extension of this class:

    class Rectangle extends DrawableElement {
      constructor(x, y, w = 0, h = 0) {
        super(x,y);
        this.w = w;
        this.h = h;
      }  
      drawSelf() {
        // because we're applying a transform that
        // puts the coordinate system's (0,0) on our
        // (x,y), we draw relative to (0,0). Handy!
        setStroke(`black`);
        setFill(`#3332`);
        rect(0, 0, this.w, this.h);
        // draw that upper-left corner, too
        setFill(`red`);
        point(0, 0);
      }
    }
    

    The trick is now to make sure to "group" (performed in the above addChild code path) and "ungroup" (not implemented in this example) objects so that transforms will apply "to the group" rather than to individual drawable elements.

    (this is also why applications like illustrator, inkscape, blender, etc. all come with grouping and ungrouping).

    Putting this all together as a runnable demo:

    function sourceCode() {
      class DrawableElement {
        ox = 0;
        oy = 0;
        sx = 1;
        sy = 1;
        angle = 0;
        children = [];
        constructor(x, y) {
          this.x = x;
          this.y = y;
        }
        setRotation(a) {
          this.angle = a;
        }
        setScale(x, y) {
          if (x) this.sx = x;
          if (y) this.sy = y;
        }
        addChild(child) {
          child.setParent(this);
          this.children.push(child);
        }
        setParent(parent) {
          this.parent = parent;
          this.ox = -parent.x;
          this.oy = -parent.y;
        }
        draw() {
          this.applyTransform();
          this.drawSelf();
          this.children.forEach(r => r.draw());
          this.reverseTransform();
        }
        applyTransform() {
          translate(this.x + this.ox, this.y + this.oy);
          rotate(this.angle);
          scale(this.sx, this.sy)
        }
        reverseTransform() {
          scale(1 / this.sx, 1 / this.sy);
          rotate(-this.angle);
          translate(-(this.x + this.ox), -(this.y + this.oy));
        }
      }
    
      class Rectangle extends DrawableElement {
        constructor(x, y, w = 0, h = 0) {
          super(x, y);
          this.w = w;
          this.h = h;
        }
        drawSelf() {
          setStroke(`black`);
          setFill(`#3332`);
          rect(0, 0, this.w, this.h);
          setFill(`red`);
          point(0, 0);
        }
      }
    
      const W = 600, H = 400;
    
      // Let's group some rects:
      const r1 = new Rectangle(150, 50, W - 200, H - 200);
      const r2 = new Rectangle(200, 100, 100, 100);
      const r3 = new Rectangle(320, 190, 200, 40);
      const r4 = new Rectangle(210, 110, 70, 50);
    
      // we'll make r1 the "outer group":
      r1.addChild(r2);
      r1.addChild(r3);
    
      // and we'll make r2 a small "inner group":
      r2.addChild(r4);
    
      function setup() {
        setSize(W, H);
        setBorder(1, `black`);
        setGrid(20, `grey`);
        addSlider(`rotation`, { value: 0, min: 0, max: TAU, step: TAU / 100, transform: (r) => updateAngle(r) });
        addSlider(`scale_x`, { max: 2, step: 0.01, transform: (x) => updateScale(x, undefined) });
        addSlider(`scale_y`, { max: 2, step: 0.01, transform: (y) => updateScale(undefined, y) });
      }
    
      function updateAngle(r) {
        r1.setRotation(r);
      }
    
      function updateScale(x, y) {
        r1.setScale(x, y);
      }
    
      function draw() {
        clear(`white`);
        r1.draw();
      }
    }
    
    // load the code once the custom element loader is done:
    customElements.whenDefined(`graphics-element`).then(() => {
      document.getElementById(`graphics`).loadFromFunction(sourceCode);
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/graphics-element/5.0.0/graphics-element.js" type="module"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/graphics-element/5.0.0/graphics-element.min.css" />
    
    <graphics-element id="graphics" title="grouped transforms"></graphics-element>

    Note that this code uses a "full" coordinate transform so you'll also see things like the anchor points, which are normally circles, scale to ellipses instead. While for things like backgrounds, you want that, for things like points, you often don't, so you generally also have two separate functions like screenToWorld and worldToScreen that you can use to convert an untransformed (x,y) pair to a transformed pair (and vice versa) so that you can draw untransformed content at a transformed coordinate, e.g.:

    ...
      drawSelf() {
        ...
        // draw the upper-left corner as an untransformed circle,
        // centered on where (0,0) is right now:
        const { x, y } = worldToScreen(0,0);
        setFill(`red`);
        drawPointAtScreenCoordinate(x, y); // bypass the transform matrix
      }
    ...
    

    The implementation for these functions usually go hand-in-hand with the implementations for transforming the coordinate system, so if you need to roll that code yourself, you basically have a singleton CoordinateTransformer that encodes a 3x3 transformation matrix that can be updated through translate, rotate, scale, and skew functions (and usually some direct setMatrix too), with a screenToWorld function that just applies the current matrix to the passed coordinate values, as well as a worldToScreen function that applies the inverse of that matrix to the passed coordinate values.

    (and inverting a transformation matrix is relatively straight-forward - also note that if you're confused about why we need what looks like a 3D matrix to work with 2D coordinates, see this question)