1

I'm developing a tool for modifying different geometrical shapes from an assortment of templates. The shapes are basic ones that could be found in rooms. For example: L-shape, T-shape, Hexagon, Rectangle etc.

What I need to do is making the shape conform all necessary edges as to keep the shape's symmetry and bounding dimensions intact when the user modifies an edge.

A shape is simply implemented like this, with the first node starting in the upper left corner and going around the shape clockwise (I use TypeScript):

public class Shape {
    private nodes: Array<Node>;
    private scale: number; // Scale for calculating correct coordinate compared to given length
    ... // A whole lot of transformation methods

Which then is drawn as a graph, connecting each node to the next in the array. (See below)

If I, for example, would change the length of edge C to 3m from 3.5m, then I'd also want either edge E or G to change their length to keep the side to 12m and also push down E so that edge D is still fully horizontal. If I instead would change side D to 2m, then B would have to change its length to 10m and so on.

(I do have shapes which have slanted angles as well, like a rectangle with one of its corners cut off)

An example of the shapes I have, a T-shape.

The problem

I have the following code for modifying the specific edge:

    public updateEdgeLength(start: Point, length: number): void  {
        let startNode: Node;
        let endNode: Node;
        let nodesSize = this.nodes.length;

        // Find start node, and then select end node of selected edge.
        for (let i = 0; i < nodesSize; i++) {
            if (this.nodes[i].getX() === start.x && this.nodes[i].getY() === start.y) {
                startNode = this.nodes[i];
                endNode = this.nodes[(i + 1) % nodesSize];
                break;
            }
        }

        // Calculate linear transformation scalar and create a vector of the edge
        let scaledLength = (length * this.scale);
        let edge: Vector = Vector.create([endNode.getX() - startNode.getX(), endNode.getY() - startNode.getY()]);
        let scalar = scaledLength / startNode.getDistance(endNode);

        edge = edge.multiply(scalar);

        // Translate the new vector to its correct position 
        edge = edge.add([startNode.getX(), startNode.getY()]);
        // Calculate tranlation vector
        edge = edge.subtract([endNode.getX(), endNode.getY()]);

        endNode.translate({x: edge.e(1), y: edge.e(2)});

    }

Now I need a more general case for finding the corresponding edges that will also need to be modified. I have begun implementing shape-specific algorithms as I know which nodes correspond to the edges of the shape, but this won't be very extensible for the future.

For example, the shape above could be implemented somewhat like this:

public updateSideLength(edge: Position): void {
    // Get start node coordinates
    let startX = edge.start.getX();
    let startY = edge.start.getY();

    // Find index of start node;
    let index: num;
    for (let i = 0; i < this.nodes.length; i++) {
        let node: Node = this.nodes[i];
        if(node.getX() === startX && node.getY() === startY) {
            index = i;
            break;
        }
    }

    // Determine side
    let side: number;
    if (index === 0 || index === 2) {
        side = this.TOP;
    }
    else if (index === 1 || index === 3 || index === 5) {
        side = this.RIGHT;
    }
    else if (index === 4 || index === 6) {
        side = this.BOTTOM;
    }    
    else if (index === 7) {
        side = this.LEFT;
    }

    adaptSideToBoundingBox(index, side); // adapts all other edges of the side except for the one that has been modified
}

public adaptSideToBoundingBox(exceptionEdge: number, side: number) {
    // Modify all other edges
        // Example: C and G will be modified
        Move C.end Y-coord to D.start Y-coord;
        Move G.start Y-coord to D.end Y-coord;       
}

And so on.. But implementing this for each shape (5 atm.) and for future shapes would be very time consuming.

So what I'm wondering is if there is a more general approach to this problem?

Thanks!

  • *Any* geometrical shape? I'm pretty sure it would be very hard to define this kind of shape constraint if you had a generalized shape – meowgoesthedog Jul 21 '17 at 08:24
  • Yea, I'm revising the question right now, giving more of an example. And you're right @meowgoesthedog, I'll try to be more specific regarding the shapes. – Felix Nordén Jul 21 '17 at 08:27
  • I deleted my comments, +1 for improving the question, that's how it should look like :) – slhck Jul 21 '17 at 11:33

1 Answers1

1

Keep a list of point pairs and the key that constrains them and use that to overwrite coordinates on update.

This works with the example you gave:

var Point = (function () {
    function Point(x, y, connectedTo) {
        if (connectedTo === void 0) { connectedTo = []; }
        this.x = x;
        this.y = y;
        this.connectedTo = connectedTo;
    }
    return Point;
}());
var Polygon = (function () {
    function Polygon(points, constrains) {
        if (constrains === void 0) { constrains = []; }
        this.points = points;
        this.constrains = constrains;
    }
    return Polygon;
}());
var Sketch = (function () {
    function Sketch(polygons, canvas) {
        if (polygons === void 0) { polygons = []; }
        if (canvas === void 0) { canvas = document.body.appendChild(document.createElement("canvas")); }
        this.polygons = polygons;
        this.canvas = canvas;
        this.canvas.width = 1000;
        this.canvas.height = 1000;
        this.ctx = this.canvas.getContext("2d");
        this.ctx.fillStyle = "#0971CE";
        this.ctx.strokeStyle = "white";
        this.canvas.onmousedown = this.clickHandler.bind(this);
        this.canvas.onmouseup = this.clickHandler.bind(this);
        this.canvas.onmousemove = this.clickHandler.bind(this);
        requestAnimationFrame(this.draw.bind(this));
    }
    Sketch.prototype.clickHandler = function (evt) {
        if (evt.type == "mousedown") {
            if (this.selectedPoint != void 0) {
                this.selectedPoint = null;
            }
            else {
                var score = null;
                var best = null;
                for (var p = 0; p < this.polygons.length; p++) {
                    var polygon = this.polygons[p];
                    for (var pi = 0; pi < polygon.points.length; pi++) {
                        var point = polygon.points[pi];
                        var dist = Math.abs(point.x - evt.offsetX) + Math.abs(point.y - evt.offsetY);
                        if (score == null ? true : dist < score) {
                            score = dist;
                            best = point;
                        }
                    }
                }
                this.selectedPoint = best;
            }
        }
        if (evt.type == "mousemove" && this.selectedPoint != void 0) {
            this.selectedPoint.x = Math.round(evt.offsetX / 5) * 5;
            this.selectedPoint.y = Math.round(evt.offsetY / 5) * 5;
            for (var pi = 0; pi < this.polygons.length; pi++) {
                var polygon = this.polygons[pi];
                if (polygon.points.indexOf(this.selectedPoint) < 0) {
                    continue;
                }
                for (var pa = 0; pa < polygon.constrains.length; pa++) {
                    var constrain = polygon.constrains[pa];
                    if (constrain.a == this.selectedPoint || constrain.b == this.selectedPoint) {
                        constrain.a[constrain.key] = this.selectedPoint[constrain.key];
                        constrain.b[constrain.key] = this.selectedPoint[constrain.key];
                        if (constrain.offset != void 0) {
                            if (constrain.a == this.selectedPoint) {
                                constrain.b[constrain.key] += constrain.offset;
                            }
                            else {
                                constrain.a[constrain.key] -= constrain.offset;
                            }
                        }
                    }
                }
            }
        }
        requestAnimationFrame(this.draw.bind(this));
    };
    Sketch.prototype.draw = function () {
        var ctx = this.ctx;
        //clear
        ctx.fillStyle = "#0971CE";
        ctx.fillRect(0, 0, 1000, 1000);
        //grid
        ctx.strokeStyle = "rgba(255,255,255,0.25)";
        for (var x = 0; x <= this.canvas.width; x += 5) {
            ctx.beginPath();
            ctx.moveTo(x, -1);
            ctx.lineTo(x, this.canvas.height);
            ctx.stroke();
            ctx.closePath();
        }
        for (var y = 0; y <= this.canvas.height; y += 5) {
            ctx.beginPath();
            ctx.moveTo(-1, y);
            ctx.lineTo(this.canvas.width, y);
            ctx.stroke();
            ctx.closePath();
        }
        ctx.strokeStyle = "white";
        ctx.fillStyle = "white";
        //shapes
        for (var i = 0; i < this.polygons.length; i++) {
            var polygon = this.polygons[i];
            for (var pa = 0; pa < polygon.points.length; pa++) {
                var pointa = polygon.points[pa];
                if (pointa == this.selectedPoint) {
                    ctx.beginPath();
                    ctx.fillRect(pointa.x - 2, pointa.y - 2, 4, 4);
                    ctx.closePath();
                }
                ctx.beginPath();
                for (var pb = 0; pb < pointa.connectedTo.length; pb++) {
                    var pointb = pointa.connectedTo[pb];
                    if (polygon.points.indexOf(pointb) < pa) {
                        continue;
                    }
                    ctx.moveTo(pointa.x, pointa.y);
                    ctx.lineTo(pointb.x, pointb.y);
                }
                ctx.stroke();
                ctx.closePath();
            }
        }
    };
    return Sketch;
}());
//==Test==
//Build polygon 1 (House)
var poly1 = new Polygon([
    new Point(10, 10),
    new Point(80, 10),
    new Point(80, 45),
    new Point(130, 45),
    new Point(130, 95),
    new Point(80, 95),
    new Point(80, 135),
    new Point(10, 135),
]);
//Connect dots
for (var x = 0; x < poly1.points.length; x++) {
    var a = poly1.points[x];
    var b = poly1.points[(x + 1) % poly1.points.length];
    a.connectedTo.push(b);
    b.connectedTo.push(a);
}
//Setup constrains
for (var x = 0; x < poly1.points.length; x++) {
    var a = poly1.points[x];
    var b = poly1.points[(x + 1) % poly1.points.length];
    poly1.constrains.push({ a: a, b: b, key: x % 2 == 1 ? 'x' : 'y' });
}
poly1.constrains.push({ a: poly1.points[1], b: poly1.points[5], key: 'x' }, { a: poly1.points[2], b: poly1.points[5], key: 'x' }, { a: poly1.points[1], b: poly1.points[6], key: 'x' }, { a: poly1.points[2], b: poly1.points[6], key: 'x' });
//Build polygon 2 (Triangle)
var poly2 = new Polygon([
    new Point(250, 250),
    new Point(300, 300),
    new Point(200, 300),
]);
//Connect dots
for (var x = 0; x < poly2.points.length; x++) {
    var a = poly2.points[x];
    var b = poly2.points[(x + 1) % poly2.points.length];
    a.connectedTo.push(b);
    b.connectedTo.push(a);
}
//Setup constrains
poly2.constrains.push({ a: poly2.points[0], b: poly2.points[1], key: 'x', offset: 50 }, { a: poly2.points[0], b: poly2.points[1], key: 'y', offset: 50 });
//Generate sketch
var s = new Sketch([poly1, poly2]);
<!-- TYPESCRIPT -->
<!--
class Point {
 constructor(public x: number, public y: number, public connectedTo: Point[] = []) {

 }
}

interface IConstrain {
 a: Point,
 b: Point,
 key: string,
 offset?: number
}

class Polygon {
 constructor(public points: Point[], public constrains: IConstrain[] = []) {

 }
}

class Sketch {
 public ctx: CanvasRenderingContext2D;
 constructor(public polygons: Polygon[] = [], public canvas = document.body.appendChild(document.createElement("canvas"))) {
  this.canvas.width = 1000;
  this.canvas.height = 1000;

  this.ctx = this.canvas.getContext("2d");
  this.ctx.fillStyle = "#0971CE";
  this.ctx.strokeStyle = "white";

  this.canvas.onmousedown = this.clickHandler.bind(this)
  this.canvas.onmouseup = this.clickHandler.bind(this)
  this.canvas.onmousemove = this.clickHandler.bind(this)
  requestAnimationFrame(this.draw.bind(this))
 }
 public selectedPoint: Point
 public clickHandler(evt: MouseEvent) {
  if (evt.type == "mousedown") {
   if (this.selectedPoint != void 0) {
    this.selectedPoint = null;
   } else {
    let score = null;
    let best = null;
    for (let p = 0; p < this.polygons.length; p++) {
     let polygon = this.polygons[p];
     for (let pi = 0; pi < polygon.points.length; pi++) {
      let point = polygon.points[pi];
      let dist = Math.abs(point.x - evt.offsetX) + Math.abs(point.y - evt.offsetY)
      if (score == null ? true : dist < score) {
       score = dist;
       best = point;
      }
     }
    }
    this.selectedPoint = best;
   }
  }
  if (evt.type == "mousemove" && this.selectedPoint != void 0) {
   this.selectedPoint.x = Math.round(evt.offsetX / 5) * 5;
   this.selectedPoint.y = Math.round(evt.offsetY / 5) * 5;
   for (let pi = 0; pi < this.polygons.length; pi++) {
    let polygon = this.polygons[pi];
    if (polygon.points.indexOf(this.selectedPoint) < 0) {
     continue;
    }
    for (let pa = 0; pa < polygon.constrains.length; pa++) {
     let constrain = polygon.constrains[pa];
     if (constrain.a == this.selectedPoint || constrain.b == this.selectedPoint) {
      constrain.a[constrain.key] = this.selectedPoint[constrain.key]
      constrain.b[constrain.key] = this.selectedPoint[constrain.key]
      if (constrain.offset != void 0) {
       if (constrain.a == this.selectedPoint) {
        constrain.b[constrain.key] += constrain.offset
       } else {
        constrain.a[constrain.key] -= constrain.offset
       }
      }
     }
    }
   }
  }
  requestAnimationFrame(this.draw.bind(this))

 }
 public draw() {
  var ctx = this.ctx;
  //clear
  ctx.fillStyle = "#0971CE";
  ctx.fillRect(0, 0, 1000, 1000)
  //grid
  ctx.strokeStyle = "rgba(255,255,255,0.25)"
  for (let x = 0; x <= this.canvas.width; x += 5) {
   ctx.beginPath()
   ctx.moveTo(x, -1)
   ctx.lineTo(x, this.canvas.height)
   ctx.stroke();
   ctx.closePath()
  }
  for (let y = 0; y <= this.canvas.height; y += 5) {
   ctx.beginPath()
   ctx.moveTo(-1, y)
   ctx.lineTo(this.canvas.width, y)
   ctx.stroke();
   ctx.closePath()
  }
  ctx.strokeStyle = "white"
  ctx.fillStyle = "white";
  //shapes
  for (let i = 0; i < this.polygons.length; i++) {
   let polygon = this.polygons[i];
   for (let pa = 0; pa < polygon.points.length; pa++) {
    let pointa = polygon.points[pa];
    if (pointa == this.selectedPoint) {
     ctx.beginPath();
     ctx.fillRect(pointa.x - 2, pointa.y - 2, 4, 4)
     ctx.closePath();
    }
    ctx.beginPath();
    for (var pb = 0; pb < pointa.connectedTo.length; pb++) {
     var pointb = pointa.connectedTo[pb];
     if (polygon.points.indexOf(pointb) < pa) {
      continue;
     }
     ctx.moveTo(pointa.x, pointa.y)
     ctx.lineTo(pointb.x, pointb.y)
    }
    ctx.stroke();
    ctx.closePath();
   }
  }
 }
}

//==Test==
//Build polygon 1 (House)
var poly1 = new Polygon([
 new Point(10, 10),
 new Point(80, 10),
 new Point(80, 45),
 new Point(130, 45),
 new Point(130, 95),
 new Point(80, 95),
 new Point(80, 135),
 new Point(10, 135),
])
//Connect dots
for (let x = 0; x < poly1.points.length; x++) {
 let a = poly1.points[x];
 let b = poly1.points[(x + 1) % poly1.points.length]
 a.connectedTo.push(b)
 b.connectedTo.push(a)
}
//Setup constrains
for (let x = 0; x < poly1.points.length; x++) {
 let a = poly1.points[x];
 let b = poly1.points[(x + 1) % poly1.points.length]
 poly1.constrains.push({ a: a, b: b, key: x % 2 == 1 ? 'x' : 'y' })
}
poly1.constrains.push(
 { a: poly1.points[1], b: poly1.points[5], key: 'x' },
 { a: poly1.points[2], b: poly1.points[5], key: 'x' },
 { a: poly1.points[1], b: poly1.points[6], key: 'x' },
 { a: poly1.points[2], b: poly1.points[6], key: 'x' }
)
//Build polygon 2 (Triangle)
var poly2 = new Polygon([
 new Point(250, 250),
 new Point(300, 300),
 new Point(200, 300),
])
//Connect dots
for (let x = 0; x < poly2.points.length; x++) {
 let a = poly2.points[x];
 let b = poly2.points[(x + 1) % poly2.points.length]
 a.connectedTo.push(b)
 b.connectedTo.push(a)
}
//Setup constrains
poly2.constrains.push(
 { a: poly2.points[0], b: poly2.points[1], key: 'x', offset: 50 },
 { a: poly2.points[0], b: poly2.points[1], key: 'y', offset: 50 },
)
//Generate sketch
var s = new Sketch([poly1, poly2])

-->

UPDATE - Constrain offsets

Based on feedback in the comments i added a "offset" key in the constrains to handle uneven relationships.

The Triangles top-right-most edge (at least initially) is constrained with an offset.

Emil S. Jørgensen
  • 6,216
  • 1
  • 15
  • 28
  • Wow, thanks for the great answer! Didn't think about having the constrains as a two way implementation. I'll try this out, but if I have slanted edges on one side, it doesn't work. For example: `var p = new Polygon([ new Point(10, 30), new Point(60, 10), new Point(110, 10), new Point(140, 45), new Point(140, 100), new Point(10, 100), ]);` The slanted edge doesn't need to modify anything else, just adapt to the edges change. – Felix Nordén Jul 21 '17 at 09:45
  • @FelixNordén You could add an offset to the constrain? – Emil S. Jørgensen Jul 21 '17 at 11:42
  • Yea, I came to that conclusion too, thanks for the help! :) – Felix Nordén Jul 21 '17 at 11:59