/*
* 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 { buildBvh } from './bvh.js';
import {
DegenerateCurveError,
prepareCurve
} from './curveGeometry.js';
import { collectParameterNames } from '../formula/dag-util.js';
import { validateNumericEpsilon } from './numeric.js';
/**
* A canonical, engine-independent type definition.
*
* The definition is a deeply frozen plain-data copy of the type supplied by a
* scene object. `hash` is an efficient lookup key, not the definition's
* identity: preprocessing also compares the complete canonical representation
* when hashes match.
*
* @typedef {Object} ProcessedType
* @property {string} hash - A deterministic structural hash.
* @property {Object} definition - The canonical type definition.
*/
/**
* A source instance. The engine decides whether to use the parameter object
* directly or pack it according to the corresponding type's `paramNames`.
*
* @typedef {Object} ProcessedSource
* @property {number} sourceTypeId - Index into `types.sources`.
* @property {Object<string, number>} params - Source instance parameters.
* @property {number} rayCount - Number of source-formula invocations.
*/
/**
* A surface instance. Its geometry is stored separately in `curves`.
*
* @typedef {Object} ProcessedSurface
* @property {number} surfaceTypeId - Index into `types.surfaces`.
* @property {Object<string, number>} params - Surface instance parameters.
*/
/**
* A bulk-region instance. All of its boundary curves point back to this
* record through their `ownerId`.
*
* @typedef {Object} ProcessedRegion
* @property {number} bulkTypeId - Index into `types.bulks`.
* @property {Object<string, number>} params - Bulk instance parameters.
* @property {number} stepSize - Interior propagation step size.
* @property {boolean} partialReflect - Whether transmissible boundaries also reflect.
*/
/**
* A detector instance. `resultId` identifies primitives contributing to the
* same logical scene-object result without prescribing an engine memory
* layout.
*
* @typedef {Object} ProcessedDetector
* @property {number} detectorTypeId - Index into `types.detectors`.
* @property {Object<string, number>} params - Detector instance parameters.
* @property {number} resultId - Index into the simulator-side detector result bindings.
* @property {number} resultSize - Number of result scalars.
*/
/**
* An intersection curve and its owning processed instance. Surface and
* detector intersection policy is kept here because it is needed while
* traversing the BVH, before formula evaluation. Region curves do not have
* sidedness or wavelength filters.
*
* @typedef {Object} ProcessedCurve
* @property {PreparedCurveGeometry} geometry - Prepared engine-independent curve geometry.
* @property {{minX: number, minY: number, maxX: number, maxY: number}} bounds - Conservative world-space bounds, including the engine-selected positional and endpoint tolerances.
* @property {'surface'|'region'|'detector'} ownerKind - Owner table kind.
* @property {number} ownerId - Index into the matching owner table.
* @property {boolean} mergesWithBoundary - Whether this curve can participate in an interaction with coincident region boundaries.
* @property {boolean} [twoSided] - Whether both oriented sides participate.
* @property {WavelengthFilter} [filter] - Optional pre-intersection wavelength filter.
*/
/**
* The BVH node array uses up to four child node indices for branches. A leaf
* has `count > 0`; its `[start, start + count)` range indexes `curveIds`, whose
* values index the stable `curves` table. Branches are stored before leaves so
* GPU packing can omit standalone leaf records. The BVH may therefore reorder
* curves without changing curve or owner IDs. Every node's `ownerKindMask` is
* the bitwise union of the surface, region, and detector kinds in its subtree.
*
* @typedef {Object} ProcessedBvh
* @property {number} root - Root node index, or -1 for an empty tree.
* @property {Array<Object>} nodes - BVH4 branch and leaf nodes.
* @property {Uint32Array} curveIds - Curve IDs in BVH leaf order.
*/
/**
* A complete engine-independent scene snapshot.
*
* Type IDs are local to their category and deterministic for a given set of
* structural definitions. Instance parameters remain plain objects in this
* common representation. Each engine may use those objects directly or pack
* the values in the corresponding type's significant `paramNames` order.
*
* The type signature changes when any canonical type definition changes,
* independent of primitive encounter order. It can be compared with the
* previous snapshot to decide whether compiled engine code remains reusable.
* Runtime parameter values, curves, and detector result sizes do not affect
* this signature.
*
* This object contains only transferable plain data. In particular, it does
* not contain the mutable detector result holders owned by scene objects.
*
* @typedef {Object} ProcessedScene
* @property {number} numericEpsilon - Relative arithmetic epsilon selected by the engine for geometry preparation and intersection.
* @property {{curveEndpoint: number, interactionMerging: number, interactionNormal: number, forwardDistance: number}} numericalTolerances - Engine-ready tolerance minimums. Distance values are in world units and `interactionNormal` is in radians.
* @property {string} typeSignature - Structural signature of all four type tables.
* @property {{sources: ProcessedType[], surfaces: ProcessedType[], bulks: ProcessedType[], detectors: ProcessedType[]}} types
* @property {ProcessedSource[]} sources
* @property {ProcessedSurface[]} surfaces
* @property {ProcessedRegion[]} regions
* @property {ProcessedDetector[]} detectors
* @property {ProcessedCurve[]} curves
* @property {ProcessedBvh} bvh
*/
/**
* Simulator-side association between a logical detector result range and the
* mutable holder supplied by one or more detector primitives. These bindings
* are deliberately returned beside, rather than inside, `processedScene`.
*
* @typedef {Object} DetectorResultBinding
* @property {number} resultId - Logical detector result ID used by processed detector instances.
* @property {DetectorResult} result - Scene-object-owned mutable holder.
* @property {number} resultSize - Number of scalars in the logical result.
*/
/**
* Convert scene-object primitives into an engine-independent processed scene.
* Degenerate curves are discarded. A surface or detector owning one is
* discarded with it, while a region is retained if any boundary remains.
*
* @param {Primitive[]} primitives - Primitives collected in scene order.
* @param {Object} [options]
* @param {Object} [options.bvhOptions] - Options forwarded to {@link buildBvh}.
* @param {number} [options.lengthScale=1] - Natural scene length used by engine-selected curve tolerances.
* @param {Object} [options.numericalTolerances] - Simulator-configured tolerance minimums, with distances relative to the scene length scale.
* @param {number} options.numericEpsilon - Relative arithmetic epsilon selected by the engine.
* @returns {{processedScene: ProcessedScene, detectorResultBindings: DetectorResultBinding[]}}
*/
export function preprocessPrimitives(primitives, {
bvhOptions = {},
lengthScale = 1,
numericalTolerances = {},
numericEpsilon
} = {}) {
if (!Array.isArray(primitives)) {
throw new TypeError('primitives must be an array.');
}
validateNumericEpsilon(numericEpsilon);
const resolvedNumericalTolerances = {
curveEndpoint: resolveToleranceMinimum(
numericalTolerances.curveEndpoint,
lengthScale,
'curveEndpoint'
),
interactionMerging: resolveToleranceMinimum(
numericalTolerances.interactionMerging,
lengthScale,
'interactionMerging'
),
interactionNormal: resolveToleranceMinimum(
numericalTolerances.interactionNormal,
1,
'interactionNormal'
),
forwardDistance: resolveToleranceMinimum(
numericalTolerances.forwardDistance,
lengthScale,
'forwardDistance'
)
};
const registries = {
sources: new TypeRegistry(),
surfaces: new TypeRegistry(),
bulks: new TypeRegistry(),
detectors: new TypeRegistry()
};
const sources = [];
const surfaces = [];
const regions = [];
const detectors = [];
const curves = [];
const curveBounds = [];
const detectorResults = new Map();
const detectorResultBindings = [];
const preparePrimitiveCurve = (curve, curvePath) => {
const normalizedCurve = normalizePrimitiveCurve(
curve,
curvePath
);
try {
return prepareCurve(normalizedCurve, {
lengthScale,
endpointTolerance: resolvedNumericalTolerances.curveEndpoint,
numericEpsilon
});
} catch (error) {
if (error instanceof DegenerateCurveError) return null;
throw error;
}
};
const appendProcessedCurve = (
prepared,
ownerKind,
ownerId,
mergesWithBoundary,
twoSided,
filter
) => {
curves.push(createProcessedCurve(
prepared.geometry,
prepared.bounds,
ownerKind,
ownerId,
mergesWithBoundary,
twoSided,
filter
));
curveBounds.push(prepared.bounds);
};
for (let primitiveIndex = 0; primitiveIndex < primitives.length; primitiveIndex++) {
const primitive = primitives[primitiveIndex];
switch (primitive.kind) {
case 'source': {
const typeRecord = registries.sources.register(
primitive.sourceType
);
sources.push({
typeRecord,
params: normalizeNumericStrings(
primitive.params,
`primitives[${primitiveIndex}].params`
),
rayCount: normalizeNumericString(
primitive.rayCount,
`primitives[${primitiveIndex}].rayCount`
)
});
break;
}
case 'surface': {
const prepared = preparePrimitiveCurve(
primitive.curve,
`primitives[${primitiveIndex}].curve`
);
if (!prepared) break;
const typeRecord = registries.surfaces.register(
primitive.surfaceType
);
const ownerId = surfaces.length;
surfaces.push({
typeRecord,
params: normalizeNumericStrings(
primitive.params,
`primitives[${primitiveIndex}].params`
)
});
appendProcessedCurve(
prepared,
'surface',
ownerId,
primitive.surfaceType.mergesWithBoundary,
primitive.twoSided,
normalizeNumericStrings(
primitive.filter,
`primitives[${primitiveIndex}].filter`
)
);
break;
}
case 'region': {
const preparedCurves = [];
for (let curveIndex = 0; curveIndex < primitive.curves.length; curveIndex++) {
const prepared = preparePrimitiveCurve(
primitive.curves[curveIndex],
`primitives[${primitiveIndex}].curves[${curveIndex}]`
);
if (prepared) {
preparedCurves.push(prepared);
}
}
if (preparedCurves.length === 0) break;
const typeRecord = registries.bulks.register(
primitive.bulkType
);
const ownerId = regions.length;
regions.push({
typeRecord,
params: normalizeNumericStrings(
primitive.params,
`primitives[${primitiveIndex}].params`
),
stepSize: normalizeNumericString(
primitive.stepSize,
`primitives[${primitiveIndex}].stepSize`
),
partialReflect: primitive.partialReflect
});
for (const prepared of preparedCurves) {
appendProcessedCurve(
prepared,
'region',
ownerId,
true,
undefined,
undefined
);
}
break;
}
case 'detector': {
const prepared = preparePrimitiveCurve(
primitive.curve,
`primitives[${primitiveIndex}].curve`
);
if (!prepared) break;
validateDetectorTypeContract(
primitive.detectorType,
primitiveIndex
);
const typeRecord = registries.detectors.register(
primitive.detectorType
);
const resultSize = normalizeNumericString(
primitive.resultSize,
`primitives[${primitiveIndex}].resultSize`
);
let resultRange = detectorResults.get(primitive.result);
if (resultRange) {
if (resultRange.resultSize !== resultSize) {
throw new RangeError(
`primitives[${primitiveIndex}].resultSize does not match the other primitives using the same result holder.`
);
}
} else {
resultRange = {
resultId: detectorResultBindings.length,
resultSize
};
detectorResults.set(primitive.result, resultRange);
detectorResultBindings.push({
resultId: resultRange.resultId,
result: primitive.result,
resultSize: resultRange.resultSize
});
}
const ownerId = detectors.length;
detectors.push({
typeRecord,
params: normalizeNumericStrings(
primitive.params,
`primitives[${primitiveIndex}].params`
),
...resultRange
});
appendProcessedCurve(
prepared,
'detector',
ownerId,
false,
primitive.twoSided,
undefined
);
break;
}
default:
throw new TypeError(
`Unsupported primitive kind at primitives[${primitiveIndex}]: ${JSON.stringify(primitive.kind)}`
);
}
}
const finalizedTypes = {
sources: registries.sources.finalize(),
surfaces: registries.surfaces.finalize(),
bulks: registries.bulks.finalize(),
detectors: registries.detectors.finalize()
};
const processedSources = sources.map(({ typeRecord, ...source }) => ({
sourceTypeId: typeRecord.id,
...source
}));
const processedSurfaces = surfaces.map(({ typeRecord, ...surface }) => ({
surfaceTypeId: typeRecord.id,
...surface
}));
const processedRegions = regions.map(({ typeRecord, ...region }) => ({
bulkTypeId: typeRecord.id,
...region
}));
const processedDetectors = detectors.map(({ typeRecord, ...detector }) => ({
detectorTypeId: typeRecord.id,
...detector
}));
const builtBvh = buildBvh(
curves.map((curveRecord, curveId) => ({
geometry: curveRecord.geometry,
bounds: curveBounds[curveId],
curveId,
ownerKind: curveRecord.ownerKind
})),
bvhOptions
);
const bvh = {
root: builtBvh.root,
nodes: builtBvh.nodes,
curveIds: Uint32Array.from(builtBvh.entries.map(entry => entry.curveId))
};
const typeSignatureSource = stableSerialize([
registries.sources.signaturePart,
registries.surfaces.signaturePart,
registries.bulks.signaturePart,
registries.detectors.signaturePart
]);
return {
processedScene: {
numericEpsilon,
numericalTolerances: resolvedNumericalTolerances,
typeSignature: hashCanonicalString(typeSignatureSource),
types: finalizedTypes,
sources: processedSources,
surfaces: processedSurfaces,
regions: processedRegions,
detectors: processedDetectors,
curves,
bvh
},
detectorResultBindings
};
}
/**
* Convert numeric strings left by older scene data without mutating primitive
* objects. Primitive parameter contracts contain only numbers, including the
* nested point objects used by curve geometry.
*/
function normalizeNumericStrings(value, path) {
if (typeof value === 'string') {
return normalizeNumericString(value, path);
}
if (!value || typeof value !== 'object') {
return value;
}
let normalized = value;
const keys = Object.keys(value);
for (const key of keys) {
const child = normalizeNumericStrings(
value[key],
Array.isArray(value) ? `${path}[${key}]` : `${path}.${key}`
);
if (Object.is(child, value[key])) continue;
if (normalized === value) {
normalized = Array.isArray(value) ? [...value] : { ...value };
}
normalized[key] = child;
}
return normalized;
}
function normalizeNumericString(value, path) {
if (typeof value !== 'string') return value;
if (value.trim() === '') {
throw new TypeError(`${path} must be numeric, but received an empty string.`);
}
const numericValue = Number(value);
if (Number.isNaN(numericValue)) {
throw new TypeError(`${path} must be numeric, but received ${JSON.stringify(value)}.`);
}
return numericValue;
}
function normalizePrimitiveCurve(curve, path) {
const params = normalizeNumericStrings(curve.params, `${path}.params`);
return params === curve.params ? curve : { ...curve, params };
}
function validateDetectorTypeContract(detectorType, primitiveIndex) {
const forbidden = new Set(['n_0', 'n_1']);
const declaredNames = detectorType?.paramNames ?? [];
const referencedNames = collectParameterNames(detectorType?.dag);
for (const name of forbidden) {
if (declaredNames.includes(name) || referencedNames.has(name)) {
throw new TypeError(
`primitives[${primitiveIndex}].detectorType must not declare or reference ${JSON.stringify(name)}.`
);
}
}
}
function resolveToleranceMinimum(value, scale, name) {
const resolvedValue = value ?? 0;
if (!Number.isFinite(resolvedValue) || resolvedValue < 0) {
throw new RangeError(
`numericalTolerances.${name} must be a finite nonnegative number.`
);
}
return resolvedValue * scale;
}
class TypeRegistry {
constructor() {
this.identityRecords = new WeakMap();
this.hashBuckets = new Map();
this.records = [];
this.signaturePart = '';
}
register(definition) {
const identityRecord = this.identityRecords.get(definition);
if (identityRecord) return identityRecord;
const canonicalDefinition = clonePlainData(definition);
const canonicalKey = stableSerialize(canonicalDefinition);
const hash = hashCanonicalString(canonicalKey);
const bucket = this.hashBuckets.get(hash) || [];
let record = bucket.find(candidate => candidate.canonicalKey === canonicalKey);
if (!record) {
record = {
hash,
canonicalKey,
definition: deepFreeze(canonicalDefinition),
id: -1
};
bucket.push(record);
this.hashBuckets.set(hash, bucket);
this.records.push(record);
}
this.identityRecords.set(definition, record);
return record;
}
finalize() {
this.records.sort((a, b) =>
a.hash.localeCompare(b.hash) || a.canonicalKey.localeCompare(b.canonicalKey)
);
this.records.forEach((record, id) => {
record.id = id;
});
this.signaturePart = stableSerialize(
this.records.map(record => [record.hash, record.canonicalKey])
);
return this.records.map(record => ({
hash: record.hash,
definition: record.definition
}));
}
}
function createProcessedCurve(
geometry,
bounds,
ownerKind,
ownerId,
mergesWithBoundary,
twoSided,
filter
) {
const processedCurve = {
geometry,
bounds,
ownerKind,
ownerId,
mergesWithBoundary
};
if (ownerKind !== 'region') {
processedCurve.twoSided = twoSided;
}
if (filter !== undefined && filter !== null) {
processedCurve.filter = filter;
}
return processedCurve;
}
function clonePlainData(value) {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'boolean' ||
typeof value === 'undefined'
) {
return value;
}
if (typeof value === 'number') {
return value;
}
if (Array.isArray(value)) {
return value.map(clonePlainData);
}
const clone = {};
for (const key of Object.keys(value)) {
clone[key] = clonePlainData(value[key]);
}
return clone;
}
function stableSerialize(value) {
if (value === undefined) return 'u';
if (value === null) return 'n';
if (typeof value === 'boolean') return value ? 'b1' : 'b0';
if (typeof value === 'string') return `s${JSON.stringify(value)}`;
if (typeof value === 'number') {
if (Number.isNaN(value)) return 'dNaN';
if (value === Infinity) return 'dInfinity';
if (value === -Infinity) return 'd-Infinity';
if (Object.is(value, -0)) return 'd-0';
return `d${String(value)}`;
}
if (Array.isArray(value)) {
return `a[${value.map(stableSerialize).join(',')}]`;
}
return `o{${Object.keys(value)
.sort()
.map(key => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
.join(',')}}`;
}
function hashCanonicalString(value) {
let first = 0x811c9dc5;
let second = 0x9e3779b9;
for (let index = 0; index < value.length; index++) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 0x01000193);
second = Math.imul(second ^ code, 0x85ebca6b);
}
return (
(first >>> 0).toString(16).padStart(8, '0') +
(second >>> 0).toString(16).padStart(8, '0')
);
}
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const child of Object.values(value)) {
deepFreeze(child);
}
return value;
}