@guinetik/penrose-js
Version:
A JavaScript library for generating beautiful Penrose tilings
447 lines (446 loc) • 15.2 kB
JavaScript
class Complex {
/**
* Create a complex number
* @param {number} real - Real part of the complex number
* @param {number} imag - Imaginary part of the complex number (default: 0)
*/
constructor(real, imag = 0) {
this.real = real;
this.imag = imag;
}
/**
* Create a complex number from polar coordinates
* @param {number} r - Radius/magnitude
* @param {number} theta - Angle in radians
* @returns {Complex} New complex number
*/
static fromPolar(r, theta) {
return new Complex(r * Math.cos(theta), r * Math.sin(theta));
}
/**
* Add two complex numbers
* @param {Complex} other - The complex number to add
* @returns {Complex} New complex number representing the sum
*/
add(other) {
return new Complex(this.real + other.real, this.imag + other.imag);
}
/**
* Subtract a complex number from this one
* @param {Complex} other - The complex number to subtract
* @returns {Complex} New complex number representing the difference
*/
subtract(other) {
return new Complex(this.real - other.real, this.imag - other.imag);
}
/**
* Multiply by another complex number
* @param {Complex} other - The complex number to multiply by
* @returns {Complex} New complex number representing the product
*/
multiply(other) {
return new Complex(
this.real * other.real - this.imag * other.imag,
this.real * other.imag + this.imag * other.real
);
}
/**
* Divide by a scalar
* @param {number} scalar - The scalar to divide by
* @returns {Complex} New complex number
*/
divide(scalar) {
return new Complex(this.real / scalar, this.imag / scalar);
}
/**
* Scale by a scalar (multiply)
* @param {number} scalar - The scalar to multiply by
* @returns {Complex} New complex number
*/
scale(scalar) {
return new Complex(this.real * scalar, this.imag * scalar);
}
/**
* Calculate the absolute value (magnitude) of this complex number
* @returns {number} The magnitude
*/
abs() {
return Math.sqrt(this.real * this.real + this.imag * this.imag);
}
}
const PHI = (Math.sqrt(5) + 1) / 2;
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? [
parseInt(result[1], 16) / 255,
parseInt(result[2], 16) / 255,
parseInt(result[3], 16) / 255,
1
// Add alpha
] : [0, 0, 0, 1];
}
function getRandomColor() {
const letters = "0123456789ABCDEF";
let color = "#";
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
function generatePenroseTriangles(divisions) {
const base = 5;
let triangles = [];
for (let i = 0; i < base * 2; i++) {
const v2 = Complex.fromPolar(1, (2 * i - 1) * Math.PI / (base * 2));
const v3 = Complex.fromPolar(1, (2 * i + 1) * Math.PI / (base * 2));
if (i % 2 === 0) {
triangles.push(["thin", new Complex(0), v3, v2]);
} else {
triangles.push(["thin", new Complex(0), v2, v3]);
}
}
for (let i = 0; i < divisions; i++) {
const newTriangles = [];
for (const [shape, v1, v2, v3] of triangles) {
if (shape === "thin") {
const p1 = v1.add(v2.subtract(v1).scale(1 / PHI));
newTriangles.push(["thin", v3, p1, v2]);
newTriangles.push(["thicc", p1, v3, v1]);
} else {
const p2 = v2.add(v1.subtract(v2).scale(1 / PHI));
const p3 = v2.add(v3.subtract(v2).scale(1 / PHI));
newTriangles.push(["thicc", p3, v3, v1]);
newTriangles.push(["thicc", p2, p3, v2]);
newTriangles.push(["thin", p3, p2, v1]);
}
}
triangles = newTriangles;
}
return triangles;
}
function calculateLineWidth(divisions) {
return divisions > 3 ? Math.pow(divisions, -3) : Math.pow(divisions, -5);
}
const DEFAULT_OPTIONS = {
divisions: 5,
zoomType: "in",
width: 800,
height: 800,
color1: [1, 0, 0, 1],
// Red for thin rhombi
color2: [0, 0, 1, 1],
// Blue for thick rhombi
color3: [0, 0, 0, 1],
// Black for outlines
backgroundColor: [1, 1, 1, 1]
// White background
};
class Penrose {
/**
* Create a new Penrose tiling generator
* @param {Object} [defaultOptions] - Custom default options to override the built-in defaults
*/
constructor(defaultOptions = {}) {
this.options = { ...DEFAULT_OPTIONS, ...defaultOptions };
}
/**
* Generate a Penrose tiling with the given options
* @param {Object} options - Options for generating the tiling
* @param {number} [options.divisions=5] - Number of subdivision iterations
* @param {string} [options.zoomType='in'] - Zoom type ('in' or 'out')
* @param {number} [options.width=800] - Width of the output
* @param {number} [options.height=800] - Height of the output
* @param {number[]} [options.color1=[1,0,0,1]] - Color for thin rhombi (RGBA, normalized 0-1)
* @param {number[]} [options.color2=[0,0,1,1]] - Color for thick rhombi (RGBA, normalized 0-1)
* @param {number[]} [options.color3=[0,0,0,1]] - Color for outlines (RGBA, normalized 0-1)
* @param {number[]} [options.backgroundColor=[1,1,1,1]] - Background color (RGBA, normalized 0-1)
* @returns {*} Implementation-specific result
*/
generatePenroseTiling(options) {
throw new Error("Not implemented - use a concrete subclass");
}
/**
* Process and merge options with defaults
* @param {Object} options - User-provided options
* @returns {Object} Processed options with defaults applied
* @protected
*/
_processOptions(options) {
const processedOptions = { ...this.options, ...options };
if (typeof processedOptions.color1 === "string") {
processedOptions.color1 = hexToRgb(processedOptions.color1);
}
if (typeof processedOptions.color2 === "string") {
processedOptions.color2 = hexToRgb(processedOptions.color2);
}
if (typeof processedOptions.color3 === "string") {
processedOptions.color3 = hexToRgb(processedOptions.color3);
}
if (typeof processedOptions.backgroundColor === "string") {
processedOptions.backgroundColor = hexToRgb(processedOptions.backgroundColor);
}
return processedOptions;
}
}
class PenroseBitmap extends Penrose {
/**
* Generate a Penrose tiling with bitmap/pixel approach
* @param {Object} options - Options for generating the tiling
* @returns {Uint8ClampedArray} The pixel data array (RGBA format)
*/
generatePenroseTiling(options) {
const opts = this._processOptions(options);
const {
divisions,
zoomType,
width,
height,
color1,
color2,
color3,
backgroundColor
} = opts;
const pixels = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = backgroundColor[0] * 255;
pixels[i + 1] = backgroundColor[1] * 255;
pixels[i + 2] = backgroundColor[2] * 255;
pixels[i + 3] = (backgroundColor[3] || 1) * 255;
}
const scale = zoomType === "in" ? 1 : 2;
const maxDim = Math.max(width, height);
const scaleX = maxDim / scale;
const scaleY = maxDim / scale;
const translateX = 0.5 * scale;
const translateY = 0.5 * scale;
const triangles = generatePenroseTriangles(divisions);
function worldToScreen(point) {
const x = Math.floor((point.real * scaleX + translateX * scaleX) * width / maxDim);
const y = Math.floor((point.imag * scaleY + translateY * scaleY) * height / maxDim);
return { x, y };
}
for (const [shape, v1, v2, v3] of triangles) {
const p1 = worldToScreen(v1);
const p2 = worldToScreen(v2);
const p3 = worldToScreen(v3);
const color = shape === "thin" ? color1 : color2;
this._fillTriangle(pixels, p1, p2, p3, color, width, height);
}
if (color3 && color3[3] > 0) {
for (const [shape, v1, v2, v3] of triangles) {
const p1 = worldToScreen(v1);
const p2 = worldToScreen(v2);
const p3 = worldToScreen(v3);
this._drawLine(pixels, p1, p2, color3, width, height);
this._drawLine(pixels, p2, p3, color3, width, height);
this._drawLine(pixels, p3, p1, color3, width, height);
}
}
return pixels;
}
/**
* Helper function to fill a triangle
* @private
*/
_fillTriangle(pixels, p1, p2, p3, color, width, height) {
if (p1.y > p2.y)
[p1, p2] = [p2, p1];
if (p1.y > p3.y)
[p1, p3] = [p3, p1];
if (p2.y > p3.y)
[p2, p3] = [p3, p2];
const r = Math.round(color[0] * 255);
const g = Math.round(color[1] * 255);
const b = Math.round(color[2] * 255);
const a = Math.round((color[3] || 1) * 255);
if (p2.y === p3.y) {
this._fillFlatBottomTriangle(pixels, p1, p2, p3, r, g, b, a, width, height);
} else if (p1.y === p2.y) {
this._fillFlatTopTriangle(pixels, p1, p2, p3, r, g, b, a, width, height);
} else {
const p4 = {
x: Math.floor(p1.x + (p2.y - p1.y) / (p3.y - p1.y) * (p3.x - p1.x)),
y: p2.y
};
this._fillFlatBottomTriangle(pixels, p1, p2, p4, r, g, b, a, width, height);
this._fillFlatTopTriangle(pixels, p2, p4, p3, r, g, b, a, width, height);
}
}
/**
* Helper to fill a flat-bottom triangle
* @private
*/
_fillFlatBottomTriangle(pixels, p1, p2, p3, r, g, b, a, width, height) {
const invSlope1 = (p2.x - p1.x) / (p2.y - p1.y || 1);
const invSlope2 = (p3.x - p1.x) / (p3.y - p1.y || 1);
let curx1 = p1.x;
let curx2 = p1.x;
for (let scanlineY = p1.y; scanlineY <= p2.y; scanlineY++) {
if (scanlineY >= 0 && scanlineY < height) {
const startX = Math.max(0, Math.min(Math.floor(curx1), width - 1));
const endX = Math.max(0, Math.min(Math.floor(curx2), width - 1));
for (let x = Math.min(startX, endX); x <= Math.max(startX, endX); x++) {
const index = (scanlineY * width + x) * 4;
if (index >= 0 && index < pixels.length - 3) {
pixels[index] = r;
pixels[index + 1] = g;
pixels[index + 2] = b;
pixels[index + 3] = a;
}
}
}
curx1 += invSlope1;
curx2 += invSlope2;
}
}
/**
* Helper to fill a flat-top triangle
* @private
*/
_fillFlatTopTriangle(pixels, p1, p2, p3, r, g, b, a, width, height) {
const invSlope1 = (p3.x - p1.x) / (p3.y - p1.y || 1);
const invSlope2 = (p3.x - p2.x) / (p3.y - p2.y || 1);
let curx1 = p3.x;
let curx2 = p3.x;
for (let scanlineY = p3.y; scanlineY > p1.y; scanlineY--) {
if (scanlineY >= 0 && scanlineY < height) {
curx1 -= invSlope1;
curx2 -= invSlope2;
const startX = Math.max(0, Math.min(Math.floor(curx1), width - 1));
const endX = Math.max(0, Math.min(Math.floor(curx2), width - 1));
for (let x = Math.min(startX, endX); x <= Math.max(startX, endX); x++) {
const index = (scanlineY * width + x) * 4;
if (index >= 0 && index < pixels.length - 3) {
pixels[index] = r;
pixels[index + 1] = g;
pixels[index + 2] = b;
pixels[index + 3] = a;
}
}
}
}
}
/**
* Helper to draw a line using Bresenham's algorithm
* @private
*/
_drawLine(pixels, p1, p2, color, width, height) {
const r = Math.round(color[0] * 255);
const g = Math.round(color[1] * 255);
const b = Math.round(color[2] * 255);
const a = Math.round((color[3] || 1) * 255);
let x0 = p1.x;
let y0 = p1.y;
let x1 = p2.x;
let y1 = p2.y;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
while (true) {
if (x0 >= 0 && x0 < width && y0 >= 0 && y0 < height) {
const index = (y0 * width + x0) * 4;
if (index >= 0 && index < pixels.length - 3) {
const alpha = a / 255;
pixels[index] = Math.round(pixels[index] * (1 - alpha) + r * alpha);
pixels[index + 1] = Math.round(pixels[index + 1] * (1 - alpha) + g * alpha);
pixels[index + 2] = Math.round(pixels[index + 2] * (1 - alpha) + b * alpha);
pixels[index + 3] = 255;
}
}
if (x0 === x1 && y0 === y1)
break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
}
class PenroseCanvas extends Penrose {
/**
* Generate a Penrose tiling on a canvas context
* @param {Object} options - Options for generating the tiling
* @param {CanvasRenderingContext2D} options.ctx - The canvas context to draw on
* @returns {CanvasRenderingContext2D} The canvas context
*/
generatePenroseTiling(options) {
if (!options.ctx) {
throw new Error("Canvas context (ctx) is required for PenroseCanvas");
}
const opts = this._processOptions(options);
const {
ctx,
divisions,
zoomType,
width,
height,
color1,
color2,
color3,
backgroundColor
} = opts;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, width, height);
if (backgroundColor) {
ctx.fillStyle = `rgba(${Math.round(backgroundColor[0] * 255)}, ${Math.round(backgroundColor[1] * 255)}, ${Math.round(backgroundColor[2] * 255)}, ${backgroundColor[3] || 1})`;
ctx.fillRect(0, 0, width, height);
}
const scale = zoomType === "in" ? 1 : 2;
const maxDim = Math.max(width, height);
ctx.scale(maxDim / scale, maxDim / scale);
ctx.translate(0.5 * scale, 0.5 * scale);
const triangles = generatePenroseTriangles(divisions);
ctx.beginPath();
for (const [shape, v1, v2, v3] of triangles) {
if (shape === "thin") {
ctx.moveTo(v1.real, v1.imag);
ctx.lineTo(v2.real, v2.imag);
ctx.lineTo(v3.real, v3.imag);
ctx.closePath();
}
}
ctx.fillStyle = `rgba(${Math.round(color1[0] * 255)}, ${Math.round(color1[1] * 255)}, ${Math.round(color1[2] * 255)}, ${color1[3] || 1})`;
ctx.fill();
ctx.beginPath();
for (const [shape, v1, v2, v3] of triangles) {
if (shape === "thicc") {
ctx.moveTo(v1.real, v1.imag);
ctx.lineTo(v2.real, v2.imag);
ctx.lineTo(v3.real, v3.imag);
ctx.closePath();
}
}
ctx.fillStyle = `rgba(${Math.round(color2[0] * 255)}, ${Math.round(color2[1] * 255)}, ${Math.round(color2[2] * 255)}, ${color2[3] || 1})`;
ctx.fill();
ctx.beginPath();
for (const [shape, v1, v2, v3] of triangles) {
ctx.moveTo(v2.real, v2.imag);
ctx.lineTo(v1.real, v1.imag);
ctx.lineTo(v3.real, v3.imag);
}
ctx.strokeStyle = `rgba(${Math.round(color3[0] * 255)}, ${Math.round(color3[1] * 255)}, ${Math.round(color3[2] * 255)}, ${color3[3] || 1})`;
const lineWidth = calculateLineWidth(divisions);
ctx.lineWidth = lineWidth;
ctx.lineJoin = "round";
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
return ctx;
}
}
export {
Complex,
DEFAULT_OPTIONS,
PHI,
Penrose,
PenroseBitmap,
PenroseCanvas,
getRandomColor,
hexToRgb
};