Пропорциональное масштабирование объектов внутри другого объекта при вращении

Я пишу программу для маркировки и столкнулся с очень сложной математической проблемой.

У меня есть объекты, которые я могу масштабировать вверх, вниз, влево, вправо, вверх-вправо, вниз-влево и т. д. Я написал всю логику для обработки всех этих случаев, даже для сгруппированных объектов. Проблема возникает, когда у нас есть вращение. Теперь вращение для отдельных объектов работает отлично, проблема заключается в логике при попытке пропорционально масштабировать все объекты внутри объекта, который масштабируется во время вращения.

Я написал простой проект, который мы можем использовать для демонстрации этой проблемы.

Позвольте мне показать вам пример.

Правильное масштабирование отдельного объекта, даже при вращении:

Пропорциональное масштабирование объектов внутри другого объекта при вращении

Правильное масштабирование родительских и дочерних объектов без вращения:

Пропорциональное масштабирование объектов внутри другого объекта при вращении

Неправильное масштабирование родительских и дочерних объектов с вращением: Пропорциональное масштабирование объектов внутри другого объекта при вращении

Я ломал голову над этой проблемой в течение последних нескольких дней, и сейчас я продвинулся гораздо дальше, как вы можете видеть в третьей анимации, что масштабирование, по крайней мере, правильное, когда я масштабирую его идеально по диагонали, но это заняло больше времени, чем следовало бы, и прямо сейчас я застрял, потому что понятия не имею, как представить расчет в своей голове, чтобы это правильно вело себя при масштабировании только в одном направлении.

Хотя это простой проект, из-за его характера он немного самостоятелен, поэтому я добавлю ссылку на исходный код, если кто-то захочет запустить его самостоятельно, но вот некоторый основной код:

Класс объекта:

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;
}

Утилита вращения точки:

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);
}

Код изменения размера события перемещения мыши:

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();
}

Вот ссылка для скачивания тестового проекта: https://uploadnow.io/f/GF2PQb7

Чтобы добавить объект, щелкните правой кнопкой мыши по форме.

Чтобы добавить дочерний объект, щелкните правой кнопкой мыши внутри уже существующего объекта.

Чтобы повернуть объект, вам нужно щелкнуть по нему и использовать колесо прокрутки.

Спасибо за ответы заранее!

В тот момент, когда вы группируете объекты, их координаты перестают быть мировыми координатами и вместо этого становятся локальными координатами их родителя (на самом деле «мир» — это просто еще один родитель, просто он оказывается самым высоким). Таким образом, когда вы преобразуете родителя, вам будет гораздо проще обновлять дочерние элементы. Таким образом, проблема «вычисления масштаба с вращением» становится «сначала повернуть рабочую систему координат, чтобы она соответствовала родительской, а затем масштабировать всех дочерних элементов в виде тупой ограничивающей рамки»

Mike 'Pomax' Kamermans 11.05.2024 19:09

@Mike'Pomax'Kamermans, ну ладно, но как мне это сделать?

Velox 11.05.2024 21:17

Присвоив прямоугольнику список childRects (или любое другое имя, подходящее для вашего кода), а затем рекурсивно рисуя прямоугольники, где вы рисуете группу только с помощью вызова external.draw(), преобразуйте систему координат, применяя переводы, поворот и масштабировать, затем нарисовать себя, затем вызвать .draw() для каждого дочернего элемента, а затем отменить преобразование системы координат. Из-за того, как работает рекурсия, это «затруднит» преобразование координат. Как вы это сделаете, используя какой конкретный API, конечно, во многом зависит от того, какую конкретную среду рисования вы используете.

Mike 'Pomax' Kamermans 12.05.2024 19:17

(Я не знаю C#, поэтому я могу написать вам ответ, но он будет на другом языке, чтобы продемонстрировать концепцию, а не ответ на C#, дающий вам точный код для копирования и вставки - идея состоит в том, что вы отслеживаете «какое бы ни было текущее преобразование», либо преобразуя систему координат, если у вас есть API, который позволяет вам это делать, либо самостоятельно отслеживая матрицу преобразования и обязательно преобразуя «реальные координаты» в их преобразованные эквиваленты и рисуя с их помощью вместо)

Mike 'Pomax' Kamermans 12.05.2024 19:38

@Mike'Pomax'Kamermans Если бы вы могли это сделать, я был бы очень признателен! Ничего страшного, если это будет на разных языках, главное, чтобы я смог понять эту концепцию.

Velox 13.05.2024 02:00

Сделанный. Вам нужно запустить этот пример в «полностраничном» режиме, поскольку встроенные размеры исполняемого фрагмента слишком малы, чтобы правильно отображать группу вложенных прямоугольников, если мы также хотим иметь возможность их масштабировать.

Mike 'Pomax' Kamermans 13.05.2024 20:41
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
6
6
181
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Вместо того, чтобы пытаться выяснить вложенные преобразования для каждого элемента отдельно, гораздо проще работать с группами и «преобразовать вашу систему координат» (или использовать одноэлементный преобразователь, который поддерживает матрицу, которую вы используете для преобразования ваших «реальных координат» в « нарисовать координаты»). Если вы моделируете группы элементов, используя отношение дерева, то каждый узел может обновить текущее преобразование координат на основе параметров локального преобразования, нарисовать себя, а затем приказать всем своим дочерним узлам сделать то же самое (которые будут применять свои собственные преобразования поверх текущий), а затем в качестве последнего шага отмените их локальное преобразование, чтобы система координат вернулась к тому состоянию, которое было до того, как мы начали рисовать.

В JS (с использованием нотации классов, поэтому реализация на C# должна быть почти тривиальной) с кодом, который предполагает, что у вас есть что-то, что может отслеживать преобразования с помощью отдельных функций translate, rotate и scale:

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));
  }
}

А затем, поскольку мы хотим рисовать прямоугольники, мы создаем расширение этого класса:

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);
  }
}

Хитрость теперь заключается в том, чтобы «группировать» (выполнено в приведенном выше пути кода addChild) и «разгруппировать» (не реализовано в этом примере) объекты, чтобы преобразования применялись «к группе», а не к отдельным рисуемым элементам.

(именно поэтому такие приложения, как Illustrator, Inkscape, Blender и т. д., поддерживают группировку и разгруппировку).

Объединим все это в работоспособную демонстрацию:

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>

Обратите внимание, что этот код использует «полное» преобразование координат, поэтому вы также увидите такие вещи, как опорные точки, которые обычно представляют собой круги, вместо этого масштабируются до эллипсов. В то время как для таких вещей, как фон, вы этого хотите, для таких вещей, как точки, вы часто этого не делаете, поэтому обычно у вас также есть две отдельные функции, такие как screenToWorld и worldToScreen, которые вы можете использовать для преобразования непреобразованной пары (x,y) в преобразованную пары (и наоборот), чтобы вы могли рисовать непреобразованный контент по преобразованной координате, например:

...
  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
  }
...

Реализация этих функций обычно идет рука об руку с реализациями преобразования системы координат, поэтому, если вам нужно выполнить этот код самостоятельно, у вас в основном есть синглтон CoordinateTransformer, который кодирует матрицу преобразования 3x3, которую можно обновить с помощью translate, Функции rotate, scale и skew (и обычно некоторые прямые setMatrix тоже), с функцией screenToWorld, которая просто применяет текущую матрицу к переданным значениям координат, а также функцией worldToScreen, которая применяет обратную матрицу к переданным значениям координат. .

(и инвертировать матрицу преобразования относительно просто - также обратите внимание: если вы не понимаете, почему нам нужно то, что выглядит как 3D-матрица, для работы с 2D-координатами, см. этот вопрос)

Просто чтобы убедиться, что я правильно это понимаю. Дочерние объекты в этом примере все еще сохраняют свои реальные позиции, верно? Если я откажусь от них и изменю происхождение, они останутся на том же месте? Или есть еще какие-то расчеты? Я спрашиваю, потому что это заставляет меня перепроектировать весь код позиционирования, если я хочу пройти путь «Поворот преобразования». По крайней мере, я думаю, что я прав?

Velox 14.05.2024 13:19

Правильно, потому что вам нужен какой-то способ восстановить их исходное положение, когда вы их разгруппируете. Таким образом, каждый элемент имеет свою реальную позицию, а также смещение, равное нулю, если «просто элемент», или ненулевое значение, когда элемент является дочерним элементом другого элемента. Если вы не хотите перепроектировать все, третий вариант — заставить каждый элемент кодировать свою собственную матрицу преобразования «полной цепочки», а затем создать код, который будет обновлять каждый из них индивидуально по мере группировки и разгруппировки. Но это будет (почти) столько же работы.

Mike 'Pomax' Kamermans 14.05.2024 17:41

Другие вопросы по теме