diff --git a/apps/acoustic-camera/model.js b/apps/acoustic-camera/model.js index 498d048..cc640f9 100644 --- a/apps/acoustic-camera/model.js +++ b/apps/acoustic-camera/model.js @@ -62,10 +62,30 @@ export function shiftSceneDistance(scene, experiment, nextDistance) { const previousDistance = experiment.scan.z; const delta = nextDistance - previousDistance; experiment.scan.z = nextDistance; - for (const source of scene.sources) source.position[2] += delta; + for (const source of scene.sources) { + source.position[2] += delta; + for (const keyframe of source.trajectory ?? []) keyframe.position[2] += delta; + } if (scene.image?.plane) scene.image.plane.z += delta; } +function sourceGeometryPositions(scene) { + return (scene.sources ?? []).flatMap((source) => { + const candidates = [source.position, ...(source.trajectory ?? []).map(({ position }) => position)]; + const uniquePositions = new Map(); + for (const position of candidates) { + if ( + Array.isArray(position) && + position.length === 3 && + position.every(Number.isFinite) + ) { + uniquePositions.set(position.join(","), position); + } + } + return [...uniquePositions.values()]; + }); +} + export function alignExperimentToScene(experiment, scene) { const plane = scene.image?.plane; if (plane) { @@ -79,13 +99,7 @@ export function alignExperimentToScene(experiment, scene) { return "image-plane"; } - const positions = (scene.sources ?? []) - .map((source) => source.position) - .filter((position) => - Array.isArray(position) && - position.length === 3 && - position.every(Number.isFinite), - ); + const positions = sourceGeometryPositions(scene); if (positions.length === 0) return "unchanged"; const paddedBounds = (values) => { diff --git a/tests/unit/camera.test.js b/tests/unit/camera.test.js index 0c08985..e324587 100644 --- a/tests/unit/camera.test.js +++ b/tests/unit/camera.test.js @@ -249,7 +249,14 @@ test("Camera workload counts unique overlapping FFT bins", () => { test("Camera distance shift keeps the scene and scan on one z plane", () => { const scene = { sources: [ - { id: "one", position: [0.1, -0.2, 2] }, + { + id: "one", + position: [0.1, -0.2, 2], + trajectory: [ + { time: 0, position: [0.1, -0.2, 2] }, + { time: 1, position: [0.6, 0.3, 2.5] }, + ], + }, { id: "two", position: [-0.3, 0.4, 2.5] }, ], image: { plane: { z: 2, xMin: -1, xMax: 1, yMin: -1, yMax: 1 } }, @@ -260,6 +267,16 @@ test("Camera distance shift keeps the scene and scan on one z plane", () => { assertEqual(experiment.scan.z, 3.25, "scan z"); assertEqual(scene.sources[0].position.join(","), "0.1,-0.2,3.25", "first source z shift"); + assertEqual( + scene.sources[0].trajectory[0].position.join(","), + "0.1,-0.2,3.25", + "first trajectory keyframe z shift", + ); + assertEqual( + scene.sources[0].trajectory[1].position.join(","), + "0.6,0.3,3.75", + "later trajectory keyframe retains relative depth", + ); assertEqual(scene.sources[1].position.join(","), "-0.3,0.4,3.75", "relative source depth retained"); assertEqual(scene.image.plane.z, 3.25, "background image plane z shift"); }); @@ -302,6 +319,26 @@ test("Camera aligns an experiment to an imported scene without changing grid res assertNear(experiment.scan.yMax, 0.45, 1e-12, "source y maximum with padding"); assertEqual(experiment.scan.columns, 32, "columns retained after source fallback"); assertEqual(experiment.scan.rows, 24, "rows retained after source fallback"); + + const movingScene = { + sources: [ + { + id: "moving", + position: [-2, -3, 1], + trajectory: [ + { time: 0, position: [-2, -3, 1] }, + { time: 1, position: [4, 5, 9] }, + ], + }, + { id: "fixed", position: [0, 1, 5] }, + ], + }; + assertEqual(alignExperimentToScene(experiment, movingScene), "source-bounds", "trajectory fallback"); + assertEqual(experiment.scan.z, 5, "trajectory depths contribute to median scan depth"); + assertNear(experiment.scan.xMin, -2.9, 1e-12, "trajectory x minimum with padding"); + assertNear(experiment.scan.xMax, 4.9, 1e-12, "trajectory x maximum with padding"); + assertNear(experiment.scan.yMin, -4.2, 1e-12, "trajectory y minimum with padding"); + assertNear(experiment.scan.yMax, 6.2, 1e-12, "trajectory y maximum with padding"); }); test("Camera result document is schema-safe and linked to its snapshot", async () => {