Source: core/sceneObjs/other/CustomParamSurface.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 ParamCurveObjMixin from '../ParamCurveObjMixin.js';
import BaseCustomSurface from '../BaseCustomSurface.js';
import i18next from 'i18next';
import geometry from '../../geometry.js';

/**
 * A parametric curve-shaped custom surface.
 * 
 * Tools -> Other -> Custom parametric surface
 * @class
 * @extends BaseCustomSurface
 * @memberof sceneObjs
 * @property {Point} origin - The origin point of the parametric coordinate system.
 * @property {Array} pieces - Array of parametric curve pieces, each with eqnX, eqnY, tMin, tMax, tStep.
 * @property {Array<OutRay>} outRays - The expressions of the outgoing rays.
 * @property {boolean} twoSided - Whether the surface is two-sided.
 * @property {Array<Point>} path - The points on the calculated curve.
 */
class CustomParamSurface extends ParamCurveObjMixin(BaseCustomSurface) {
  static type = 'CustomParamSurface';
  static isOptical = true;
  static mergesWithGlass = true;
  static serializableDefaults = {
    origin: { x: 0, y: 0 },
    pieces: [
      {
        eqnX: "50\\cdot\\cos(t)",
        eqnY: "50\\cdot\\sin(t)",
        tMin: 0,
        tMax: 2 * Math.PI,
        tStep: 0.01
      }
    ],
    outRays: [
      {
        eqnTheta: "\\theta_0",
        eqnP: "0.7\\cdot P_0"
      },
      {
        eqnTheta: "\\pi-\\theta_0",
        eqnP: "P_0-P_1"
      }
    ],
    twoSided: false,
  };

  populateObjBar(objBar) {
    objBar.setTitle(i18next.t('main:tools.CustomParamSurface.title'));
    objBar.createInfoBox('<ul><li>' + i18next.t('simulator:sceneObjs.common.eqnInfo.constants') + '<br><code>pi e</code></li><li>' + i18next.t('simulator:sceneObjs.common.eqnInfo.operators') + '<br><code>+ - * / ^</code></li><li>' + i18next.t('simulator:sceneObjs.common.eqnInfo.functions') + '<br><code>sqrt sin cos tan sec csc cot sinh cosh tanh log exp arcsin arccos arctan arcsinh arccosh arctanh floor round ceil trunc sgn max min abs</code></li><li>' + i18next.t('simulator:sceneObjs.common.eqnInfo.module') + '</li></ul>');
    
    
    // Add parametric curve controls
    this.populateObjBarShape(objBar);
    
    // Add custom surface controls
    super.populateObjBar(objBar);
  }

  draw(canvasRenderer, isAboveLight, isHovered) {
    const ctx = canvasRenderer.ctx;
    const ls = canvasRenderer.lengthScale;

    // Initialize path if needed
    if (!this.path) {
      if (!this.initPath()) {
        // If initialization failed, draw error indicator at origin
        ctx.fillStyle = "red";
        ctx.fillRect(this.origin.x - 1.5 * ls, this.origin.y - 1.5 * ls, 3 * ls, 3 * ls);
        return;
      }
    }

    // Check if curve is empty or fully degenerate
    let isEmptyOrDegenerate = false;
    
    if (this.path.length < 2) {
      isEmptyOrDegenerate = true;
    } else {
      // Check if all points are identical (fully degenerate curve)
      const firstPoint = this.path[0];
      let allPointsIdentical = true;
      for (let i = 1; i < this.path.length; i++) {
        if (Math.abs(this.path[i].x - firstPoint.x) > 1e-10 || 
            Math.abs(this.path[i].y - firstPoint.y) > 1e-10) {
          allPointsIdentical = false;
          break;
        }
      }
      isEmptyOrDegenerate = allPointsIdentical;
    }

    if (isEmptyOrDegenerate) {
      // Empty or fully degenerate curve - always draw origin square
      ctx.fillStyle = isHovered ? 'rgb(255,0,0)' : 'rgb(128,128,128)';
      ctx.fillRect(this.origin.x - 1.5 * ls, this.origin.y - 1.5 * ls, 3 * ls, 3 * ls);
      return;
    }

    // Use theme colors and width
    const theme = this.scene.theme.customSurface;
    const color = isHovered ? this.scene.highlightColorCss : canvasRenderer.rgbaToCssColor(theme.color);
    const baseWidth = theme.width * ls;
    
    if (this.twoSided) {
      // For two-sided surfaces, draw just one curve with the full width
      ctx.strokeStyle = color;
      ctx.lineWidth = baseWidth;
      ctx.setLineDash([]);
      this.drawPath(canvasRenderer);
      ctx.stroke();
    } else {
      // For one-sided surfaces, draw two curves each with half the width
      const halfWidth = baseWidth / 2;
      
      // Draw the main solid curve
      ctx.strokeStyle = color;
      ctx.lineWidth = halfWidth;
      ctx.setLineDash([]);
      this.drawPath(canvasRenderer);
      ctx.stroke();

      // Draw the dashed curve offset by half width (negative offset = right side)
      ctx.lineWidth = halfWidth;
      ctx.setLineDash(theme.dash.map(d => d * ls));
      this.drawPath(canvasRenderer, -halfWidth);
      ctx.stroke();
    }

    // Reset line dash
    ctx.setLineDash([]);
  }

  checkRayIntersects(ray) {
    // Get all intersections
    const intersections = this.getRayIntersections(ray);
    
    if (intersections.length === 0) {
      return null;
    }

    // Filter intersections based on surface sidedness
    let validIntersections = [];
    
    for (const intersection of intersections) {
      // Check for undefined behavior (NaN incidentType)
      if (isNaN(intersection.incidentType)) {
        return {
          isAbsorbed: true,
          isUndefinedBehavior: true
        };
      }

      // For single-sided surfaces, only allow forward-facing intersections (incidentType = 1)
      if (!this.twoSided && intersection.incidentType === -1) {
        continue; // Skip backward-facing intersections for single-sided surfaces
      }

      validIntersections.push({
        intersection: intersection,
        distance: geometry.distanceSquared(ray.p1, intersection.s_point)
      });
    }

    if (validIntersections.length === 0) {
      return null;
    }

    // For two-sided surfaces, use the nearest intersection
    if (this.twoSided) {
      validIntersections.sort((a, b) => a.distance - b.distance);
      return validIntersections[0].intersection.s_point;
    } else {
      // For one-sided surfaces, prefer forward-facing intersections (incidentType = 1)
      const forwardIntersections = validIntersections.filter(item => item.intersection.incidentType === 1);
      if (forwardIntersections.length > 0) {
        // Use the nearest forward-facing intersection
        forwardIntersections.sort((a, b) => a.distance - b.distance);
        return forwardIntersections[0].intersection.s_point;
      } else {
        // If no forward-facing intersections, use the nearest valid intersection
        validIntersections.sort((a, b) => a.distance - b.distance);
        return validIntersections[0].intersection.s_point;
      }
    }
  }

  onRayIncident(ray, rayIndex, incidentPoint, surfaceMergingObjs) {
    // Get all intersections to find the one at the incident point
    const intersections = this.getRayIntersections(ray);
    
    // Find the intersection that matches the incident point
    let matchingIntersection = null;
    for (const intersection of intersections) {
      if (geometry.distanceSquared(incidentPoint, intersection.s_point) < 1e-10) {
        matchingIntersection = intersection;
        break;
      }
    }

    if (!matchingIntersection) {
      // Fallback - shouldn't happen if checkRayIntersects worked correctly
      return {
        isAbsorbed: true,
      };
    }

    // Check for undefined behavior
    if (isNaN(matchingIntersection.incidentType) || 
        isNaN(matchingIntersection.normal.x) || 
        isNaN(matchingIntersection.normal.y)) {
      return {
        isAbsorbed: true,
        isUndefinedBehavior: true,
      };
    }

    // The incident position is just the parameter t (not normalized to -1 to 1)
    const incidentPos = matchingIntersection.incidentPos;

    return this.handleOutRays(ray, rayIndex, incidentPoint, matchingIntersection.normal, incidentPos, surfaceMergingObjs, ray.bodyMergingObj);
  }
}

export default CustomParamSurface;