Source: core/simulationEngines/cpu/CpuSimulationEngine.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
 *
 * 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 CanvasRenderer from '../../CanvasRenderer.js';
import FloatColorRenderer from '../../FloatColorRenderer.js';
import { createDagEvaluator } from '../../formula/dag-evaluator.js';
import {
  createInteractionCandidate,
  createInteractionCandidateContext,
  finalizeInteractionCandidate,
  INTERSECTION_CONFLICT_NONE,
  INTERSECTION_CONFLICT_NORMAL,
  INTERSECTION_TOLERANCE_MERGING,
  INTERSECTION_TOLERANCE_NONE,
  INTERSECTION_TOLERANCE_NORMAL,
  INTERSECTION_TOLERANCE_NORMAL_CONSTRUCTION
} from '../../primitive/interactionCandidate.js';
import {
  createRegionMembershipResult,
  traverseBvhForRegionMembership
} from '../../primitive/regionMembership.js';
import {
  traverseBvhForInteraction
} from '../../primitive/bvhTraversal.js';
import {
  validateNumericEpsilon
} from '../../primitive/numeric.js';
import {
  beginCpuRayRendering,
  createCpuRayRenderState,
  finishCpuRayRendering,
  renderCpuRay
} from './cpuRayRenderer.js';
import {
  createInteractionTypeLayout,
  getInteractionTypeIndex
} from '../interactionIndexBuffers.js';
import {
  createCpuDetectorResults,
  prepareCpuOutgoingRayData,
  writeCpuOutgoingRays
} from './cpuOutgoingRays.js';
import {
  collectRayPowerQueue
} from '../stableRayPowerSampling.js';
import {
  getEffectiveRayPowerOptions
} from '../rayPower.js';
import {
  deriveWebGpuWavelengthRange
} from '../webgpu/webGpuParameterRanges.js';
import {
  DEFAULT_AMBIGUOUS_RAY_WARNING_SAFETY_FACTOR,
  estimateAmbiguousRayWarningPowerThreshold
} from '../ambiguousRayWarning.js';

const MAX_MEMBERSHIP_ATTEMPTS = 4;
const GOLDEN_ANGLE_COS = -0.737368878;
const GOLDEN_ANGLE_SIN = 0.675490294;
export const NO_HIT_CURVE_ID = -1;
export const TERMINATE_HIT_CURVE_ID = -2;
const SOURCE_OUTPUT_LABELS = [
  'x',
  'y',
  'd_x',
  'd_y',
  'P_s',
  'P_p',
  'lambda'
];

export class CpuSimulationRun {
  constructor(engine, options) {
    this.engine = engine;
    this.options = options;
    const rayPowerOptions = getEffectiveRayPowerOptions(options);
    this.rayPowerCutoff = rayPowerOptions.rayPowerCutoff;
    this.rayPowerSampling = rayPowerOptions.rayPowerSampling;
    this.maxRayDepth = normalizeMaxRayDepth(options.maxRayDepth);
    this.isCancelled = false;
    this.isComplete = false;
    this.phase = 'populate';
    this.rayBuffers = [[], []];
    this.currentRayBufferIndex = 0;
    this.hitBuffer = [];
    this.sourceIndex = 0;
    this.sourceRayIndex = 0;
    this.sourceInputs = null;
    this.membershipRayIndex = 0;
    this.samplingGeneration = 0;
    this.passIndex = 0;
    this.processedRayCount = 0;
    this.totalTruncation = 0;
    this.hasRenderedOutput = false;
    this.warningState = {
      totalPower: 0,
      first: null
    };
    this.rendering = createRenderingOptions(options);
    this.renderState = createCpuRayRenderState();
    this.detectorResults = createCpuDetectorResults(
      options.preparedScene.description
    );
    this.membershipScratch = createRegionMembershipResult(
      options.preparedScene.description.regions.length
    );
    this.interactionContext = createInteractionCandidateContext(
      options.preparedScene.description,
      engine.numericEpsilon
    );
    this.summary = {
      sourceCount: options.preparedScene.description.sources.length,
      raySlotCount: 0,
      activeSourceRayCount: 0,
      inactiveSourceRayCount: 0,
      invalidSourceRayCount: 0,
      membershipRetryCount: 0,
      membershipDiscardedRayCount: 0,
      activeRayCount: 0,
      weakRayCount: 0,
      finiteHitCount: 0,
      grinStepCount: 0,
      escapingRayCount: 0,
      normalConflictCount: 0,
      regionCount: options.preparedScene.description.regions.length
    };
    this.engine.beginRenderer({
      origin: options.viewport?.origin || { x: 0, y: 0 },
      scale: options.viewport?.scale ?? 1,
      lengthScale: options.viewport?.lengthScale ?? 1,
      colorMode: options.colorMode ?? 'default',
      rendering: this.rendering
    });
    beginCpuRayRendering(this.engine.ctxMain, this.rendering);
  }

  get currentRayBuffer() {
    return this.rayBuffers[this.currentRayBufferIndex];
  }

  get nextRayBuffer() {
    return this.rayBuffers[1 - this.currentRayBufferIndex];
  }

  async advance({
    timeBudgetMs = Infinity,
    itemBudget = Infinity
  } = {}) {
    if (this.isCancelled || this.isComplete) {
      return this.getUpdate();
    }

    const startTime = getCurrentTime();
    let processedItemCount = 0;
    do {
      this.advanceOneWorkItem();
      processedItemCount++;
    } while (
      !this.isCancelled &&
      !this.isComplete &&
      processedItemCount < Math.max(1, itemBudget) &&
      (
        !Number.isFinite(timeBudgetMs) ||
        getCurrentTime() - startTime < Math.max(0, timeBudgetMs)
      )
    );
    if (
      !this.isComplete &&
      this.phase === 'megakernel' &&
      this.options.colorMode !== 'default'
    ) {
      this.engine.canvasRenderer?.flush?.();
    }
    return this.getUpdate();
  }

  advanceOneWorkItem() {
    switch (this.phase) {
      case 'populate':
        this.populateNextRay();
        break;
      case 'membership':
        this.populateNextMembership();
        break;
      case 'megakernel':
        this.advanceMegakernel();
        break;
      default:
        throw new Error(`Unknown CPU simulation phase: ${this.phase}`);
    }
  }

  populateNextRay() {
    const { description, sourceEvaluators } = this.options.preparedScene;
    while (this.sourceIndex < description.sources.length) {
      const source = description.sources[this.sourceIndex];
      if (this.sourceRayIndex >= source.rayCount) {
        this.sourceIndex++;
        this.sourceRayIndex = 0;
        this.sourceInputs = null;
        continue;
      }

      if (!this.sourceInputs) {
        this.sourceInputs = {
          ...source.params,
          N: source.rayCount
        };
      }
      this.sourceInputs.i = this.sourceRayIndex;
      const output =
        sourceEvaluators[source.sourceTypeId](this.sourceInputs);
      const { ray, invalid } = createInitialRay(
        output,
        this.summary.regionCount,
        this.options.preparedScene.wavelengthRange,
        this.options.preparedScene.keepNonVisibleLight
      );
      const active = isRayActive(ray);
      this.currentRayBuffer.push(ray);
      this.summary.raySlotCount++;
      if (active) {
        this.summary.activeSourceRayCount++;
      } else {
        this.summary.inactiveSourceRayCount++;
      }
      if (invalid) this.summary.invalidSourceRayCount++;
      this.sourceRayIndex++;
      return;
    }

    this.phase = 'membership';
  }

  populateNextMembership() {
    if (this.membershipRayIndex >= this.currentRayBuffer.length) {
      this.summary.activeRayCount =
        this.summary.activeSourceRayCount -
        this.summary.membershipDiscardedRayCount;
      this.beginMegakernelCycle();
      return;
    }

    const ray = this.currentRayBuffer[this.membershipRayIndex++];
    if (!isRayActive(ray)) return;

    const membership = resolveInitialMembership(
      this.options.preparedScene.description,
      ray,
      this.membershipScratch,
      this.engine.numericEpsilon
    );
    this.summary.membershipRetryCount += membership.attemptCount - 1;
    if (!membership.resolved) {
      ray.powerS = 0;
      ray.powerP = 0;
      this.summary.membershipDiscardedRayCount++;
      return;
    }
    ray.membership.set(this.membershipScratch.regionMask);
  }

  beginMegakernelCycle() {
    this.phase = 'megakernel';
    this.megakernelIteration = 0;
    this.megakernelRayIndex = 0;
    this.megakernelLanes = this.currentRayBuffer.map(ray => ({
      ray,
      depth: ray.depth ?? 0,
      active: isRayActive(ray)
    }));
    this.megakernelOutputs = this.currentRayBuffer.map(() => []);
    this.hitBuffer = new Array(this.currentRayBuffer.length);
    this.renderState = createCpuRayRenderState();
  }

  advanceMegakernel() {
    if (this.megakernelRayIndex >= this.megakernelLanes.length) {
      const hasContinuation = this.megakernelLanes.some(lane => lane.active);
      if (
        hasContinuation &&
        this.megakernelIteration + 1 < this.engine.maxLocalIterations
      ) {
        this.megakernelIteration++;
        this.megakernelRayIndex = 0;
        this.renderState = createCpuRayRenderState();
        return;
      }
      this.finishMegakernelCycle();
      return;
    }

    const rayIndex = this.megakernelRayIndex++;
    const lane = this.megakernelLanes[rayIndex];
    if (!lane.active) {
      renderCpuRay({
        ray: lane.ray,
        hit: createInteractionCandidate(this.summary.regionCount, 0),
        renderer: this.engine.canvasRenderer,
        ctxMain: this.engine.ctxMain,
        rendering: this.rendering,
        lengthScale: this.options.viewport?.lengthScale ?? 1,
        state: this.renderState,
        firstPass: lane.depth === 0
      });
      return;
    }

    const power = lane.ray.powerS + lane.ray.powerP;
    if (
      !this.rayPowerSampling &&
      this.rayPowerCutoff > 0 &&
      power < this.rayPowerCutoff
    ) {
      this.totalTruncation += power;
      this.summary.weakRayCount++;
      lane.active = false;
      this.renderState.lastRay = null;
      this.renderState.lastIntersection = null;
      return;
    }

    const hit = this.traceRay(lane.ray, rayIndex);
    this.hitBuffer[rayIndex] = hit;
    this.hasRenderedOutput = renderCpuRay({
      ray: lane.ray,
      hit,
      renderer: this.engine.canvasRenderer,
      ctxMain: this.engine.ctxMain,
      rendering: this.rendering,
      lengthScale: this.options.viewport?.lengthScale ?? 1,
      state: this.renderState,
      firstPass: lane.depth === 0
    }) || this.hasRenderedOutput;

    const typeIndex = getInteractionTypeIndex(
      this.options.preparedScene.description,
      this.options.preparedScene.interactionTypeLayout,
      hit
    );
    if (typeIndex < 0) {
      if (hit.curveId === TERMINATE_HIT_CURVE_ID) {
        this.totalTruncation += power;
      }
      lane.active = false;
      return;
    }
    if (lane.depth >= this.maxRayDepth) {
      this.totalTruncation += lane.ray.powerS + lane.ray.powerP;
      lane.active = false;
      return;
    }

    const outputs = this.createLocalOutgoingRays(
      typeIndex,
      lane.ray,
      hit,
      lane.depth + 1
    );
    const continuation = outputs.shift();
    if (continuation) {
      lane.ray = continuation;
      lane.depth++;
      lane.active = true;
    } else {
      lane.active = false;
    }
    this.megakernelOutputs[rayIndex].push(...outputs);
  }

  traceRay(ray, rayIndex) {
    const description = this.options.preparedScene.description;
    const regionCount = this.summary.regionCount;
    const maximumDistance = getSmallestPositiveStepSize(
      description,
      ray.membership
    );
    const candidate = createInteractionCandidate(
      regionCount,
      maximumDistance
    );
    this.interactionContext.maximumDistance = maximumDistance;
    traverseBvhForInteraction(
      description,
      ray,
      candidate,
      this.interactionContext
    );
    const finalizedCandidate = finalizeInteractionCandidate(
      candidate,
      this.interactionContext,
      ray
    );
    let warningType = candidate.conflictType;
    if (
      candidate.curveId >= 0 &&
      !finalizedCandidate &&
      warningType === INTERSECTION_CONFLICT_NONE
    ) {
      warningType = INTERSECTION_CONFLICT_NORMAL;
    }
    if (warningType !== INTERSECTION_CONFLICT_NONE) {
      const tolerance = candidate.conflictToleranceKind ===
        INTERSECTION_TOLERANCE_NONE
        ? {
          kind: INTERSECTION_TOLERANCE_NORMAL_CONSTRUCTION,
          value: this.interactionContext.tolerancePolicy.tangent
        }
        : {
          kind: candidate.conflictToleranceKind,
          value: candidate.conflictTolerance
        };
      this.recordWarning(
        warningType,
        candidate.curveId,
        candidate.conflictCurveId,
        ray.powerS + ray.powerP,
        tolerance,
        rayIndex
      );
    }
    if (
      candidate.conflictType === INTERSECTION_CONFLICT_NORMAL ||
      (candidate.curveId >= 0 && !finalizedCandidate)
    ) {
      candidate.curveId = TERMINATE_HIT_CURVE_ID;
      this.summary.normalConflictCount++;
    } else if (candidate.curveId >= 0) {
      this.summary.finiteHitCount++;
    } else if (Number.isFinite(maximumDistance)) {
      this.summary.grinStepCount++;
    } else {
      this.summary.escapingRayCount++;
    }
    this.processedRayCount++;
    return candidate;
  }

  createLocalOutgoingRays(typeIndex, sourceRay, hit, depth) {
    const baseType =
      this.options.preparedScene.interactionTypeLayout.types[typeIndex];
    const type = {
      ...baseType,
      interactionCount: 1,
      destinationRayStart: 0
    };
    const outputs = new Array(type.outRayCount);
    writeCpuOutgoingRays({
      description: this.options.preparedScene.description,
      prepared: this.options.preparedScene.outgoingRayData,
      type,
      localInteractionIndex: 0,
      sourceRay,
      hit,
      destinationRayBuffer: outputs,
      detectorResults: this.detectorResults
    });
    return outputs.filter(isRayActive).map(ray => {
      ray.depth = depth;
      return ray;
    });
  }

  finishMegakernelCycle() {
    for (let rayIndex = 0;
      rayIndex < this.megakernelLanes.length;
      rayIndex++) {
      const lane = this.megakernelLanes[rayIndex];
      if (lane.active) this.megakernelOutputs[rayIndex].push(lane.ray);
    }
    const outputSlots = [];
    const maximumSlotCount = this.megakernelOutputs.reduce(
      (maximum, outputs) => Math.max(maximum, outputs.length),
      0
    );
    for (let slot = 0; slot < maximumSlotCount; slot++) {
      for (const outputs of this.megakernelOutputs) {
        if (outputs[slot]) outputSlots.push(outputs[slot]);
      }
    }
    const sampled = collectRayPowerQueue(
      outputSlots,
      this.rayPowerCutoff,
      ++this.samplingGeneration,
      this.rayPowerSampling
    );
    this.summary.weakRayCount += sampled.weakRayCount;
    this.totalTruncation += sampled.weakRayPower;
    const destination = this.nextRayBuffer;
    destination.length = 0;
    for (const ray of sampled.rays) destination.push(ray);
    this.currentRayBufferIndex = 1 - this.currentRayBufferIndex;
    this.nextRayBuffer.length = 0;
    this.passIndex++;

    if (
      this.currentRayBuffer.length === 0 ||
      this.processedRayCount >= (this.options.rayCountLimit ?? Infinity)
    ) {
      this.completeSimulation();
      return;
    }
    this.beginMegakernelCycle();
  }

  recordWarning(
    type,
    curveId,
    conflictingCurveId,
    power,
    tolerance,
    rayIndex = 0
  ) {
    this.warningState.totalPower += power;
    if (this.warningState.first) return;
    this.warningState.first = {
      type,
      rayIndex,
      curveId,
      conflictingCurveId,
      tolerance: serializeWarningTolerance(tolerance)
    };
  }

  completeSimulation() {
    finishCpuRayRendering({
      renderer: this.engine.canvasRenderer,
      ctxMain: this.engine.ctxMain,
      rendering: this.rendering,
      colorMode: this.options.colorMode ?? 'default'
    });
    this.phase = 'complete';
    this.isComplete = true;
  }

  getUpdate() {
    const warningPower = this.warningState.totalPower;
    const warningThreshold = estimateAmbiguousRayWarningPowerThreshold({
      numericEpsilon: this.engine.numericEpsilon,
      processedRayCount: this.processedRayCount,
      description: this.options.preparedScene.description,
      safetyFactor: this.engine.ambiguousRayWarningSafetyFactor
    });
    const warning = this.warningState.first &&
      warningPower > warningThreshold
      ? {
        ...this.warningState.first,
        ambiguousPower: warningPower
      }
      : null;
    return {
      status:
        this.isCancelled || this.isComplete
          ? 'complete'
          : 'running',
      progress: {
        processedRayCount: this.processedRayCount,
        totalTruncation: this.totalTruncation,
      },
      outputUpdated: this.hasRenderedOutput && !this.isCancelled,
      result: {
        detectors: this.detectorResults,
        processedRayCount: this.processedRayCount,
        totalTruncation: this.totalTruncation,
        warning,
        warningPower,
      },
    };
  }

  cancel() {
    this.isCancelled = true;
  }

  dispose() {
    this.cancel();
  }
}

export function normalizeMaxRayDepth(value) {
  if (!Number.isFinite(value)) return Infinity;
  return Math.max(0, Math.floor(value));
}

function serializeWarningTolerance(tolerance) {
  switch (tolerance.kind) {
    case INTERSECTION_TOLERANCE_MERGING:
      return {
        kind: 'interactionMerging',
        unit: 'sceneUnits',
        value: tolerance.value
      };
    case INTERSECTION_TOLERANCE_NORMAL:
      return {
        kind: 'interactionNormal',
        unit: 'radians',
        value: tolerance.value
      };
    case INTERSECTION_TOLERANCE_NORMAL_CONSTRUCTION:
      return {
        kind: 'normalConstruction',
        unit: 'normalizedMagnitude',
        value: tolerance.value
      };
    default:
      throw new TypeError(
        `Unsupported warning tolerance kind: ${tolerance.kind}`
      );
  }
}

/**
 * CPU primitive simulation engine using the same stable outgoing-ray queue
 * semantics as the WebGPU megakernel implementation.
 */
class CpuSimulationEngine {
  constructor({
    // JavaScript Number arithmetic is binary64. Keep binary64 as the CPU
    // default so this backend can diagnose failures caused by WebGPU f32
    // precision; tests may still supply a different epsilon explicitly.
    numericEpsilon = Number.EPSILON,
    ctxMain = null,
    glMain = null,
    ctxVirtual = null,
    config = {}
  } = {}) {
    this.kind = 'primitiveCpu';
    this.numericEpsilon = validateNumericEpsilon(numericEpsilon);
    this.ctxMain = ctxMain;
    this.glMain = glMain;
    this.ctxVirtual = ctxVirtual;
    this.canvasRenderer = null;
    this.configure(config);
  }

  configure(config = {}) {
    const timeBudgetMs = config.timeBudgetMs ?? 200;
    if (!Number.isFinite(timeBudgetMs) || timeBudgetMs <= 0) {
      throw new RangeError('timeBudgetMs must be positive and finite.');
    }
    this.timeBudgetMs = timeBudgetMs;
    const maxLocalIterations = config.maxLocalIterations ?? 256;
    if (
      !Number.isSafeInteger(maxLocalIterations) ||
      maxLocalIterations <= 0
    ) {
      throw new RangeError(
        'maxLocalIterations must be a positive safe integer.'
      );
    }
    this.maxLocalIterations = maxLocalIterations;
    const ambiguousRayWarningSafetyFactor =
      config.ambiguousRayWarningSafetyFactor ??
      DEFAULT_AMBIGUOUS_RAY_WARNING_SAFETY_FACTOR;
    if (!Number.isFinite(ambiguousRayWarningSafetyFactor) ||
        ambiguousRayWarningSafetyFactor < 0) {
      throw new RangeError(
        'ambiguousRayWarningSafetyFactor must be finite and nonnegative.'
      );
    }
    this.ambiguousRayWarningSafetyFactor =
      ambiguousRayWarningSafetyFactor;
  }

  async prepare(description, {
    violetWavelength,
    redWavelength,
    keepNonVisibleLight = false
  } = {}) {
    const [wavelengthRange] = deriveWebGpuWavelengthRange({
      violetWavelength,
      redWavelength,
      keepNonVisibleLight
    });
    return {
      description,
      wavelengthRange,
      keepNonVisibleLight: Boolean(keepNonVisibleLight),
      interactionTypeLayout:
        createInteractionTypeLayout(description),
      outgoingRayData:
        prepareCpuOutgoingRayData(description),
      sourceEvaluators: description.types.sources.map(type =>
        createDagEvaluator(type.definition.dag, {
          labels: SOURCE_OUTPUT_LABELS
        })
      )
    };
  }

  async createRun(options = {}) {
    return new CpuSimulationRun(this, options);
  }

  beginRenderer({ origin, scale, lengthScale, colorMode }) {
    if (!this.ctxMain && !this.glMain) {
      this.canvasRenderer?.destroy?.();
      this.canvasRenderer = null;
      return null;
    }

    if (colorMode === 'default') {
      this.canvasRenderer?.destroy?.();
      this.canvasRenderer = null;
      if (!this.ctxMain) return null;
      this.canvasRenderer = new CanvasRenderer(
        this.ctxMain,
        origin,
        scale,
        lengthScale,
        null,
        this.ctxVirtual
      );
      return this.canvasRenderer;
    }

    if (!this.glMain) {
      throw new Error('WebGL is unavailable.');
    }

    const canReuseRenderer =
      this.canvasRenderer instanceof FloatColorRenderer &&
      this.canvasRenderer.colorMode === colorMode &&
      this.canvasRenderer.scale === scale &&
      this.canvasRenderer.lengthScale === lengthScale &&
      this.canvasRenderer.origin.x === origin.x &&
      this.canvasRenderer.origin.y === origin.y &&
      this.canvasRenderer.width === this.glMain.canvas.width &&
      this.canvasRenderer.height === this.glMain.canvas.height;

    if (canReuseRenderer) {
      this.canvasRenderer.begin();
    } else {
      this.canvasRenderer?.destroy?.();
      this.canvasRenderer = new FloatColorRenderer(
        this.glMain,
        origin,
        scale,
        lengthScale,
        null,
        null,
        colorMode
      );
    }
    return this.canvasRenderer;
  }

  dispose() {
    this.canvasRenderer?.destroy?.();
    this.canvasRenderer = null;
  }
}

function createInitialRay(
  output,
  regionCount,
  wavelengthRange,
  keepNonVisibleLight = false
) {
  const directionLengthSquared =
    output.d_x * output.d_x + output.d_y * output.d_y;
  const valid =
    Number.isFinite(output.x) &&
    Number.isFinite(output.y) &&
    Number.isFinite(directionLengthSquared) &&
    directionLengthSquared > 0 &&
    Number.isFinite(output.P_s) &&
    output.P_s >= 0 &&
    Number.isFinite(output.P_p) &&
    output.P_p >= 0 &&
    Number.isFinite(output.lambda) &&
    (keepNonVisibleLight
      ? output.lambda > 0
      : output.lambda >= wavelengthRange[0] &&
        output.lambda <= wavelengthRange[1]);
  const powerS = valid ? output.P_s : 0;
  const powerP = valid ? output.P_p : 0;
  return {
    ray: {
      originX: output.x,
      originY: output.y,
      directionX: output.d_x,
      directionY: output.d_y,
      powerS,
      powerP,
      wavelength: output.lambda,
      membership: new Uint8Array(regionCount),
      depth: 0
    },
    invalid: !valid
  };
}

function isRayActive(ray) {
  return ray.powerS !== 0 || ray.powerP !== 0;
}

function getSmallestPositiveStepSize(description, membership) {
  let stepSize = Infinity;
  for (let regionId = 0; regionId < membership.length; regionId++) {
    if (!membership[regionId]) continue;
    const regionStepSize = description.regions[regionId].stepSize;
    if (regionStepSize > 0 && regionStepSize < stepSize) {
      stepSize = regionStepSize;
    }
  }
  return stepSize;
}

function createRenderingOptions(options) {
  const rendering = options.rendering ?? {};
  return {
    mode: rendering.mode ?? 'rays',
    simulateColors: Boolean(rendering.simulateColors),
    showRayArrows: Boolean(rendering.showRayArrows),
    observer: rendering.observer ?? null,
    colorMode: options.colorMode ?? 'default',
    wavelengthToColor:
      rendering.wavelengthToColor ??
      ((_wavelength, brightness) => [1, 1, 1, brightness]),
    getThemeRayColor:
      rendering.getThemeRayColor ??
      ((_rayType, alpha) => [1, 1, 1, alpha]),
    getThemeRayDash:
      rendering.getThemeRayDash ??
      (() => []),
    getThemeImageColor:
      rendering.getThemeImageColor ??
      ((_imageType, alpha) => [1, 1, 1, alpha]),
    getThemeImageSize:
      rendering.getThemeImageSize ??
      (() => 5)
  };
}

function resolveInitialMembership(
  description,
  ray,
  result,
  numericEpsilon,
) {
  let testRay = ray;
  for (let attempt = 0; attempt < MAX_MEMBERSHIP_ATTEMPTS; attempt++) {
    traverseBvhForRegionMembership(
      description,
      testRay,
      result,
      numericEpsilon
    );
    if (result.ambiguousCurveId < 0) {
      return {
        resolved: true,
        attemptCount: attempt + 1
      };
    }
    if (
      attempt + 1 >= MAX_MEMBERSHIP_ATTEMPTS ||
      !Number.isFinite(result.nearestForwardS) ||
      !(result.nearestForwardS > 0)
    ) {
      return {
        resolved: false,
        attemptCount: attempt + 1
      };
    }

    const nextDirectionX =
      GOLDEN_ANGLE_COS * testRay.directionX -
      GOLDEN_ANGLE_SIN * testRay.directionY;
    const nextDirectionY =
      GOLDEN_ANGLE_SIN * testRay.directionX +
      GOLDEN_ANGLE_COS * testRay.directionY;
    testRay = {
      originX:
        testRay.originX +
        0.5 * result.nearestForwardS * testRay.directionX,
      originY:
        testRay.originY +
        0.5 * result.nearestForwardS * testRay.directionY,
      directionX: nextDirectionX,
      directionY: nextDirectionY
    };
  }
  return {
    resolved: false,
    attemptCount: MAX_MEMBERSHIP_ATTEMPTS
  };
}

function getCurrentTime() {
  return typeof performance !== 'undefined' &&
    typeof performance.now === 'function'
    ? performance.now()
    : Date.now();
}

export default CpuSimulationEngine;