Source: core/sceneObjs/primitiveCurveHelpers.js

/*
 * Copyright 2026 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
 */

import geometry from '../geometry.js';

const TWO_PI = Math.PI * 2;
const SMOOTH_NORMAL_LENGTH_RATIO_LIMIT = 10;

export function createLineSegmentCurve(start, end) {
  if (!isFinitePoint(start) || !isFinitePoint(end) ||
      (start.x === end.x && start.y === end.y)) {
    return null;
  }
  return {
    kind: 'lineSegment',
    params: {
      start: { x: start.x, y: start.y },
      end: { x: end.x, y: end.y }
    }
  };
}

export function createCubicBezierCurve(curve) {
  if (!curve?.points || curve.points.length !== 4) return null;
  const [start, control1, control2, end] = curve.points;
  if (![start, control1, control2, end].every(isFinitePoint)) return null;
  if ([control1, control2, end].every(
    point => point.x === start.x && point.y === start.y
  )) {
    return null;
  }
  return {
    kind: 'cubicBezier',
    params: {
      start: { x: start.x, y: start.y },
      control1: { x: control1.x, y: control1.y },
      control2: { x: control2.x, y: control2.y },
      end: { x: end.x, y: end.y }
    }
  };
}

/**
 * Convert an endpoint/through-point circular arc to the primitive bulge form.
 * Collinear input falls back to a line segment.
 */
export function createArcOrLineCurve(start, end, through) {
  if (![start, end, through].every(isFinitePoint)) return null;
  const center = geometry.linesIntersection(
    geometry.perpendicularBisector(geometry.line(start, through)),
    geometry.perpendicularBisector(geometry.line(end, through))
  );
  if (center && Number.isFinite(center.x) && Number.isFinite(center.y)) {
    const startAngle = Math.atan2(start.y - center.y, start.x - center.x);
    const endAngle = Math.atan2(end.y - center.y, end.x - center.x);
    const throughAngle = Math.atan2(through.y - center.y, through.x - center.x);
    const counterclockwiseSweep = normalizeAngle(endAngle - startAngle);
    const throughSweep = normalizeAngle(throughAngle - startAngle);
    const signedSweep = throughSweep <= counterclockwiseSweep + 1e-12
      ? counterclockwiseSweep
      : counterclockwiseSweep - TWO_PI;
    const bulge = Math.tan(signedSweep * 0.25);
    if (Number.isFinite(bulge) && bulge !== 0) {
      return {
        kind: 'circularArc',
        params: {
          start: { x: start.x, y: start.y },
          end: { x: end.x, y: end.y },
          bulge
        }
      };
    }
  }
  return createLineSegmentCurve(start, end);
}

/**
 * Convert the sampled representation maintained by ParamCurveObjMixin and the
 * custom-equation glass/mirror classes. Each returned entry retains the
 * sampled parameter endpoints used to reconstruct a legacy `t` from native
 * primitive parameter `u`.
 */
export function createSampledPrimitiveCurveEntries(obj, {
  skipBoundarySegments = false
} = {}) {
  if (!obj.path && !obj.initPath?.()) return [];
  if (!obj.path || obj.path.length < 2) return [];

  if (obj.curveType === 'cubicBezier') {
    if (!obj._ensureCubicBezierPathReady?.()) return [];
    const entries = [];
    for (let index = 0; index < obj.bezierSegments.length; index++) {
      const isBoundary = Boolean(
        obj.bezierSegmentBoundaryFlags?.[index] ||
        obj.bezierSegmentLinearFlags?.[index]
      );
      if (skipBoundarySegments && isBoundary) continue;
      const curve = isBoundary
        ? createLineSegmentCurve(obj.path[index], obj.path[index + 1])
        : createCubicBezierCurve(obj.bezierSegments[index]);
      if (!curve) continue;
      entries.push({
        curve,
        index,
        parameterStart: obj.path[index].t,
        parameterEnd: obj.path[index + 1].t
      });
    }
    return entries;
  }

  const segmentInfos = obj.path.slice(0, -1).map(
    (start, index) => createSegmentInfo(start, obj.path[index + 1])
  );
  const entries = [];
  for (let index = 0; index < obj.path.length - 1; index++) {
    const start = obj.path[index];
    const end = obj.path[index + 1];
    const segment = segmentInfos[index];
    if (!segment) continue;
    let curve;
    if (obj.curveType === 'smoothNormal' &&
        !isLargeSamplingSkip(segmentInfos, index)) {
      curve = {
        kind: 'smoothLineSegment',
        params: {
          start: { x: start.x, y: start.y },
          end: { x: end.x, y: end.y },
          startNormal: getCornerNormal(segment, segmentInfos[index - 1]),
          endNormal: getCornerNormal(segment, segmentInfos[index + 1])
        }
      };
    } else {
      curve = createLineSegmentCurve(start, end);
    }
    if (!curve) continue;
    entries.push({
      curve,
      index,
      parameterStart: start.t,
      parameterEnd: end.t
    });
  }
  return entries;
}

export function createBezierPrimitiveCurves(curves) {
  return (curves || []).map(createCubicBezierCurve).filter(Boolean);
}

function isFinitePoint(point) {
  return Number.isFinite(point?.x) && Number.isFinite(point?.y);
}

function normalizeAngle(angle) {
  return ((angle % TWO_PI) + TWO_PI) % TWO_PI;
}

function createSegmentInfo(start, end) {
  if (!isFinitePoint(start) || !isFinitePoint(end)) return null;
  const tangentX = end.x - start.x;
  const tangentY = end.y - start.y;
  const length = Math.hypot(tangentX, tangentY);
  if (!(length > 0)) return null;
  return {
    length,
    normal: { x: -tangentY / length, y: tangentX / length }
  };
}

function getCornerNormal(segment, adjacentSegment) {
  if (!lengthsAreComparable(segment, adjacentSegment)) {
    return { ...segment.normal };
  }
  const x = segment.normal.x + adjacentSegment.normal.x;
  const y = segment.normal.y + adjacentSegment.normal.y;
  const length = Math.hypot(x, y);
  return length > 0
    ? { x: x / length, y: y / length }
    : { ...segment.normal };
}

function lengthsAreComparable(first, second) {
  if (!first || !second) return false;
  return first.length / second.length < SMOOTH_NORMAL_LENGTH_RATIO_LIMIT &&
    second.length / first.length < SMOOTH_NORMAL_LENGTH_RATIO_LIMIT;
}

function isLargeSamplingSkip(segments, index) {
  const segment = segments[index];
  const previous = segments[index - 1];
  const next = segments[index + 1];
  return Boolean(
    previous &&
      segment.length / previous.length >= SMOOTH_NORMAL_LENGTH_RATIO_LIMIT ||
    next &&
      segment.length / next.length >= SMOOTH_NORMAL_LENGTH_RATIO_LIMIT
  );
}