Source: core/PrimitiveBasedSimulator.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 i18next from 'i18next';
import { BVH_MAX_GROUP_EXTENT } from './primitive/bvh.js';
import { drawPreparedCurve } from './primitive/drawPreparedCurve.js';
import { preprocessPrimitives } from './primitive/preprocess.js';
import {
  formatPrimitiveCurveReference
} from './primitive/diagnosticReference.js';
import {
  INTERSECTION_CONFLICT_MERGE,
  INTERSECTION_CONFLICT_NORMAL,
  INTERSECTION_CONFLICT_ORIENTATION
} from './primitive/interactionCandidate.js';
import {
  DEFAULT_WEBGPU_WORKLOAD_THRESHOLD,
  getPrimitiveEngineWorkloadScore,
  selectPrimitiveEngineKind,
  summarizePrimitiveWorkload
} from './simulationEngines/primitiveEngineSelection.js';
import {
  DEFAULT_PRIMITIVE_NUMERICAL_TOLERANCES
} from './simulationEngines/config.js';
import {
  getEffectiveRayPowerOptions
} from './simulationEngines/rayPower.js';

const UV_WAVELENGTH = 380;
const VIOLET_WAVELENGTH = 420;
const BLUE_WAVELENGTH = 460;
const CYAN_WAVELENGTH = 500;
const GREEN_WAVELENGTH = 540;
const YELLOW_WAVELENGTH = 580;
const RED_WAVELENGTH = 620;
const INFRARED_WAVELENGTH = 700;
const AUTOMATIC_COMPARISON_MIN_RUNTIME_MS = 50;
const AUTOMATIC_CPU_PREFERENCE_RUNTIME_MS = 20;
const AUTOMATIC_COMPARISON_PAUSE_MS = 50;

function formatPrimitiveEngineWarning(warning) {
  if (!warning.tolerance) return null;
  const warningKey = {
    [INTERSECTION_CONFLICT_MERGE]: 'primitiveMergeConflict',
    [INTERSECTION_CONFLICT_ORIENTATION]: 'primitiveOrientationConflict',
    [INTERSECTION_CONFLICT_NORMAL]: 'primitiveNormalConflict'
  }[warning.type];
  if (!warningKey) {
    throw new Error(
      `Unknown primitive interaction warning type: ${warning.type}`
    );
  }
  return i18next.t(
    `simulator:generalWarnings.${warningKey}`, {
      curveId: formatWarningCurveId(warning.curveId),
      conflictingCurveId: formatWarningCurveId(
        warning.conflictingCurveId
      )
    }
  );
}

function formatWarningCurveId(curveId) {
  return Number.isSafeInteger(curveId) && curveId >= 0
    ? formatPrimitiveCurveReference(curveId)
    : String(curveId)
}

/**
 * Primitive simulator coordinating shared preprocessing, rendering, and a
 * lazily instantiated registry of execution engines.
 */
class PrimitiveBasedSimulator {
  /**
   * @param {Object} options
   * @param {Scene} options.scene
   * @param {CpuSimulationEngine|WebGpuSimulationEngine} [options.engine]
   * @param {Object<string, Function|Object>} [options.engineProviders]
   * @param {'automatic'|'primitiveCpu'|'webgpu'|string} [options.enginePreference]
   * @param {Function} [options.engineSelector]
   * @param {CanvasRenderingContext2D|null} [options.ctxBelowLight]
   * @param {CanvasRenderingContext2D|null} [options.ctxAboveLight]
   * @param {CanvasRenderingContext2D|null} [options.ctxGrid]
   * @param {CanvasRenderingContext2D|null} [options.ctxVirtual]
   * @param {boolean} [options.enableTimer=false]
   * @param {number} [options.rayCountLimit=Infinity]
   * @param {function|null} [options.tempCanvasFactory=null]
   * @param {Object} [options.numericalTolerances]
   */
  constructor({
    scene,
    engine = null,
    engineProviders = null,
    enginePreference = 'automatic',
    engineSelector = selectPrimitiveEngineKind,
    ctxBelowLight = null,
    ctxAboveLight = null,
    ctxGrid = null,
    ctxVirtual = null,
    enableTimer = false,
    rayCountLimit = Infinity,
    tempCanvasFactory = null,
    numericalTolerances = DEFAULT_PRIMITIVE_NUMERICAL_TOLERANCES,
  }) {
    this.scene = scene;
    this.ctxBelowLight = ctxBelowLight;
    this.ctxAboveLight = ctxAboveLight;
    this.ctxGrid = ctxGrid;
    this.ctxVirtual = ctxVirtual;
    this.enableTimer = enableTimer;
    this.rayCountLimit = rayCountLimit;
    this.tempCanvasFactory = tempCanvasFactory;
    this.numericalTolerances = { ...numericalTolerances };

    this.scene.simulator = this;
    this.dpr = 1;
    this.manualLightRedraw = false;
    this.isLightLayerSynced = true;
    this.processedRayCount = 0;
    this.totalTruncation = 0;
    this.brightnessScale = 0;
    this.simulationStartTime = null;
    this.error = null;
    this.warning = null;
    this.preprocessingWarning = null;
    this.engineWarning = null;
    this.engineFallbackWarning = null;
    this.eventListeners = {};

    this.engineProviders = normalizeEngineProviders(engineProviders);
    if (typeof engineSelector !== 'function') {
      throw new TypeError('engineSelector must be a function.');
    }
    this.engineSelector = engineSelector;
    this.engines = new Map();
    if (engine) {
      this.engineProviders.set(engine.kind, {
        create: () => engine,
        isSupported: () => true
      });
      this.engines.set(engine.kind, engine);
      this.enginePreference = engine.kind;
    } else {
      this.enginePreference = enginePreference;
    }
    if (this.engineProviders.size === 0) {
      throw new TypeError('At least one primitive simulation engine is required.');
    }
    this.automaticEngineWinner = null;
    this.workload = summarizePrimitiveWorkload([]);
    const initialEngineKind = this.selectEngineKind(this.workload);
    this.engine = this.getEngine(initialEngineKind);
    this.engineFallbackActive = false;
    this.presentedEngineKind = initialEngineKind;
    this.presentedEngineFallback = false;
    this.canvasRendererBelowLight = null;
    this.canvasRendererAboveLight = null;
    this.canvasRendererGrid = null;

    this.activeRun = null;
    this.silentActiveRun = null;
    this.runGeneration = 0;
    this.runFrameRequest = null;
    this.pendingRunJob = null;
    this.runLoopActive = false;
    this.comparisonTimer = null;
    this.comparisonRunPromise = null;
    this.benchmarkEngines = new Map();
    this.isRunning = false;
    this.simulationStartPending = false;
    this.primitives = [];
    const initialPreprocessing = preprocessPrimitives([], {
      lengthScale: this.scene.lengthScale,
      numericalTolerances: this.numericalTolerances,
      numericEpsilon: this.engine.numericEpsilon
    });
    this.processedScene = initialPreprocessing.processedScene;
    this.detectorResultBindings = initialPreprocessing.detectorResultBindings;
    this.primitiveBvh = this.processedScene.bvh;
  }

  setEnginePreference(preference) {
    if (preference !== 'automatic' && !this.engineProviders.has(preference)) {
      throw new RangeError(`Unknown primitive engine preference: ${preference}`);
    }
    if (preference !== this.enginePreference) {
      this.automaticEngineWinner = null;
    }
    this.enginePreference = preference;
  }

  selectEngineKind(workload) {
    return this.getEngineSelectionDecision(workload).selectedEngineKind;
  }

  selectFormulaEngineKind(workload) {
    const selected = this.engineSelector({
      preference: this.enginePreference,
      workload,
      isAvailable: kind => this.isEngineAvailable(kind),
    });
    if (this.engineProviders.has(selected)) return selected;
    if (this.engineProviders.has('primitiveCpu')) return 'primitiveCpu';
    return this.engineProviders.keys().next().value;
  }

  isAutomaticComparisonEligible() {
    return this.enginePreference === 'automatic' &&
      this.isEngineAvailable('primitiveCpu') &&
      this.isEngineAvailable('webgpu');
  }

  getEngineSelectionDecision(workload) {
    const formulaEngineKind = this.selectFormulaEngineKind(workload);
    const workloadScore = getPrimitiveEngineWorkloadScore(workload);
    const crossover = DEFAULT_WEBGPU_WORKLOAD_THRESHOLD;
    const crossoverRatio = crossover > 0
      ? workloadScore / crossover
      : null;
    const comparisonEligible = this.isAutomaticComparisonEligible();
    const learnedEngineKind = this.enginePreference === 'automatic' &&
      this.isEngineAvailable(this.automaticEngineWinner)
      ? this.automaticEngineWinner
      : null;
    const grinWebGpuFirst = this.enginePreference === 'automatic' &&
      workload?.hasGrinRegion && formulaEngineKind === 'webgpu';
    const selectedEngineKind = grinWebGpuFirst
      ? formulaEngineKind
      : learnedEngineKind ?? formulaEngineKind;
    let reason = 'formula';
    if (grinWebGpuFirst) reason = 'grin-region';
    else if (learnedEngineKind) reason = 'previous-comparison-winner';
    else if (this.enginePreference !== 'automatic') reason = 'forced-preference';
    else if (!comparisonEligible) reason = 'comparison-unavailable';
    return {
      preference: this.enginePreference,
      workload: { ...workload },
      workloadScore,
      crossover,
      crossoverRatio,
      comparisonEligible,
      cpuAvailable: this.isEngineAvailable('primitiveCpu'),
      webGpuAvailable: this.isEngineAvailable('webgpu'),
      formulaEngineKind,
      learnedEngineKind: this.automaticEngineWinner ?? null,
      selectedEngineKind,
      reason,
    };
  }

  isEngineAvailable(kind) {
    const provider = this.engineProviders.get(kind);
    if (!provider) return false;
    try {
      return provider.isSupported?.() !== false;
    } catch (_) {
      return false;
    }
  }

  getEngine(kind) {
    let engine = this.engines.get(kind);
    if (engine) return engine;
    const provider = this.engineProviders.get(kind);
    if (!provider) throw new RangeError(`No primitive engine provider for ${kind}.`);
    engine = provider.create();
    if (!engine || typeof engine.then === 'function' || engine.kind !== kind) {
      throw new TypeError(
        `Primitive engine provider ${kind} must synchronously create that engine.`
      );
    }
    this.engines.set(kind, engine);
    return engine;
  }

  getBenchmarkEngine(kind) {
    let engine = this.benchmarkEngines.get(kind);
    if (engine) return engine;
    const provider = this.engineProviders.get(kind);
    if (!provider) throw new RangeError(`No primitive engine provider for ${kind}.`);
    engine = provider.create({ silent: true });
    if (!engine || typeof engine.then === 'function' || engine.kind !== kind) {
      throw new TypeError(
        `Primitive engine provider ${kind} must synchronously create that engine.`
      );
    }
    // A provider returning its foreground singleton cannot render a silent
    // comparison without corrupting the visible run.
    if (engine === this.engines.get(kind)) {
      throw new Error(`Primitive engine provider ${kind} has no silent engine.`);
    }
    this.benchmarkEngines.set(kind, engine);
    return engine;
  }

  activateEngine(kind, { fallback = false, deferPresentation = false } = {}) {
    this.engine = this.getEngine(kind);
    this.engineFallbackActive = fallback;
    if (!deferPresentation) this.presentEngine(kind, { fallback });
  }

  presentEngine(kind, { fallback = false } = {}) {
    const previousKind = this.presentedEngineKind;
    const previousFallback = this.presentedEngineFallback;
    this.presentedEngineKind = kind;
    this.presentedEngineFallback = fallback;
    if (previousKind !== kind || previousFallback !== fallback) {
      this.emit('engineChange', {
        kind,
        previousKind,
        fallback,
        preference: this.enginePreference,
        workload: this.workload
      });
    }
  }

  on(eventName, callback) {
    if (!this.eventListeners[eventName]) {
      this.eventListeners[eventName] = [];
    }
    this.eventListeners[eventName].push(callback);
  }

  emit(eventName, data) {
    this.eventListeners[eventName]?.forEach(callback => callback(data));
  }

  updateSimulation(skipLight = false, skipGrid = false, forceRedraw = false) {
    this.emit('update', { skipLight, skipGrid, forceRedraw });

    if (!skipLight && this.manualLightRedraw && !forceRedraw) {
      this.isLightLayerSynced = false;
      this.emit('lightLayerSyncChange', { isSynced: false });
    }
    skipLight = skipLight || (this.manualLightRedraw && !forceRedraw);
    if (skipLight) {
      this.drawSceneLayers(skipGrid);
      this.emit('requestUpdateErrorAndWarning');
      return;
    }

    const generation = ++this.runGeneration;
    this.cancelObsoleteWork();
    this.collectAndPreprocessPrimitives();
    this.drawSceneLayers(skipGrid);
    this.processedRayCount = 0;
    this.totalTruncation = 0;
    this.simulationStartTime = new Date();
    this.error = null;
    this.isRunning = true;
    this.simulationStartPending = Boolean(
      this.engine.deferSimulationStartUntilPause
    );
    this.isLightLayerSynced = true;
    this.emit('lightLayerSyncChange', { isSynced: true });
    if (!this.simulationStartPending) this.emit('simulationStart', null);
    this.scheduleRun(this.createRunJob(generation));
  }

  cancelObsoleteWork() {
    this.pendingRunJob = null;
    this.activeRun?.cancel?.();
    this.silentActiveRun?.cancel?.();
    if (this.comparisonTimer !== null) {
      clearTimeout(this.comparisonTimer);
      this.comparisonTimer = null;
    }
  }

  createRunJob(generation, overrides = {}) {
    const engine = overrides.engine ?? this.engine;
    const origin = this.scene.origin ?? { x: 0, y: 0 };
    const rayPowerOptions = getEffectiveRayPowerOptions(this.scene);
    return {
      generation,
      engine,
      engineKind: engine.kind,
      fallback: overrides.fallback ?? this.engineFallbackActive,
      processedScene: overrides.processedScene ?? this.processedScene,
      detectorResultBindings:
        overrides.detectorResultBindings ?? this.detectorResultBindings,
      primitives: overrides.primitives ?? this.primitives.slice(),
      workload: overrides.workload ?? this.workload,
      comparisonEligible: overrides.comparisonEligible ??
        this.isAutomaticComparisonEligible(),
      viewport: {
        origin: {
          x: (origin.x ?? 0) * this.dpr,
          y: (origin.y ?? 0) * this.dpr,
        },
        scale: (this.scene.scale ?? 1) * this.dpr,
        lengthScale: this.scene.lengthScale,
      },
      sceneOptions: {
        violetWavelength: this.scene.violetWavelength,
        redWavelength: this.scene.redWavelength,
        keepNonVisibleLight: this.scene.keepNonVisibleLight,
        colorMode: this.scene.colorMode,
        rayPowerCutoff: rayPowerOptions.rayPowerCutoff,
        rayPowerSampling: rayPowerOptions.rayPowerSampling,
        maxRayDepth: this.scene.maxRayDepth,
        mode: this.scene.mode,
        simulateColors: this.scene.simulateColors,
        showRayArrows: this.scene.showRayArrows,
        observer: this.scene.observer,
        numericalTolerances: { ...this.numericalTolerances },
        lengthScale: this.scene.lengthScale,
      },
    };
  }

  scheduleRun(job) {
    this.pendingRunJob = job;
    if (this.runLoopActive || this.runFrameRequest !== null) return;
    const launch = () => {
      this.runFrameRequest = null;
      this.runPendingRuns();
    };
    if (isWebGpuEngine(job.engine) &&
        typeof requestAnimationFrame === 'function') {
      this.runFrameRequest = requestAnimationFrame(launch);
    } else {
      this.runFrameRequest = true;
      Promise.resolve().then(launch);
    }
  }

  async runPendingRuns() {
    if (this.runLoopActive) return;
    this.runLoopActive = true;
    try {
      while (this.pendingRunJob) {
        const job = this.pendingRunJob;
        this.pendingRunJob = null;
        if (job.generation !== this.runGeneration) continue;
        // Engines and canvases are never shared by overlapping foreground
        // revisions. An update cancels the active revision and replaces the
        // one pending slot with its newest complete snapshot.
        await this.startEngineRun(job);
      }
    } finally {
      this.runLoopActive = false;
      if (this.pendingRunJob) {
        this.scheduleRun(this.pendingRunJob);
      }
    }
  }

  async startEngineRun(job) {
    if (typeof job === 'number') job = this.createRunJob(job);
    let result = null;
    try {
      result = await this.runEngine(job);
    } catch (error) {
      if (job.generation !== this.runGeneration) return;
      if (isWebGpuEngine(job.engine) &&
          this.engineProviders.has('primitiveCpu')) {
        try {
          result = await this.fallbackToCpu(job, error);
        } catch (fallbackError) {
          if (job.generation !== this.runGeneration) return;
          this.activeRun?.dispose?.();
          this.activeRun = null;
          this.error = fallbackError.message ||
            i18next.t('simulator:settings.correctBrightness.error');
        }
      } else {
        this.activeRun?.dispose?.();
        this.activeRun = null;
        this.error = error.message ||
          i18next.t('simulator:settings.correctBrightness.error');
      }
    }
    if (job.generation !== this.runGeneration) return;
    this.completeRun(job.generation);
    if (result?.completed) {
      this.scheduleAutomaticComparison(result.job, result.durationMs);
    }
  }

  async fallbackToCpu(job, webGpuError) {
    // Restart the complete simulation for this scene-update revision. Engine
    // selection never changes within a bounce or megakernel ping-pong.
    this.activeRun?.cancel?.();
    this.activeRun?.dispose?.();
    this.activeRun = null;
    this.engineFallbackWarning = i18next.t(
      'simulator:simulationEngineModal.webgpu.fallback',
      { message: webGpuError.message }
    );
    this.activateEngine('primitiveCpu', {
      fallback: true,
      deferPresentation: true,
    });
    this.preprocessCollectedPrimitives();
    this.refreshWarning();
    if (this.simulationStartPending) {
      this.simulationStartPending = false;
      this.emit('simulationStart', null);
    }
    const fallbackJob = this.createRunJob(job.generation, {
      engine: this.engine,
      fallback: true,
      processedScene: this.processedScene,
      detectorResultBindings: this.detectorResultBindings,
      comparisonEligible: false,
    });
    return this.runEngine(fallbackJob);
  }

  async runEngine(job, {
    silent = false,
    timeLimitMs = Infinity,
  } = {}) {
    if (typeof job === 'number') job = this.createRunJob(job);
    const activeRunKey = silent ? 'silentActiveRun' : 'activeRun';
    const previousRun = this[activeRunKey];
    this[activeRunKey] = null;
    previousRun?.cancel?.();
    previousRun?.dispose?.();

    const isCurrent = () => job.generation === this.runGeneration;
    const preparedScene = await job.engine.prepare(job.processedScene, {
      violetWavelength: job.sceneOptions.violetWavelength,
      redWavelength: job.sceneOptions.redWavelength,
      keepNonVisibleLight: job.sceneOptions.keepNonVisibleLight
    });
    if (!isCurrent()) return { completed: false, job, durationMs: 0 };

    const run = await job.engine.createRun({
      preparedScene,
      isCurrent,
      viewport: job.viewport,
      colorMode: job.sceneOptions.colorMode,
      rayPowerCutoff: job.sceneOptions.rayPowerCutoff,
      rayPowerSampling: job.sceneOptions.rayPowerSampling,
      rayCountLimit: this.rayCountLimit,
      maxRayDepth: job.sceneOptions.maxRayDepth,
      sceneRevision: job.generation,
      rendering: {
        mode: job.sceneOptions.mode,
        simulateColors: job.sceneOptions.simulateColors,
        showRayArrows: job.sceneOptions.showRayArrows,
        observer: job.sceneOptions.observer,
        wavelengthToColor: (wavelength, brightness, transform) =>
          this.wavelengthToColor(wavelength, brightness, transform),
        getThemeRayColor: (rayType, alpha) =>
          this.getThemeRayColor(rayType, alpha),
        getThemeRayDash: rayType => this.getThemeRayDash(rayType),
        getThemeImageColor: (imageType, alpha) =>
          this.getThemeImageColor(imageType, alpha),
        getThemeImageSize: imageType =>
          this.getThemeImageSize(imageType)
      },
    });
    if (!isCurrent()) {
      run.cancel?.();
      run.dispose?.();
      return { completed: false, job, durationMs: 0 };
    }
    this[activeRunKey] = run;

    let update;
    let durationMs = 0;
    try {
      do {
        const remainingMs = Math.max(1, timeLimitMs - durationMs);
        const advanceStart = monotonicNow();
        update = await run.advance({
          timeBudgetMs: silent
            ? Math.min(job.engine.timeBudgetMs ?? 200, remainingMs)
            : this.enableTimer
              ? (job.engine.timeBudgetMs ?? 200)
              : Infinity,
        });
        durationMs += monotonicNow() - advanceStart;
        if (!isCurrent()) {
          run.cancel?.();
          return { completed: false, job, durationMs };
        }

        if (silent) {
          if (update.status !== 'complete' && durationMs >= timeLimitMs) {
            run.cancel?.();
            return { completed: false, slower: true, job, durationMs };
          }
        } else {
          this.publishRunUpdate(update, job.detectorResultBindings);
          this.presentEngine(job.engineKind, { fallback: job.fallback });
          if (update.status !== 'complete') {
            if (this.simulationStartPending) {
              this.simulationStartPending = false;
              this.emit('simulationStart', null);
            }
            this.emit('simulationPause', null);
          }
          this.updateSimulation(true, true);
        }
        if (update.status !== 'complete' && (this.enableTimer || silent)) {
          await new Promise(resolve => setTimeout(resolve, 0));
        }
      } while (update.status !== 'complete');
    } finally {
      run.dispose?.();
      if (this[activeRunKey] === run) this[activeRunKey] = null;
    }
    return { completed: true, job, durationMs };
  }

  scheduleAutomaticComparison(job, firstDurationMs) {
    // A forced engine preference cannot produce or consume an automatic
    // comparison result.
    if (this.enginePreference !== 'automatic') return;
    const challengerKind = job.engineKind === 'webgpu'
      ? 'primitiveCpu'
      : 'webgpu';
    const cpuAvailable = this.isEngineAvailable('primitiveCpu');
    const webGpuAvailable = this.isEngineAvailable('webgpu');
    if (job.generation !== this.runGeneration || job.fallback) return;
    if (!cpuAvailable || !webGpuAvailable) return;
    if (challengerKind === 'primitiveCpu') {
      // In Automatic mode CPU is always tried after WebGPU, even when the
      // WebGPU foreground run is below the normal comparison threshold.
    } else {
      if (!(firstDurationMs > AUTOMATIC_COMPARISON_MIN_RUNTIME_MS)) {
        return;
      }
    }
    this.comparisonTimer = setTimeout(() => {
      this.comparisonTimer = null;
      this.queueAutomaticComparison(job, firstDurationMs, challengerKind);
    }, AUTOMATIC_COMPARISON_PAUSE_MS);
  }

  queueAutomaticComparison(firstJob, firstDurationMs, challengerKind) {
    const previous = this.comparisonRunPromise;
    const current = (async () => {
      try {
        await previous;
      } catch (_) {
        // runAutomaticComparison handles its own failures. Keep the queue
        // moving if an unexpected rejection escaped an older revision.
      }
      if (firstJob.generation !== this.runGeneration) return;
      await this.runAutomaticComparison(
        firstJob,
        firstDurationMs,
        challengerKind
      );
    })();
    this.comparisonRunPromise = current;
    current.finally(() => {
      if (this.comparisonRunPromise === current) {
        this.comparisonRunPromise = null;
      }
    });
  }

  async runAutomaticComparison(firstJob, firstDurationMs, challengerKind) {
    if (firstJob.generation !== this.runGeneration ||
        this.isRunning ||
        this.enginePreference !== 'automatic') return;
    try {
      const engine = this.getBenchmarkEngine(challengerKind);
      this.resizeBenchmarkEngineOutput(engine);
      const preprocessing = preprocessPrimitives(firstJob.primitives, {
        lengthScale: firstJob.sceneOptions.lengthScale,
        numericalTolerances: firstJob.sceneOptions.numericalTolerances,
        numericEpsilon: engine.numericEpsilon,
        bvhOptions: {
          maxGroupExtent:
            BVH_MAX_GROUP_EXTENT *
            firstJob.sceneOptions.lengthScale
        }
      });
      if (firstJob.generation !== this.runGeneration) return;
      const challengerJob = {
        ...firstJob,
        engine,
        engineKind: challengerKind,
        fallback: false,
        comparisonEligible: false,
        processedScene: preprocessing.processedScene,
        detectorResultBindings: preprocessing.detectorResultBindings,
      };
      const challengerTimeLimitMs = challengerKind === 'primitiveCpu'
        ? Math.max(
          firstDurationMs,
          AUTOMATIC_CPU_PREFERENCE_RUNTIME_MS
        )
        : firstDurationMs;
      const result = await this.runEngine(challengerJob, {
        silent: true,
        timeLimitMs: challengerTimeLimitMs,
      });
      if (firstJob.generation !== this.runGeneration) return;
      const cpuPreferredForResponsiveness =
        challengerKind === 'primitiveCpu' &&
        result.completed &&
        result.durationMs < AUTOMATIC_CPU_PREFERENCE_RUNTIME_MS;
      const challengerWon = result.completed &&
        (result.durationMs < firstDurationMs ||
          cpuPreferredForResponsiveness);
      this.automaticEngineWinner = challengerWon
        ? challengerKind
        : firstJob.engineKind;
    } catch (_) {
      if (firstJob.generation !== this.runGeneration) return;
      // A silent comparison must not become a user-visible simulation error.
      // Keep the successful foreground engine when the challenger is unusable.
      this.automaticEngineWinner = firstJob.engineKind;
    }
  }

  resizeBenchmarkEngineOutput(engine) {
    const referenceCanvas = this.ctxAboveLight?.canvas ??
      this.ctxBelowLight?.canvas;
    if (!referenceCanvas) return;
    const { width, height } = referenceCanvas;
    for (const canvas of [
      engine.ctxMain?.canvas,
      engine.glMain?.canvas,
      engine.ctxVirtual?.canvas,
    ]) {
      if (!canvas) continue;
      if (canvas.width !== width) canvas.width = width;
      if (canvas.height !== height) canvas.height = height;
    }
    engine.output?.resize?.(width, height);
  }

  publishRunUpdate(update, detectorResultBindings = this.detectorResultBindings) {
    this.processedRayCount =
      update.progress?.processedRayCount ?? this.processedRayCount;
    this.totalTruncation =
      update.progress?.totalTruncation ?? this.totalTruncation;
    const detectorResults = update.result?.detectors ?? [];
    for (const binding of detectorResultBindings) {
      const values = detectorResults[binding.resultId];
      if (values) binding.result.values = values;
    }
    if (update.result?.warning) {
      this.engineWarning = update.result.warning;
      this.refreshWarning();
    }
  }

  drawSceneLayers(skipGrid) {
    const origin = { x: this.scene.origin.x * this.dpr, y: this.scene.origin.y * this.dpr };
    const scale = this.scene.scale * this.dpr;

    if (this.ctxBelowLight) {
      this.canvasRendererBelowLight = new CanvasRenderer(
        this.ctxBelowLight,
        origin,
        scale,
        this.scene.lengthScale,
        this.scene.backgroundImage
      );
    }

    if (this.ctxAboveLight) {
      this.canvasRendererAboveLight = new CanvasRenderer(
        this.ctxAboveLight,
        origin,
        scale,
        this.scene.lengthScale
      );
    }

    if (!skipGrid && this.ctxGrid) {
      this.canvasRendererGrid = new CanvasRenderer(
        this.ctxGrid,
        origin,
        scale,
        this.scene.lengthScale
      );
      this.drawGrid();
    }

    const sortedObjs = this.scene.objs
      .map((obj, index) => ({ obj, index, zIndex: obj.getZIndex() }))
      .sort((a, b) => a.zIndex - b.zIndex);

    if (this.canvasRendererBelowLight) {
      for (const { obj, index } of sortedObjs) {
        const isHighlighted = this.scene.editor?.isObjHighlighted(index) || false;
        obj.draw(this.canvasRendererBelowLight, false, isHighlighted);
      }
    }

    if (this.canvasRendererAboveLight) {
      for (const { obj, index } of sortedObjs) {
        const isHighlighted = this.scene.editor?.isObjHighlighted(index) || false;
        obj.draw(this.canvasRendererAboveLight, true, isHighlighted);
      }
      this.drawExternalHighlightPrimitiveCurves(
        this.canvasRendererAboveLight
      );
      this.drawExternalHighlightPoints(this.canvasRendererAboveLight);
      this.drawObserver();
    }
  }

  collectAndPreprocessPrimitives() {
    const primitives = [];
    let brightnessScale = 0;
    for (const obj of this.scene.opticalObjs) {
      const objPrimitives = obj.getPrimitives();
      if (Array.isArray(objPrimitives)) {
        primitives.push(...objPrimitives);
      }
      if (this.scene.colorMode !== 'default' || !obj.brightnessScale) {
        continue;
      }
      if (brightnessScale === 0) {
        brightnessScale = obj.brightnessScale;
      } else if (brightnessScale !== obj.brightnessScale) {
        brightnessScale = -1;
      }
    }
    this.brightnessScale = this.scene.colorMode === 'default'
      ? brightnessScale
      : 0;
    const hasDetector = primitives.some(
      primitive => primitive?.kind === 'detector'
    );
    this.preprocessingWarning = this.brightnessScale === -1 &&
      (hasDetector || this.scene.simulateColors)
      ? i18next.t('simulator:generalWarnings.brightnessInconsistent')
      : null;
    this.engineWarning = null;
    this.engineFallbackWarning = null;
    this.refreshWarning();

    this.primitives = primitives;
    this.workload = summarizePrimitiveWorkload(primitives);
    const selectionDecision = this.getEngineSelectionDecision(this.workload);
    this.activateEngine(selectionDecision.selectedEngineKind, {
      fallback: false,
      deferPresentation: true,
    });
    this.preprocessCollectedPrimitives();
  }

  preprocessCollectedPrimitives() {
    const {
      processedScene,
      detectorResultBindings
    } = preprocessPrimitives(this.primitives, {
      lengthScale: this.scene.lengthScale,
      numericalTolerances: this.numericalTolerances,
      numericEpsilon: this.engine.numericEpsilon,
      bvhOptions: {
        maxGroupExtent:
          BVH_MAX_GROUP_EXTENT *
          this.scene.lengthScale
      }
    });
    this.processedScene = processedScene;
    this.detectorResultBindings = detectorResultBindings;
    this.primitiveBvh = processedScene.bvh;
  }

  refreshWarning() {
    const warnings = [this.preprocessingWarning];
    if (this.engineFallbackWarning) {
      warnings.push(this.engineFallbackWarning);
    }
    if (this.engineWarning) {
      warnings.push(formatPrimitiveEngineWarning(this.engineWarning));
    }
    this.warning = warnings.filter(Boolean).join('<br>');
    if (!this.warning) this.warning = null;
  }

  drawGrid() {
    if (!this.scene.showGrid) return;

    const ctx = this.ctxGrid;
    ctx.save();
    ctx.setTransform(this.scene.scale * this.dpr, 0, 0, this.scene.scale * this.dpr, 0, 0);
    let dashPattern = this.scene.theme.grid.dash.map(value => value * this.scene.lengthScale);
    const dashPeriod = dashPattern.reduce((a, b) => a + b, 0);
    ctx.strokeStyle = `rgba(${Math.round(this.scene.theme.grid.color.r * 255)}, ${Math.round(this.scene.theme.grid.color.g * 255)}, ${Math.round(this.scene.theme.grid.color.b * 255)}, ${this.scene.theme.grid.color.a})`;
    ctx.lineWidth = this.scene.theme.grid.width * this.scene.lengthScale;
    if (dashPeriod * this.scene.scale <= 2) dashPattern = [];
    ctx.setLineDash(dashPattern);
    ctx.beginPath();

    for (let x = this.scene.origin.x / this.scene.scale % this.scene.gridSize; x <= ctx.canvas.width / (this.scene.scale * this.dpr); x += this.scene.gridSize) {
      ctx.moveTo(x, this.scene.origin.y / this.scene.scale % this.scene.gridSize - this.scene.gridSize);
      ctx.lineTo(x, ctx.canvas.height / (this.scene.scale * this.dpr));
    }
    for (let y = this.scene.origin.y / this.scene.scale % this.scene.gridSize; y <= ctx.canvas.height / (this.scene.scale * this.dpr); y += this.scene.gridSize) {
      ctx.moveTo(this.scene.origin.x / this.scene.scale % this.scene.gridSize - this.scene.gridSize, y);
      ctx.lineTo(ctx.canvas.width / (this.scene.scale * this.dpr), y);
    }
    ctx.stroke();
    ctx.restore();
  }

  drawObserver() {
    if (this.scene.mode !== 'observer' || !this.ctxAboveLight || !this.scene.observer) return;

    const ctx = this.ctxAboveLight;
    ctx.globalAlpha = 1;
    ctx.beginPath();
    ctx.fillStyle = `rgb(${Math.round(this.scene.theme.observer.color.r * 255)}, ${Math.round(this.scene.theme.observer.color.g * 255)}, ${Math.round(this.scene.theme.observer.color.b * 255)})`;
    ctx.arc(this.scene.observer.c.x, this.scene.observer.c.y, this.scene.observer.r, 0, Math.PI * 2);
    ctx.fill();
  }

  drawExternalHighlightPoints(canvasRenderer) {
    const points = this.scene.editor?.externalHighlightPoints;
    if (!points?.length) return;

    const ctx = canvasRenderer.ctx;
    const lengthScale = canvasRenderer.lengthScale;
    ctx.save();
    ctx.globalAlpha = 1;
    ctx.lineWidth = 1.5 * lengthScale;
    ctx.strokeStyle = this.scene.highlightColorCss;
    ctx.setLineDash([2.1 * lengthScale, 2.1 * lengthScale]);
    for (const point of points) {
      if (!Number.isFinite(point?.x) || !Number.isFinite(point?.y)) continue;
      ctx.beginPath();
      ctx.arc(point.x, point.y, 4 * lengthScale, 0, Math.PI * 2);
      ctx.stroke();
    }
    ctx.restore();
  }

  drawExternalHighlightPrimitiveCurves(canvasRenderer) {
    const curveIds =
      this.scene.editor?.externalHighlightPrimitiveCurveIds;
    if (!canvasRenderer?.ctx || !curveIds?.length) return;

    for (const curveId of curveIds) {
      const geometry = this.processedScene?.curves[curveId]?.geometry;
      if (!geometry) continue;
      drawPreparedCurve(
        canvasRenderer,
        geometry,
        this.scene.highlightColor,
        3
      );
    }
  }

  completeRun(generation) {
    if (generation !== this.runGeneration) return;
    this.isRunning = false;
    this.simulationStartPending = false;
    this.emit('requestUpdateErrorAndWarning');
    this.emit('simulationComplete', null);
  }

  manualRedrawLightLayer() {
    this.updateSimulation(false, true, true);
  }

  stopSimulation() {
    const wasRunning = this.isRunning;
    this.runGeneration++;
    this.pendingRunJob = null;
    if (
      this.runFrameRequest !== null &&
      this.runFrameRequest !== true &&
      typeof cancelAnimationFrame === 'function'
    ) cancelAnimationFrame(this.runFrameRequest);
    this.runFrameRequest = null;
    if (this.comparisonTimer !== null) clearTimeout(this.comparisonTimer);
    this.comparisonTimer = null;
    this.activeRun?.cancel?.();
    this.activeRun?.dispose?.();
    this.activeRun = null;
    this.silentActiveRun?.cancel?.();
    this.silentActiveRun?.dispose?.();
    this.silentActiveRun = null;
    this.isRunning = false;
    this.simulationStartPending = false;
    if (wasRunning) this.emit('simulationStop', null);
  }

  destroy() {
    this.stopSimulation();
    for (const engine of new Set(this.engines.values())) {
      engine.dispose?.();
    }
    for (const engine of new Set(this.benchmarkEngines.values())) {
      engine.dispose?.();
    }
    this.engines.clear();
    this.benchmarkEngines.clear();
    this.eventListeners = {};
  }

  createTempCanvas(width, height) {
    if (this.tempCanvasFactory) return this.tempCanvasFactory(width, height);
    if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height);
    if (typeof document !== 'undefined') {
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      return canvas;
    }
    return null;
  }

  wavelengthToColor(wavelength, brightness, transform) {
    let r;
    let g;
    let b;
    let spectralIntensity;

    const sceneViolet = Number.isFinite(this.scene.violetWavelength)
      ? this.scene.violetWavelength
      : VIOLET_WAVELENGTH;
    const sceneRed = Number.isFinite(this.scene.redWavelength)
      ? this.scene.redWavelength
      : RED_WAVELENGTH;
    const targetViolet = sceneRed > sceneViolet ? sceneViolet : VIOLET_WAVELENGTH;
    const targetRed = sceneRed > sceneViolet ? sceneRed : RED_WAVELENGTH;
    const wavelengthScale =
      (targetRed - targetViolet) / (RED_WAVELENGTH - VIOLET_WAVELENGTH);
    const scaleWavelength = value =>
      targetViolet + (value - VIOLET_WAVELENGTH) * wavelengthScale;

    const uvWavelength = scaleWavelength(UV_WAVELENGTH);
    const blueWavelength = scaleWavelength(BLUE_WAVELENGTH);
    const cyanWavelength = scaleWavelength(CYAN_WAVELENGTH);
    const greenWavelength = scaleWavelength(GREEN_WAVELENGTH);
    const yellowWavelength = scaleWavelength(YELLOW_WAVELENGTH);
    const infraredWavelength = scaleWavelength(INFRARED_WAVELENGTH);

    const keepNonVisibleLight = Boolean(
      this.scene.keepNonVisibleLight &&
      Number.isFinite(wavelength) &&
      wavelength > 0
    );

    if ((keepNonVisibleLight && wavelength < targetViolet) ||
        (wavelength >= uvWavelength && wavelength < targetViolet)) {
      r = 0.5;
      g = 0;
      b = 1;
    } else if (wavelength >= targetViolet && wavelength < blueWavelength) {
      r = -0.5 * (wavelength - blueWavelength) / (blueWavelength - targetViolet);
      g = 0;
      b = 1;
    } else if (wavelength >= blueWavelength && wavelength < cyanWavelength) {
      r = 0;
      g = (wavelength - blueWavelength) / (cyanWavelength - blueWavelength);
      b = 1;
    } else if (wavelength >= cyanWavelength && wavelength < greenWavelength) {
      r = 0;
      g = 1;
      b = -(wavelength - greenWavelength) / (greenWavelength - cyanWavelength);
    } else if (wavelength >= greenWavelength && wavelength < yellowWavelength) {
      r = (wavelength - greenWavelength) / (yellowWavelength - greenWavelength);
      g = 1;
      b = 0;
    } else if (wavelength >= yellowWavelength && wavelength < targetRed) {
      r = 1;
      g = -(wavelength - targetRed) / (targetRed - yellowWavelength);
      b = 0;
    } else if (wavelength >= targetRed &&
               (keepNonVisibleLight || wavelength <= infraredWavelength)) {
      r = 1;
      g = 0;
      b = 0;
    } else {
      r = 0;
      g = 0;
      b = 0;
    }

    const fadeLimit = keepNonVisibleLight ? 0.25 : 0;
    if (wavelength > infraredWavelength || wavelength < uvWavelength) {
      spectralIntensity = fadeLimit;
    } else if (wavelength > targetRed) {
      spectralIntensity = keepNonVisibleLight
        ? 1 - (1 - fadeLimit) *
          (wavelength - targetRed) / (infraredWavelength - targetRed)
        : (infraredWavelength - wavelength) /
          (infraredWavelength - targetRed);
    } else if (wavelength < targetViolet) {
      spectralIntensity = keepNonVisibleLight
        ? 1 - (1 - fadeLimit) *
          (targetViolet - wavelength) / (targetViolet - uvWavelength)
        : (wavelength - uvWavelength) /
          (targetViolet - uvWavelength);
    } else {
      spectralIntensity = 1;
    }

    r *= spectralIntensity * brightness;
    g *= spectralIntensity * brightness;
    b *= spectralIntensity * brightness;

    if (transform) {
      r = 1 - Math.exp(-r);
      g = 1 - Math.exp(-g);
      b = 1 - Math.exp(-b);
    }

    return [r, g, b, 1];
  }

  getThemeRayColor(rayType, alpha) {
    const color = this.scene.theme[rayType]?.color || this.scene.theme.ray.color;
    return [color.r, color.g, color.b, alpha];
  }

  getThemeRayDash(rayType) {
    return this.scene.theme[rayType]?.dash || this.scene.theme.ray.dash;
  }

  getThemeImageColor(imageType, alpha) {
    const color = this.scene.theme[imageType]?.color || this.scene.theme.realImage.color;

    // This avoids excessive accumulation for the default gray virtual-object
    // color when the floating-point light renderer is in use.
    if (
      imageType === 'virtualObject' &&
      color.r === 0.3 &&
      color.g === 0.3 &&
      color.b === 0.3 &&
      this.scene.colorMode !== 'default'
    ) {
      return [0.03, 0.03, 0.03, alpha];
    }

    return [color.r, color.g, color.b, alpha];
  }

  getThemeImageSize(imageType) {
    return this.scene.theme[imageType]?.size || this.scene.theme.realImage.size || 5;
  }
}

function isWebGpuEngine(engine) {
  return engine?.kind === 'webgpu';
}

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

function normalizeEngineProviders(providers) {
  const result = new Map();
  const entries = providers instanceof Map
    ? providers.entries()
    : Object.entries(providers ?? {});
  for (const [kind, provider] of entries) {
    if (typeof provider === 'function') {
      result.set(kind, { create: provider, isSupported: () => true });
      continue;
    }
    if (provider && typeof provider.create === 'function') {
      result.set(kind, provider);
      continue;
    }
    throw new TypeError(`Primitive engine provider ${kind} requires create().`);
  }
  return result;
}

export default PrimitiveBasedSimulator;