Source: core/CanvasRenderer.js

/*
 * Copyright 2024 The Ray Optics Simulation authors and contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import * as C2S from 'canvas2svg';

/**
 * A class to render geometric figures from geometry.js on a canvas, and to handle the transformation and background image of the canvas.
 * @class
 */
class CanvasRenderer {
  constructor(ctx, origin, scale, lengthScale, backgroundImage, ctxVirtual) {

    /** @property {Object} ctx - The context of the canvas. */
    this.ctx = ctx;

    /** @property {Object} origin - The origin of the scene in the viewport. */
    this.origin = origin;

    /** @property {number} scale - The scale factor (the viewport physical pixel per internal length unit) of the scene. */
    this.scale = scale;

    /** @property {number} lengthScale - The scale factor of the length units of the scene. */
    this.lengthScale = lengthScale;

    /** @property {Object} canvas - The canvas of the scene. */
    this.canvas = ctx.canvas;

    /** @property {Object|null} backgroundImage - The background image of the scene, null if not set. */
    this.backgroundImage = backgroundImage;

    /** @property {CanvasRenderingContext2D} ctxVirtual - The virtual context for color adjustment. */
    this.ctxVirtual = ctxVirtual;

    if (typeof C2S !== 'undefined' && ctx.constructor === C2S) {
      /** @property {boolean} isSVG - Whether the canvas is being exported to SVG. */
      this.isSVG = true;
    }

    // Initialize the canvas
    if (!this.isSVG) {
      // only do this when not being exported to SVG to avoid bug
      this.ctx.setTransform(1, 0, 0, 1, 0, 0);
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
      this.ctx.setTransform(this.scale, 0, 0, this.scale, this.origin.x, this.origin.y);
      if (this.ctx.constructor !== C2S && this.backgroundImage) {
        this.ctx.globalAlpha = 1;
        this.ctx.drawImage(this.backgroundImage, 0, 0);
      }
    }
  }

  /**
   * Converts an RGBA array [R, G, B, A] with values between 0 and 1 to a CSS color string.
   * @param {number[]} rgba - The RGBA array.
   * @returns {string} The CSS color string.
   */
  rgbaToCssColor(rgba) {
    const [r, g, b, a] = rgba;
    return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
  }

  /**
   * Draw a point.
   * @param {Point} p
   * @param {number[]} [color=[0, 0, 0, 1]]
   * @param {number} [size=5]
   */
  drawPoint(p, color = [0, 0, 0, 1], size = 5) {
    this.ctx.fillStyle = this.rgbaToCssColor(color);
    this.ctx.fillRect(p.x - (size / 2) * this.lengthScale, p.y - (size / 2) * this.lengthScale, size * this.lengthScale, size * this.lengthScale);
  }

  /**
   * Draw a line.
   * @param {Line} l
   * @param {number[]} [color=[0, 0, 0, 1]]
   */
  drawLine(l, color = [0, 0, 0, 1]) {
    this.ctx.strokeStyle = this.rgbaToCssColor(color);
    this.ctx.lineWidth = 1 * this.lengthScale;
    this.ctx.beginPath();
    let ang1 = Math.atan2((l.p2.x - l.p1.x), (l.p2.y - l.p1.y));
    let cvsLimit = (Math.abs(l.p1.x + this.origin.x) + Math.abs(l.p1.y + this.origin.y) + this.canvas.height + this.canvas.width) / Math.min(1, this.scale);
    this.ctx.moveTo(l.p1.x - Math.sin(ang1) * cvsLimit, l.p1.y - Math.cos(ang1) * cvsLimit);
    this.ctx.lineTo(l.p1.x + Math.sin(ang1) * cvsLimit, l.p1.y + Math.cos(ang1) * cvsLimit);
    this.ctx.stroke();
  }

  /**
   * Draw a ray.
   * @param {Line} r
   * @param {number[]} [color=[0, 0, 0, 1]]
   * @param {boolean} [showArrow=true]
   * @param {number[]} [lineDash=[]]
   */
  drawRay(r, color = [0, 0, 0, 1], showArrow = false, lineDash = []) {
    this.ctx.setLineDash(lineDash);
    this.ctx.strokeStyle = this.rgbaToCssColor(color);
    this.ctx.lineWidth = 1 * this.lengthScale;
    this.ctx.fillStyle = this.rgbaToCssColor(color);
    
    // Check if ray has a valid direction
    if (Math.abs(r.p2.x - r.p1.x) <= 1e-5 * this.lengthScale && Math.abs(r.p2.y - r.p1.y) <= 1e-5 * this.lengthScale) {
      return;
    }
    
    // Calculate direction vector and normalize it
    const dx = r.p2.x - r.p1.x;
    const dy = r.p2.y - r.p1.y;
    const length = Math.sqrt(dx * dx + dy * dy);
    const unitX = dx / length;
    const unitY = dy / length;
    
    // Calculate canvas limit for ray length
    const cvsLimit = (Math.abs(r.p1.x + this.origin.x) + Math.abs(r.p1.y + this.origin.y) + this.canvas.height + this.canvas.width) / Math.min(1, this.scale);
    
    // Calculate arrow size and position
    const arrowSize = 5 * this.lengthScale;
    const arrowDistance = 150 * this.lengthScale;  // Fixed distance from p1
    
    // Don't draw arrow if it would be too small compared to line width
    if (!showArrow || arrowSize < this.ctx.lineWidth * 1.2) {
      // Draw the ray without arrow
      this.ctx.beginPath();
      this.ctx.moveTo(r.p1.x, r.p1.y);
      this.ctx.lineTo(r.p1.x + unitX * cvsLimit, r.p1.y + unitY * cvsLimit);
      this.ctx.stroke();
      return;
    }
    
    // Draw first part of ray (from p1 to arrow)
    this.ctx.beginPath();
    this.ctx.moveTo(r.p1.x, r.p1.y);
    this.ctx.lineTo(r.p1.x + unitX * arrowDistance, r.p1.y + unitY * arrowDistance);
    this.ctx.stroke();
    
    // Calculate arrow points for trapezoid
    const arrowX = r.p1.x + unitX * arrowDistance;
    const arrowY = r.p1.y + unitY * arrowDistance;
    const baseWidth = this.ctx.lineWidth;
    const tipWidth = arrowSize;
    
    // Calculate perpendicular vector for width
    const perpX = -unitY;
    const perpY = unitX;
    
    // Draw arrow as trapezoid
    this.ctx.beginPath();
    // Front points of arrow (wide part)
    this.ctx.moveTo(
      arrowX - (tipWidth/2) * perpX,
      arrowY - (tipWidth/2) * perpY
    );
    this.ctx.lineTo(
      arrowX + (tipWidth/2) * perpX,
      arrowY + (tipWidth/2) * perpY
    );
    // Back of arrow (narrow part)
    this.ctx.lineTo(
      arrowX + arrowSize * unitX + (baseWidth/2) * perpX,
      arrowY + arrowSize * unitY + (baseWidth/2) * perpY
    );
    this.ctx.lineTo(
      arrowX + arrowSize * unitX - (baseWidth/2) * perpX,
      arrowY + arrowSize * unitY - (baseWidth/2) * perpY
    );
    this.ctx.closePath();
    this.ctx.fill();
    
    // Draw rest of ray (from arrow to infinity)
    this.ctx.beginPath();
    this.ctx.moveTo(arrowX + arrowSize * unitX, arrowY + arrowSize * unitY);
    this.ctx.lineTo(r.p1.x + unitX * cvsLimit, r.p1.y + unitY * cvsLimit);
    this.ctx.stroke();
  }

  /**
   * Draw a segment.
   * @param {Line} s
   * @param {number[]} [color=[0, 0, 0, 1]]
   * @param {boolean} [showArrow=true]
   * @param {number} [arrowPosition=0.67] Position of arrow along line (0 to 1, where 0 is at p1 and 1 is at p2)
   */
  drawSegment(s, color = [0, 0, 0, 1], showArrow = false, lineDash = []) {
    this.ctx.setLineDash(lineDash);
    this.ctx.strokeStyle = this.rgbaToCssColor(color);
    this.ctx.lineWidth = 1 * this.lengthScale;
    this.ctx.fillStyle = this.rgbaToCssColor(color);
    
    // Calculate arrow size first to determine if we should draw it
    const dx = s.p2.x - s.p1.x;
    const dy = s.p2.y - s.p1.y;
    const length = Math.sqrt(dx * dx + dy * dy);
    const arrowSize = Math.min(length * 0.15, 5 * this.lengthScale);
    const arrowPosition = 0.67;
    
    // Don't draw arrow if it would be too small compared to line width
    if (!showArrow || arrowSize < this.ctx.lineWidth * 1.2) {
      // Draw the line segment without arrow
      this.ctx.beginPath();
      this.ctx.moveTo(s.p1.x, s.p1.y);
      this.ctx.lineTo(s.p2.x, s.p2.y);
      this.ctx.stroke();
      return;
    }
    
    // Calculate direction vector and normalize it
    const unitX = dx / length;
    const unitY = dy / length;
    
    // Calculate arrow position
    const arrowX = s.p1.x + dx * arrowPosition;
    const arrowY = s.p1.y + dy * arrowPosition;
    
    // Draw first part of line (from p1 to arrow)
    this.ctx.beginPath();
    this.ctx.moveTo(s.p1.x, s.p1.y);
    this.ctx.lineTo(arrowX - arrowSize/2 * unitX, arrowY - arrowSize/2 * unitY);
    this.ctx.stroke();
    
    // Calculate arrow points for trapezoid
    const baseWidth = this.ctx.lineWidth;  // Use existing line width
    const tipWidth = arrowSize;  // Width at the front of arrow
    
    // Calculate perpendicular vector for width
    const perpX = -unitY;
    const perpY = unitX;
    
    // Draw arrow as trapezoid
    this.ctx.beginPath();
    // Front points of arrow (wide part)
    this.ctx.moveTo(
      arrowX - arrowSize/2 * unitX - (tipWidth/2) * perpX,
      arrowY - arrowSize/2 * unitY - (tipWidth/2) * perpY
    );
    this.ctx.lineTo(
      arrowX - arrowSize/2 * unitX + (tipWidth/2) * perpX,
      arrowY - arrowSize/2 * unitY + (tipWidth/2) * perpY
    );
    // Back of arrow (narrow part)
    this.ctx.lineTo(
      arrowX + arrowSize/2 * unitX + (baseWidth/2) * perpX,
      arrowY + arrowSize/2 * unitY + (baseWidth/2) * perpY
    );
    this.ctx.lineTo(
      arrowX + arrowSize/2 * unitX - (baseWidth/2) * perpX,
      arrowY + arrowSize/2 * unitY - (baseWidth/2) * perpY
    );
    this.ctx.closePath();
    this.ctx.fill();
    
    // Draw second part of line (from arrow to p2)
    this.ctx.beginPath();
    this.ctx.moveTo(arrowX + arrowSize/2 * unitX, arrowY + arrowSize/2 * unitY);
    this.ctx.lineTo(s.p2.x, s.p2.y);
    this.ctx.stroke();
  }

  /**
   * Draw a circle.
   * @param {Circle} c
   * @param {String} [color='black']
   */
  drawCircle(c, color = 'black') {
    this.ctx.strokeStyle = color;
    this.ctx.lineWidth = 1 * this.lengthScale;
    this.ctx.beginPath();
    if (typeof c.r === 'object') {
      let dx = c.r.p1.x - c.r.p2.x;
      let dy = c.r.p1.y - c.r.p2.y;
      this.ctx.arc(c.c.x, c.c.y, Math.sqrt(dx * dx + dy * dy), 0, Math.PI * 2, false);
    } else {
      this.ctx.arc(c.c.x, c.c.y, c.r, 0, Math.PI * 2, false);
    }
    this.ctx.stroke();
  }

  /**
   * Apply color transformation to simulate 'lighter' composition with less color saturation.
   */
  applyColorTransformation() {
    this.ctxVirtual.canvas.width = this.canvas.width;
    this.ctxVirtual.canvas.height = this.canvas.height;
    this.ctxVirtual.drawImage(this.canvas, 0, 0);

    const imageData = this.ctxVirtual.getImageData(0, 0, this.ctxVirtual.canvas.width, this.ctxVirtual.canvas.height);
    const data = imageData.data;
    for (let i = 0; i < data.length; i += 4) {
      if (data[i + 3] === 0) continue; // Skip transparent pixels
      const a0 = data[i + 3] / 256;
      const R = -Math.log(1 - (data[i] / 256)) * a0;
      const G = -Math.log(1 - (data[i + 1] / 256)) * a0;
      const B = -Math.log(1 - (data[i + 2] / 256)) * a0;
      const factor = Math.max(R, G, B);
      data[i] = 255 * R / factor;
      data[i + 1] = 255 * G / factor;
      data[i + 2] = 255 * B / factor;
      data[i + 3] = 255 * Math.min(factor, 1);
    }
    this.ctxVirtual.putImageData(imageData, 0, 0);
    this.ctx.globalCompositeOperation = 'source-over';
    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.drawImage(this.ctxVirtual.canvas, 0, 0);
  }
}

export default CanvasRenderer;