Files
spore-ledlab/public/scripts/canvas-renderer.js
2025-10-12 13:52:22 +02:00

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;
}