galdecoa.com

Over-engineering rectangles

📅

I have been kicking around an idea for a while that would require some simple collision detection logic in JavaScript. How simple? Well, the only requirement would be that I could be able to tell when two rectangles are intersecting.

Rectangle collision detection, the simple way

Any rectangle can be described by the coordinates of its opposing corners. For example:

const rectangle1 = { minX: 10, minY: 10, maxX: 30, maxY: 30 };
const rectangle2 = { minX: 20, minY: 20, maxX: 40, maxY: 40 };

Finding out if two rectangles overlap is straightforward:

/**
 * Determine if two rectangles, defined by the coordinates of their opposing 
 * corners, overlap.
 * @param  {Object} rectangle1
 * @param  {Object} rectangle2
 * @return {Boolean}
 */
function areColliding( rectangle1, rectangle2 ) {
	return (
		   ( rectangle1.minX <= rectangle2.maxX )
		&& ( rectangle1.maxX >= rectangle2.minX )
		&& ( rectangle1.minY <= rectangle2.maxY )
		&& ( rectangle1.maxY >= rectangle2.minY )
	);
}

Great! Mission accomplished, time to wrap up.

Why would I want anything else?

There are quite a few plausible and somewhat reasonable answers to the previous question. There are more things to do with rectangles, more often than not. Also, maintaining (or even browsing) an ever-growing collection of snippets to copy-paste when required may not be the best way to manage a codebase.

The answer, in my case, was “because”. The use I intend to make of whatever comes out of this will not justify its complexity. I would not be surprised if it less performant than copying and pasting the previous logic where required. Nevertheless, structuring the logic in a conventional package is useful for me, even if it is just to provide a lexical context to the code that I may end up copying and pasting.

Enter the Box class.

What’s in the Box

Box is a shorter name than “Rectangle” for a class that represents just that, a rectangle as per the previous definition. Basically:

/**
 * A representation of a rectangle.
 * @class
 */
class Box {

	/** @type {Number} */
	minX = 0;
	/** @type {Number} */
	maxX = 0;
	/** @type {Number} */
	minY = 0;
	/** @type {Number} */
	maxY = 0;

	/**
	 * Determine if two `Box` instances are colliding.
	 * @param  {Box} box1
	 * @param  {Box} box2
	 * @return {Boolean}
	 */
	static areColliding( box1, box2 ) {
		return (
			   ( box1.minX <= box2.maxX )
			&& ( box1.maxX >= box2.minX )
			&& ( box1.minY <= box2.maxY )
			&& ( box1.maxY >= box2.minY )
		);
	}

	/**
	 * Determine if the `Box` instance is colliding with `anotherBox`.
	 * @param  {Box} anotherBox
	 * @return {Boolean}
	 */
	isColliding( anotherBox ) { return Box.areColliding( this, anotherBox ); }

	/**
	 * Create a `Box` rectangle, defined by the coordinates of its opposing corners.
	 * @param {Number} minX
	 * @param {Number} maxX
	 * @param {Number} minY
	 * @param {Number} maxY
	 * @example <caption>Create a 10×10 `Box` at (25,30)</caption>
	 * const box = new Box( 25, 30, 35, 40 );
	 */
	constructor( minX, minY, maxX, maxY ) {
		this.minX = minX;
		this.maxX = maxX;
		this.minY = minY;
		this.maxY = maxY;
	}

}

There you go, all nicely OOPed, with the collision detection logic in static and dynamic form.

Great! Mission accomplished, time to wrap up.

Where am I going to use this?

Nowhere, really, in this form.

The idea I have been noodling with requires the rectangles to be painted in a CanvasRenderingContext2D. In this context, rectangles are defined by its top-left corner coordinates, x and y, and its size, width and height.

Reworking the Box class with this in mind is trivial, substituting minX with x, minY with y, maxX with x + width, and maxY with y + height.

Great! Mission accomplished, time to wrap up.

Complicating things, the OOP way

What if I decided arbitrarily that I wanted to keep addition and substraction out of the collision detection logic? Well, for starters, I would have to keep the min_ and max_ variables. I would also have to implement the logic to maintain the coherence of the different properties.

And that is precisely what I am going to do.

Inside the class Box {} definition, I establish a set of private variables.

	/** @type {Number} */
	#minX = 0;
	/** @type {Number} */
	#maxX = 0;
	/** @type {Number} */
	#minY = 0;
	/** @type {Number} */
	#maxY = 0;
	/** @type {Number} */
	#width = 0;
	/** @type {Number} */
	#height = 0;

Then, I define the getters and setters for the class’ original properties:

	/** @type {Number} */
	get minX() { return this.#minX; }
	set minX( value ) {
		if ( isNaN( value ) ) { return; }
		this.#minX = value;
		this.#maxX = value + this.#width;
	}

	/** @type {Number} */
	get maxX() { return this.#maxX; }
	set maxX( value ) {
		if ( isNaN( value ) ) { return; }
		this.#maxX = value;
		this.#minX = value - this.#width;
	}

	/** @type {Number} */
	get minY() { return this.#minY; }
	set minY( value ) {
		if ( isNaN( value ) ) { return; }
		this.#minY = value;
		this.#maxY = value + this.#height;
	}

	/** @type {Number} */
	get maxY() { return this.#maxY; }
	set maxY( value ) {
		if ( isNaN( value ) ) { return; }
		this.#maxY = value;
		this.#minY = value - this.#height;
	}

After that, I add do the same thing for the properties with the CanvasRenderingContext2D rectangle nomenclature:

	/** @type {Number} */
	get x() { return this.#minX; }
	set x( value ) { this.minX = value; }

	/** @type {Number} */
	get y() { return this.#minY; }
	set y( value ) { this.minY = value; }

	/** @type {Number} */
	get width() { return this.#width; }
	set width( value ) {
		if ( isNaN( value ) || ( value < 0 ) ) { return; }
		this.#width = value;
		this.#maxX = this.#minX + value; // This is a choice, not self-evident.
	}

	/** @type {Number} */
	get height() { return this.#height; }
	set height( value ) {
		if ( isNaN( value ) || ( value < 0 )  ) { return; }
		this.#height = value;
		this.#maxY = this.#minY + value; // This is a choice, not self-evident.
	}

After copying the areColliding and isColliding methods, the only thing left to do is update the constructor:

	/**
	 * Create a `Box` rectangle, defined by the coordinates of a corner and its size.
	 * @param {Number} [x=0]	  
	 * @param {Number} [y=0]
	 * @param {Number} [width=0]  Cannot be a negative number.
	 * @param {Number} [height=0] Cannot be a negative number.
	 */
	constructor( x = 0, y = 0, width = 0, height = 0 ) {
		this.#minX = x;
		this.#minY = y;
		this.width = width;   // This will also set `this.#maxX` value
		this.height = height; // This will also set `this.#maxY` value
	}

Great! Mission accomplished, time to wrap up.

More is less

That is quite a bit more code for, basically, the same functionality. Granted, now I prevent some erroneous handling of the object’s properties (e.g., setting a minX that is more than maxX) and, for a CanvasRenderingContext2D object named context, instead of

context.fillRect( box.minX, box.minY, box.maxX - box.minX, box.maxY - box.minY );

, I can declare

context.fillRect( box.x, box.y, box.width, box.height );

, but there are no substantial gains to be found here. Furthermore, as I stated before, I would expect the new Box class to be less performant than the original, although I would have to test it.

In for a penny, in for a pound, so, there are quite a few ways to overload this class. For example, another property could be

	/** 
	 * @readonly
	 * @type {Number[]} An array with the values for `x`, `y`, `width` and `height`.
	 */
	get rect() { return [ this.#minX, this.#minY, this.#width, this.#height ]; }

so, again, for a CanvasRenderingContext2D object named context:

context.fillRect( ...box.rect );

It does look prettier or, at least, more compact, although I would (and did) argue against some uses of the spread operator.

Great! Mission accomplished, time to wrap up.

There and back again

Having looked ahead, I can anticipate having to convert to and from the format of the data I will be handling. Why not add that, too?

	/** 
	 * @readonly
	 * @type {Object} An object with `x`, `y`, `width` and `height` properties.
	 */
	get rectangle() { return { 
		x: this.#minX, y: this.#minY, width: this.#width, height: this.#height 
	}; }
	
	/**
	 * Instances a `Box` from a `rect` array.
	 * @param  {Number[]} rect 
	 *		 An array with the values for `x`, `y`, `width` and `height`.
	 * @return {(Box|Number[])?} 
	 */
	 static createFromArray( rect ) {
		if ( !(
			   ( rect instanceof Array )
			&& ( rect.length === 4 )
			&& !isNaN( rect[ 0 ] ) && !isNaN( rect[ 1 ] )
			&& !isNaN( rect[ 2 ] ) && !isNaN( rect[ 3 ] )
			&& ( rect[ 2 ] >= 0 ) 
			&& ( rect[ 3 ] >= 0 )
		) ) { 
			return rect; 
		}
		return ( new Box ( rect[ 0 ], rect[ 1 ], rect[ 2 ], rect[ 3 ] ) );
	}

	/**
	 * Instances a `Box` from a `rectangle` object.
	 * @param  {Object} rectangle 
	 *			   An object with `x`, `y`, `width` and `height` properties.
	 * @return {(Box|Object)?} 
	 */
	static createFromObject( rectangle ) {
		if ( !(
				   ( rectangle instanceof Object )
				&& rectangle.hasOwnProperty( "x" ) && !isNaN( rectangle.x )
				&& rectangle.hasOwnProperty( "y" ) && !isNaN( rectangle.y )
				&& rectangle.hasOwnProperty( "width" ) && !isNaN( rectangle.width )
				&& rectangle.hasOwnProperty( "height" ) && !isNaN( rectangle.height )
				&& ( rectangle.width >= 0 ) 
				&& ( rectangle.height >= 0 ) 
		) ) { 
			return rectangle; 
		}
		return ( 
			new Box ( rectangle.x, rectangle.y, rectangle.width, rectangle.height ) 
		);
	}

	/**
	 * Instances a `Box` from a `rect` array or a `rectangle` object.
	 * @param  {Number[]|Object} source 
	 * @return {(Box|Object)?} 
	 */
	 static createFrom( source ) {
		if ( !( source instanceof Object ) ) { return source; }
		if ( source instanceof Array ) { return Box.createFromArray( source ); }
		return Box.createFromObject( source );
	}

Great! Mission accomplished, time to wrap up.

To be continued

By this stage, I should have started writting tests, establishing metrics and measuring. Ideally, I would have started when I defined the areColliding method.

I am not going to do it. (Shame! Shame! Shame!)

I will be able to test its functionality and performance if (hopefully, when) I start developing my idea. In the interim, maybe I can write a bit more.