/*
* 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.
*/
/**
* Get a value from an object by a dot-separated key path.
* Numeric segments are treated as array indices.
* @param {Object} obj - The object to read from (root).
* @param {string} path - Dot-separated path (e.g. 'focalLength', 'path.0', 'params.r1').
* Empty string returns the root object.
* @param {Object} [defaults] - Optional defaults. When value is undefined, returns getByKeyPath(defaults, path).
* @returns {*} The value at the path, or undefined (or default) if any segment is missing.
*/
const FOR_IF_DEFAULTS = { for: [], if: true };
export function getForIfDefault(key) {
return key in FOR_IF_DEFAULTS ? FOR_IF_DEFAULTS[key] : undefined;
}
export function getByKeyPath(obj, path, defaults) {
if (path === '') {
return obj;
}
const segments = path.split('.');
const lastSeg = segments[segments.length - 1];
let current = obj;
for (const seg of segments) {
if (current == null) {
if (defaults != null) {
return getByKeyPath(defaults, path);
}
const forIfDef = getForIfDefault(lastSeg);
return forIfDef !== undefined ? forIfDef : undefined;
}
const num = Number(seg);
const key = Number.isNaN(num) ? seg : num;
current = current[key];
}
if (current === undefined && defaults != null) {
return getByKeyPath(defaults, path);
}
if (current === undefined) {
const forIfDef = getForIfDefault(lastSeg);
return forIfDef !== undefined ? forIfDef : undefined;
}
return current;
}
/**
* Returns true when `arr` looks like a module-expanded list: at least one element is a
* non-null object with a numeric `_sourceIndex` (see ModuleObj.expandArray).
* @param {Array} arr
* @returns {boolean}
*/
export function isSourceIndexArray(arr) {
if (!Array.isArray(arr) || arr.length === 0) {
return false;
}
return arr.some(
(item) =>
item != null &&
typeof item === 'object' &&
typeof item._sourceIndex === 'number'
);
}
/**
* Read values along a dot-separated key path, returning an array of results (one per branch).
* Like {@link getByKeyPath}, but when a numeric segment follows an array that
* {@link isSourceIndexArray} marks as source-indexed, the segment selects every element whose
* `_sourceIndex` equals that number (not the array index). Remaining segments are applied
* to each branch; the final return is the list of leaf values (possibly empty).
* @param {Object} obj - Root object.
* @param {string} path - Dot-separated path (e.g. `expanded.2.x`). Empty string returns `[obj]`.
* @returns {Array} All values at the path; `[]` if no branches match or all branches hit null.
*/
export function getAllByKeyPath(obj, path) {
if (path === '') {
return [obj];
}
const segments = path.split('.');
let batch = [obj];
for (const seg of segments) {
const num = Number(seg);
const key = Number.isNaN(num) ? seg : num;
const nextBatch = [];
for (const v of batch) {
if (v == null) {
continue;
}
if (Array.isArray(v)) {
if (typeof key === 'number' && isSourceIndexArray(v)) {
for (const item of v) {
if (
item != null &&
typeof item === 'object' &&
item._sourceIndex === key
) {
nextBatch.push(item);
}
}
} else {
nextBatch.push(v[key]);
}
} else if (typeof v === 'object') {
nextBatch.push(v[key]);
} else {
nextBatch.push(undefined);
}
}
batch = nextBatch;
}
return batch;
}
/**
* Set a value in an object by a dot-separated key path.
* Creates intermediate objects/arrays as needed.
* When a parent is undefined (using default), materializes it from defaults before modifying.
* @param {Object} obj - The object to mutate (root).
* @param {string} path - Dot-separated path (e.g. 'focalLength', 'path.0', 'params.r1').
* Empty string is not valid for set (would replace root).
* @param {*} value - The value to set.
* @param {Object} [defaults] - Optional defaults. When an intermediate is null/undefined, materializes from defaults first.
* When the value being set matches the default (from `defaults` or the built-in for/if defaults), the property is deleted instead of set.
*/
export function setByKeyPath(obj, path, value, defaults) {
if (path === '') {
throw new Error('keyPath: empty path is not valid for setByKeyPath');
}
const segments = path.split('.');
let current = obj;
let partialPath = '';
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const num = Number(seg);
const key = Number.isNaN(num) ? seg : num;
const nextKey = segments[i + 1];
const nextNum = Number(nextKey);
const isNextArray = !Number.isNaN(nextNum);
if (current[key] == null) {
if (defaults != null) {
const nextPartial = partialPath ? `${partialPath}.${seg}` : seg;
const defVal = getByKeyPath(defaults, nextPartial);
if (defVal != null) {
current[key] = JSON.parse(JSON.stringify(defVal));
} else {
current[key] = isNextArray ? [] : {};
}
} else {
current[key] = isNextArray ? [] : {};
}
}
partialPath = partialPath ? `${partialPath}.${seg}` : seg;
current = current[key];
}
const lastSeg = segments[segments.length - 1];
const lastNum = Number(lastSeg);
const lastKey = Number.isNaN(lastNum) ? lastSeg : lastNum;
let isDefault = false;
const forIfDef = getForIfDefault(lastSeg);
if (forIfDef !== undefined && JSON.stringify(value) === JSON.stringify(forIfDef)) {
isDefault = true;
} else if (defaults != null) {
const defVal = getByKeyPath(defaults, path);
if (defVal !== undefined && JSON.stringify(value) === JSON.stringify(defVal)) {
isDefault = true;
}
}
if (isDefault && current != null && typeof current === 'object' && !Array.isArray(current)) {
delete current[lastKey];
} else {
current[lastKey] = value;
}
}
const RESERVED_KEYS = new Set(['for', 'if']);
/**
* Format a dot-separated key path into a more familiar bracket notation.
* Numeric segments become array indices: path.1.x → path[1].x
* Reserved JS keywords (for, if) use bracket notation: obj.if → obj["if"]
* @param {string} path - Dot-separated key path.
* @returns {string} The formatted path.
*/
export function formatKeyPath(path) {
if (!path) return '';
const segments = path.split('.');
let result = '';
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const num = Number(seg);
if (!Number.isNaN(num)) {
result += `[${seg}]`;
} else if (RESERVED_KEYS.has(seg)) {
result += `["${seg}"]`;
} else {
result += (i === 0 ? '' : '.') + seg;
}
}
return result;
}
/**
* Check whether a property is "non-basic" (always shown in partial view).
* Non-basic: points, equations, and arrays of objects (arrays whose itemSchema
* is not just a single number or string). Styles are basic.
* @param {Object} descriptor - PropertyDescriptor.
* @returns {boolean} True if the property is non-basic.
*/
function isNonBasicProperty(descriptor) {
const t = descriptor?.type;
if (t === 'point' || t === 'equation') {
return true;
}
if (t === 'array') {
const itemSchema = descriptor?.itemSchema;
if (!Array.isArray(itemSchema) || itemSchema.length === 0) {
return true;
}
const isSimplePrimitiveArray =
itemSchema.length === 1 &&
(itemSchema[0]?.type === 'number' || itemSchema[0]?.type === 'text');
return !isSimplePrimitiveArray;
}
return false;
}
/**
* Check whether the value at the given descriptor key path differs from the default.
* Non-basic properties (points, equations, arrays of objects) are always treated as non-default.
* Basic properties use serializableDefaults for comparison.
* @param {Object} objData - Raw/serialized object data (plain object with type and properties; never a class instance).
* @param {Object} descriptor - PropertyDescriptor with a `key` property (dot-separated path).
* @param {Object} serializableDefaults - The default values structure (e.g. from constructor.serializableDefaults).
* @param {string} [basePath=''] - Optional base path when used in nested contexts (e.g. array items).
* @returns {boolean} True if the value is different from the default (or if non-basic, always true).
*/
export function isNonDefault(objData, descriptor, serializableDefaults, basePath = '') {
if (isNonBasicProperty(descriptor)) {
return true;
}
const key = descriptor?.key;
if (key == null) {
return false;
}
const fullPath = [basePath, key].filter(Boolean).join('.');
const current = getByKeyPath(objData, fullPath, serializableDefaults);
const def = getByKeyPath(serializableDefaults, fullPath);
return JSON.stringify(current) !== JSON.stringify(def);
}