function copyPosition(position) { if (!Array.isArray(position) || position.length !== 3 || !position.every(Number.isFinite)) { throw new TypeError("source position must be a finite [x, y, z] array."); } return [position[0], position[1], position[2]]; } function requireFiniteTime(time, label = "time") { if (!Number.isFinite(time)) throw new TypeError(label + " must be finite."); } /** * Resolve a source position at an absolute time measured from the beginning of * the scene. Valid trajectories are ordered by the scene contract. Values * outside their covered interval hold the nearest endpoint position. */ export function sourcePositionAtTime(source, time) { requireFiniteTime(time); const trajectory = source?.trajectory; if (!Array.isArray(trajectory) || trajectory.length === 0) { return copyPosition(source?.position); } const first = trajectory[0]; const last = trajectory[trajectory.length - 1]; if (time <= first.time) return copyPosition(first.position); if (time >= last.time) return copyPosition(last.position); let low = 0; let high = trajectory.length - 1; while (high - low > 1) { const middle = (low + high) >> 1; if (trajectory[middle].time <= time) low = middle; else high = middle; } const left = trajectory[low]; const right = trajectory[high]; const ratio = (time - left.time) / (right.time - left.time); return left.position.map((coordinate, index) => coordinate + (right.position[index] - coordinate) * ratio, ); } /** * Return a new shallow scene/source graph whose source positions are resolved * at one instant. Trajectories stay attached so callers can sample the returned * scene again; the input scene and source objects are never mutated. */ export function sceneAtTime(scene, time) { requireFiniteTime(time); if (!scene || !Array.isArray(scene.sources)) { throw new TypeError("scene.sources must be an array."); } return { ...scene, sources: scene.sources.map((source) => ({ ...source, position: sourcePositionAtTime(source, time), })), }; } /** * Approximate a moving scene as stationary during [startTime, endTime] by * sampling every trajectory at the window midpoint. */ export function sampleQuasiStaticWindow(scene, startTime, endTime) { requireFiniteTime(startTime, "startTime"); requireFiniteTime(endTime, "endTime"); if (startTime < 0 || endTime <= startTime) { throw new RangeError("window must satisfy 0 <= startTime < endTime."); } if (Number.isFinite(scene?.duration) && endTime > scene.duration) { throw new RangeError("window endTime must not exceed scene.duration."); } const sampleTime = startTime + (endTime - startTime) / 2; return { startTime, endTime, sampleTime, duration: endTime - startTime, scene: sceneAtTime(scene, sampleTime), }; } /** * Split a scene interval into quasi-static windows. The final window is * shortened to the requested end time instead of being dropped. */ export function createQuasiStaticWindows(scene, options = {}) { const startTime = options.startTime ?? 0; const endTime = options.endTime ?? scene?.duration; const windowDuration = options.windowDuration; const hopDuration = options.hopDuration ?? windowDuration; requireFiniteTime(startTime, "startTime"); requireFiniteTime(endTime, "endTime"); requireFiniteTime(windowDuration, "windowDuration"); requireFiniteTime(hopDuration, "hopDuration"); if (startTime < 0 || endTime <= startTime) { throw new RangeError("interval must satisfy 0 <= startTime < endTime."); } if (windowDuration <= 0 || hopDuration <= 0) { throw new RangeError("windowDuration and hopDuration must be positive."); } if (Number.isFinite(scene?.duration) && endTime > scene.duration) { throw new RangeError("endTime must not exceed scene.duration."); } const windows = []; for (let windowStart = startTime; windowStart < endTime; windowStart += hopDuration) { const windowEnd = Math.min(windowStart + windowDuration, endTime); windows.push(sampleQuasiStaticWindow(scene, windowStart, windowEnd)); } return windows; }