542 lines
20 KiB
JavaScript
542 lines
20 KiB
JavaScript
// 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 });
|
|
};
|
|
}
|
|
|
|
case 'palette': {
|
|
const stops = colorConfig.stops || [
|
|
{ position: 0, color: '000000' },
|
|
{ position: 1, color: 'ffffff' }
|
|
];
|
|
|
|
return (t) => {
|
|
const clamped = this.clamp(t, 0, 1);
|
|
|
|
// Find the two stops to interpolate between
|
|
let stop1 = stops[0];
|
|
let stop2 = stops[stops.length - 1];
|
|
|
|
for (let i = 0; i < stops.length - 1; i++) {
|
|
if (clamped >= stops[i].position && clamped <= stops[i + 1].position) {
|
|
stop1 = stops[i];
|
|
stop2 = stops[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Interpolate between the two stops
|
|
const range = stop2.position - stop1.position;
|
|
const localT = range > 0 ? (clamped - stop1.position) / range : 0;
|
|
|
|
const rgb1 = this.hexToRgb(stop1.color);
|
|
const rgb2 = this.hexToRgb(stop2.color);
|
|
|
|
return this.rgbToHex(this.lerpRgb(rgb1, rgb2, localT));
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|