feat: pattern editor
This commit is contained in:
509
public/scripts/canvas-renderer.js
Normal file
509
public/scripts/canvas-renderer.js
Normal file
@@ -0,0 +1,509 @@
|
||||
// Client-side Canvas Renderer for Preset Editor
|
||||
// Simplified implementation of building blocks for browser canvas rendering
|
||||
|
||||
class CanvasRenderer {
|
||||
constructor(canvas, width = 16, height = 16) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d', { alpha: false });
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
// Disable image smoothing for pixel-perfect rendering
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Set canvas resolution
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
|
||||
this.frame = [];
|
||||
this.time = 0;
|
||||
this.animationStates = new Map();
|
||||
this.lastFrameTime = Date.now();
|
||||
}
|
||||
|
||||
setSize(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.frame = [];
|
||||
|
||||
// Re-apply image smoothing setting after canvas resize
|
||||
this.ctx.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
createFrame(fill = '000000') {
|
||||
return new Array(this.width * this.height).fill(fill);
|
||||
}
|
||||
|
||||
hexToRgb(hex) {
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
rgbToHex(rgb) {
|
||||
const r = Math.max(0, Math.min(255, Math.round(rgb.r))).toString(16).padStart(2, '0');
|
||||
const g = Math.max(0, Math.min(255, Math.round(rgb.g))).toString(16).padStart(2, '0');
|
||||
const b = Math.max(0, Math.min(255, Math.round(rgb.b))).toString(16).padStart(2, '0');
|
||||
return r + g + b;
|
||||
}
|
||||
|
||||
lerpRgb(rgb1, rgb2, t) {
|
||||
return {
|
||||
r: rgb1.r + (rgb2.r - rgb1.r) * t,
|
||||
g: rgb1.g + (rgb2.g - rgb1.g) * t,
|
||||
b: rgb1.b + (rgb2.b - rgb1.b) * t
|
||||
};
|
||||
}
|
||||
|
||||
clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
toIndex(col, row) {
|
||||
// Serpentine wiring
|
||||
if (row % 2 === 0) {
|
||||
return row * this.width + col;
|
||||
}
|
||||
return row * this.width + (this.width - 1 - col);
|
||||
}
|
||||
|
||||
setPixelColor(frame, col, row, color, intensity = 1.0) {
|
||||
const index = this.toIndex(col, row);
|
||||
|
||||
if (index < 0 || index >= frame.length) return;
|
||||
|
||||
if (intensity >= 1.0) {
|
||||
frame[index] = color;
|
||||
} else {
|
||||
const currentRgb = this.hexToRgb(frame[index]);
|
||||
const newRgb = this.hexToRgb(color);
|
||||
|
||||
const blended = {
|
||||
r: currentRgb.r + (newRgb.r - currentRgb.r) * intensity,
|
||||
g: currentRgb.g + (newRgb.g - currentRgb.g) * intensity,
|
||||
b: currentRgb.b + (newRgb.b - currentRgb.b) * intensity
|
||||
};
|
||||
|
||||
frame[index] = this.rgbToHex(blended);
|
||||
}
|
||||
}
|
||||
|
||||
// Color generators
|
||||
createColorGenerator(colorConfig) {
|
||||
if (!colorConfig) {
|
||||
return () => 'ff0000';
|
||||
}
|
||||
|
||||
const type = colorConfig.type || 'solid';
|
||||
|
||||
switch (type) {
|
||||
case 'solid':
|
||||
return () => colorConfig.value || 'ff0000';
|
||||
|
||||
case 'gradient': {
|
||||
const rgb1 = this.hexToRgb(colorConfig.color1 || 'ff0000');
|
||||
const rgb2 = this.hexToRgb(colorConfig.color2 || '0000ff');
|
||||
return (t) => {
|
||||
const clamped = this.clamp(t, 0, 1);
|
||||
return this.rgbToHex(this.lerpRgb(rgb1, rgb2, clamped));
|
||||
};
|
||||
}
|
||||
|
||||
case 'rainbow': {
|
||||
return (t) => {
|
||||
const pos = Math.floor(this.clamp(t, 0, 1) * 255);
|
||||
const wheelPos = 255 - pos;
|
||||
let r, g, b;
|
||||
|
||||
if (wheelPos < 85) {
|
||||
r = 255 - wheelPos * 3;
|
||||
g = 0;
|
||||
b = wheelPos * 3;
|
||||
} else if (wheelPos < 170) {
|
||||
const adjusted = wheelPos - 85;
|
||||
r = 0;
|
||||
g = adjusted * 3;
|
||||
b = 255 - adjusted * 3;
|
||||
} else {
|
||||
const adjusted = wheelPos - 170;
|
||||
r = adjusted * 3;
|
||||
g = 255 - adjusted * 3;
|
||||
b = 0;
|
||||
}
|
||||
|
||||
return this.rgbToHex({ r, g, b });
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return () => 'ff0000';
|
||||
}
|
||||
}
|
||||
|
||||
// Shape rendering
|
||||
renderCircle(frame, centerX, centerY, radius, color, intensity = 1.0) {
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
for (let col = 0; col < this.width; col++) {
|
||||
const dx = col - centerX;
|
||||
const dy = row - centerY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance <= radius) {
|
||||
const falloff = 1 - (distance / radius);
|
||||
const pixelIntensity = falloff * intensity;
|
||||
this.setPixelColor(frame, col, row, color, pixelIntensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderRectangle(frame, x, y, width, height, color, intensity = 1.0) {
|
||||
for (let row = Math.floor(y); row < Math.min(this.height, y + height); row++) {
|
||||
for (let col = Math.floor(x); col < Math.min(this.width, x + width); col++) {
|
||||
if (row >= 0 && col >= 0) {
|
||||
this.setPixelColor(frame, col, row, color, intensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderBlob(frame, centerX, centerY, radius, color, falloffPower = 2) {
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
for (let col = 0; col < this.width; col++) {
|
||||
const dx = col - centerX;
|
||||
const dy = row - centerY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const falloff = Math.max(0, 1 - distance / radius);
|
||||
const intensity = Math.pow(falloff, falloffPower);
|
||||
|
||||
if (intensity > 0.01) {
|
||||
this.setPixelColor(frame, col, row, color, intensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderPoint(frame, x, y, color, intensity = 1.0) {
|
||||
const col = Math.round(x);
|
||||
const row = Math.round(y);
|
||||
if (col >= 0 && col < this.width && row >= 0 && row < this.height) {
|
||||
this.setPixelColor(frame, col, row, color, intensity);
|
||||
}
|
||||
}
|
||||
|
||||
renderTriangle(frame, x1, y1, x2, y2, x3, y3, color, intensity = 1.0) {
|
||||
// Simple filled triangle using barycentric coordinates
|
||||
const minX = Math.max(0, Math.floor(Math.min(x1, x2, x3)));
|
||||
const maxX = Math.min(this.width - 1, Math.ceil(Math.max(x1, x2, x3)));
|
||||
const minY = Math.max(0, Math.floor(Math.min(y1, y2, y3)));
|
||||
const maxY = Math.min(this.height - 1, Math.ceil(Math.max(y1, y2, y3)));
|
||||
|
||||
for (let row = minY; row <= maxY; row++) {
|
||||
for (let col = minX; col <= maxX; col++) {
|
||||
if (this.isPointInTriangle(col, row, x1, y1, x2, y2, x3, y3)) {
|
||||
this.setPixelColor(frame, col, row, color, intensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPointInTriangle(px, py, x1, y1, x2, y2, x3, y3) {
|
||||
const d1 = this.sign(px, py, x1, y1, x2, y2);
|
||||
const d2 = this.sign(px, py, x2, y2, x3, y3);
|
||||
const d3 = this.sign(px, py, x3, y3, x1, y1);
|
||||
|
||||
const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
|
||||
const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
|
||||
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
sign(px, py, x1, y1, x2, y2) {
|
||||
return (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
|
||||
}
|
||||
|
||||
// Animation generators
|
||||
createAnimation(animConfig, layerIndex) {
|
||||
const type = animConfig.type;
|
||||
const params = animConfig.params || {};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
switch (type) {
|
||||
case 'move':
|
||||
return () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const duration = params.duration || 2.0;
|
||||
const t = this.clamp((elapsed % duration) / duration, 0, 1);
|
||||
return {
|
||||
x: (params.startX || 0) + ((params.endX || this.width) - (params.startX || 0)) * t,
|
||||
y: (params.startY || 0) + ((params.endY || this.height) - (params.startY || 0)) * t,
|
||||
t
|
||||
};
|
||||
};
|
||||
|
||||
case 'rotate':
|
||||
return () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const speed = params.speed || 1.0;
|
||||
return { angle: elapsed * speed * Math.PI * 2, elapsed };
|
||||
};
|
||||
|
||||
case 'pulse':
|
||||
return () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const frequency = params.frequency || 1.0;
|
||||
const t = (Math.sin(2 * Math.PI * frequency * elapsed) + 1) / 2;
|
||||
const minScale = params.minScale || 0.5;
|
||||
const maxScale = params.maxScale || 1.5;
|
||||
return { scale: minScale + (maxScale - minScale) * t, t, elapsed };
|
||||
};
|
||||
|
||||
case 'oscillateX':
|
||||
case 'oscillateY':
|
||||
return () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const frequency = params.frequency || 0.5;
|
||||
const center = params.center || (type === 'oscillateX' ? this.width / 2 : this.height / 2);
|
||||
const amplitude = params.amplitude || 4;
|
||||
const phase = params.phase || 0;
|
||||
const value = center + amplitude * Math.sin(2 * Math.PI * frequency * elapsed + phase);
|
||||
return { value, elapsed };
|
||||
};
|
||||
|
||||
case 'fade':
|
||||
return () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const duration = params.duration || 2.0;
|
||||
const t = this.clamp(elapsed / duration, 0, 1);
|
||||
const fadeIn = params.fadeIn !== false;
|
||||
return { intensity: fadeIn ? t : (1 - t), t, elapsed };
|
||||
};
|
||||
|
||||
default:
|
||||
return () => ({});
|
||||
}
|
||||
}
|
||||
|
||||
// Render a complete preset configuration
|
||||
renderConfiguration(configuration, speed = 1.0, brightness = 1.0) {
|
||||
const now = Date.now();
|
||||
const deltaTime = (now - this.lastFrameTime) / 1000;
|
||||
this.lastFrameTime = now;
|
||||
|
||||
this.time += deltaTime * speed;
|
||||
|
||||
let frame = this.createFrame('000000');
|
||||
|
||||
// Render each layer
|
||||
configuration.layers?.forEach((layer, index) => {
|
||||
this.renderLayer(frame, layer, index, brightness);
|
||||
});
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
renderLayer(frame, layer, layerIndex, globalBrightness) {
|
||||
const type = layer.type;
|
||||
|
||||
if (type === 'shape') {
|
||||
this.renderShapeLayer(frame, layer, layerIndex, globalBrightness);
|
||||
} else if (type === 'pattern') {
|
||||
this.renderPatternLayer(frame, layer, layerIndex, globalBrightness);
|
||||
}
|
||||
}
|
||||
|
||||
renderShapeLayer(frame, layer, layerIndex, globalBrightness) {
|
||||
let position = layer.position || { x: this.width / 2, y: this.height / 2 };
|
||||
let size = layer.size || { radius: 3 };
|
||||
let rotation = 0;
|
||||
let scale = 1.0;
|
||||
let intensity = (layer.intensity || 1.0) * globalBrightness;
|
||||
|
||||
// Initialize animation if needed
|
||||
if (layer.animation && !this.animationStates.has(layerIndex)) {
|
||||
this.animationStates.set(layerIndex, this.createAnimation(layer.animation, layerIndex));
|
||||
}
|
||||
|
||||
// Apply animation
|
||||
if (this.animationStates.has(layerIndex)) {
|
||||
const animation = this.animationStates.get(layerIndex);
|
||||
const animState = animation();
|
||||
|
||||
if (animState.x !== undefined && animState.y !== undefined) {
|
||||
position = { x: animState.x, y: animState.y };
|
||||
}
|
||||
|
||||
if (animState.angle !== undefined) {
|
||||
rotation = animState.angle;
|
||||
}
|
||||
|
||||
if (animState.scale !== undefined) {
|
||||
scale = animState.scale;
|
||||
}
|
||||
|
||||
if (animState.intensity !== undefined) {
|
||||
intensity *= animState.intensity;
|
||||
}
|
||||
|
||||
if (animState.value !== undefined && layer.animation?.axis === 'x') {
|
||||
position.x = animState.value;
|
||||
} else if (animState.value !== undefined && layer.animation?.axis === 'y') {
|
||||
position.y = animState.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Create color generator
|
||||
const colorGen = this.createColorGenerator(layer.color);
|
||||
const color = typeof colorGen === 'function' ? colorGen(0.5) : colorGen;
|
||||
|
||||
// Apply scale to size
|
||||
const scaledSize = {
|
||||
radius: (size.radius || 3) * scale,
|
||||
width: (size.width || 5) * scale,
|
||||
height: (size.height || 5) * scale
|
||||
};
|
||||
|
||||
// Render shape
|
||||
const shapeType = layer.shape || 'circle';
|
||||
|
||||
switch (shapeType) {
|
||||
case 'circle':
|
||||
this.renderCircle(frame, position.x, position.y, scaledSize.radius, color, intensity);
|
||||
break;
|
||||
|
||||
case 'rectangle':
|
||||
this.renderRectangle(
|
||||
frame,
|
||||
position.x - scaledSize.width / 2,
|
||||
position.y - scaledSize.height / 2,
|
||||
scaledSize.width,
|
||||
scaledSize.height,
|
||||
color,
|
||||
intensity
|
||||
);
|
||||
break;
|
||||
|
||||
case 'triangle': {
|
||||
// Triangle with rotation support
|
||||
const triSize = scaledSize.radius || 3;
|
||||
const points = [
|
||||
{ x: 0, y: -triSize },
|
||||
{ x: -triSize, y: triSize },
|
||||
{ x: triSize, y: triSize }
|
||||
];
|
||||
|
||||
// Apply rotation if there's an animation with angle
|
||||
const cos = Math.cos(rotation);
|
||||
const sin = Math.sin(rotation);
|
||||
|
||||
const rotated = points.map(p => ({
|
||||
x: p.x * cos - p.y * sin,
|
||||
y: p.x * sin + p.y * cos
|
||||
}));
|
||||
|
||||
this.renderTriangle(
|
||||
frame,
|
||||
position.x + rotated[0].x,
|
||||
position.y + rotated[0].y,
|
||||
position.x + rotated[1].x,
|
||||
position.y + rotated[1].y,
|
||||
position.x + rotated[2].x,
|
||||
position.y + rotated[2].y,
|
||||
color,
|
||||
intensity
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blob':
|
||||
this.renderBlob(frame, position.x, position.y, scaledSize.radius, color, layer.falloffPower || 2);
|
||||
break;
|
||||
|
||||
case 'point':
|
||||
this.renderPoint(frame, position.x, position.y, color, intensity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderPatternLayer(frame, layer, layerIndex, globalBrightness) {
|
||||
// Simplified pattern rendering for canvas
|
||||
const patternType = layer.pattern;
|
||||
const intensity = (layer.intensity || 1.0) * globalBrightness;
|
||||
|
||||
if (patternType === 'radial' || patternType === 'spiral') {
|
||||
const params = layer.params || {};
|
||||
const centerX = params.centerX || this.width / 2;
|
||||
const centerY = params.centerY || this.height / 2;
|
||||
const maxRadius = Math.hypot(this.width / 2, this.height / 2);
|
||||
const colorGen = this.createColorGenerator(layer.color);
|
||||
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
for (let col = 0; col < this.width; col++) {
|
||||
const dx = col - centerX;
|
||||
const dy = row - centerY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
let t;
|
||||
if (patternType === 'spiral') {
|
||||
const arms = params.arms || 5;
|
||||
const rotationSpeed = params.rotationSpeed || 1.0;
|
||||
const spiralValue = (Math.sin(arms * (angle + this.time * rotationSpeed)) + 1) / 2;
|
||||
const radiusValue = distance / maxRadius;
|
||||
t = this.clamp(spiralValue * 0.5 + radiusValue * 0.5, 0, 1);
|
||||
} else {
|
||||
t = this.clamp(distance / maxRadius, 0, 1);
|
||||
}
|
||||
|
||||
const color = colorGen(t);
|
||||
// Use setPixelColor for proper layer compositing
|
||||
this.setPixelColor(frame, col, row, color, intensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw frame to canvas
|
||||
drawFrame(frame) {
|
||||
// Clear canvas first
|
||||
this.ctx.fillStyle = '#000000';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
const pixelWidth = this.canvas.width / this.width;
|
||||
const pixelHeight = this.canvas.height / this.height;
|
||||
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
for (let col = 0; col < this.width; col++) {
|
||||
const index = this.toIndex(col, row);
|
||||
const color = frame[index];
|
||||
|
||||
if (color) {
|
||||
const rgb = this.hexToRgb(color);
|
||||
this.ctx.fillStyle = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
||||
this.ctx.fillRect(col * pixelWidth, row * pixelHeight, pixelWidth, pixelHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.ctx.fillStyle = '#000000';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.animationStates.clear();
|
||||
this.time = 0;
|
||||
this.lastFrameTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = CanvasRenderer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user