UNPKG

@guinetik/penrose-js

Version:

A JavaScript library for generating beautiful Penrose tilings

447 lines (446 loc) 15.2 kB
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 };