From 7643ba3dd61a504244cc81734778b323ae667e14 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:03:33 -0600 Subject: [PATCH 1/3] Establish explicit viewer dataset presentation lifecycle --- viewer/README.md | 6 ++ viewer/src/colony-viewer.ts | 9 +- viewer/src/main.ts | 83 +++++++++------- viewer/src/presentation-state.ts | 67 +++++++++++++ viewer/tests/presentation-state.test.ts | 121 ++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 36 deletions(-) create mode 100644 viewer/src/presentation-state.ts create mode 100644 viewer/tests/presentation-state.test.ts diff --git a/viewer/README.md b/viewer/README.md index 1eea13e..80b8478 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -62,3 +62,9 @@ pnpm --dir viewer build ``` The unit suite includes a Python-authored scene fixture whose digest contains floating-point values that ordinary Python and JavaScript JSON serializers spell differently. Passing that test is the cross-language integrity gate. + +## Dataset presentation lifecycle + +Opening a scene file, live session, or recording begins a new dataset. Call `DatasetPresentationState.beginDataset()` and `ColonyViewer.beginDataset()` once, then present its first frame with `setFrame(frame, true)` to fit the camera. Ordinary updates, a reset of the same live model, and recording seeks use `setFrame(frame)` without beginning a dataset. Neither simulation time returning to zero nor a changed signal-grid shape identifies a new dataset. + +`DatasetPresentationState.datasetId` scopes numerical channel identities; display labels are not identities. Retained preferences are separate from the effective values returned by `forFrame()`. Temporarily absent channels or smaller grids use valid display indices without erasing the user's selections, signal visibility, or chosen slice. The first available signal grid initializes a default slice once. Feature-specific display state should reset only in the explicit `newDataset` block in `presentScene()`. diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 0a3ff8a..4c3038c 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -194,7 +194,14 @@ export class ColonyViewer { }); } - public setFrame(frame: SceneFrame, fit = true): void { + /** Call once when opening a file, live session, or recording. */ + public beginDataset(): void { + this.cancelCameraTransition(); + this.selectCell(null); + } + + /** Frame updates, including reset/seek, retain camera and dataset state. */ + public setFrame(frame: SceneFrame, fit = false): void { this.viewCube.setVisible(true); disposeGroup(this.colony); this.cellMeshes = []; diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 901b4e9..1b69adb 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -3,6 +3,7 @@ import "./style.css"; import { mapCellColors, type ColorMode } from "./color"; import { ColonyViewer } from "./colony-viewer"; import { signalSlice, sliceDimension, type SliceAxis } from "./grid"; +import { DatasetPresentationState } from "./presentation-state"; import { LiveConnection, type LiveConnectionState, @@ -72,6 +73,7 @@ let liveConnected = false; let livePlaying = false; let liveCheckpointEnabled = false; let liveConnection: LiveConnection | null = null; +const presentation = new DatasetPresentationState(); const viewer = new ColonyViewer(canvasHost, viewCubeElement, updateSelection); @@ -104,8 +106,8 @@ function options( select: HTMLSelectElement, count: number, prefix: string, + selected: number, ): void { - const previous = selectedInteger(select); select.replaceChildren(); for (let index = 0; index < count; index += 1) { const option = document.createElement("option"); @@ -113,7 +115,7 @@ function options( option.textContent = `${prefix} ${index}`; select.append(option); } - select.value = String(Math.min(previous, Math.max(count - 1, 0))); + select.value = String(selected); } function selectedInteger( @@ -215,26 +217,21 @@ function updateSelection(cell: SceneCell | null): void { } } -function sameShape( - previous: SceneFrame["signalGrid"], - next: SceneFrame["signalGrid"], -): boolean { - return ( - previous !== null && - next !== null && - previous.signalCount === next.signalCount && - previous.shape.every((value, index) => value === next.shape[index]) - ); -} - function presentScene( next: SceneFrame, label: string, - { fit = true, announce = true }: { fit?: boolean; announce?: boolean } = {}, + { + newDataset = false, + announce = true, + }: { newDataset?: boolean; announce?: boolean } = {}, ): void { - const previous = frame; + if (newDataset) { + presentation.beginDataset(); + viewer.beginDataset(); + } + const display = presentation.forFrame(next); frame = next; - viewer.setFrame(next, fit); + viewer.setFrame(next, newDataset); fitButton.disabled = false; colorMode.disabled = false; emptyState.hidden = true; @@ -248,10 +245,8 @@ function presentScene( gridShape.textContent = next.signalGrid === null ? "None" : next.signalGrid.shape.join(" × "); - options(speciesChannel, next.speciesCount, "Channel"); - if (next.speciesCount === 0 && colorMode.value === "species") { - colorMode.value = "cell-type"; - } + colorMode.value = display.colorMode; + options(speciesChannel, next.speciesCount, "Channel", display.speciesChannel); const speciesOption = colorMode.querySelector( 'option[value="species"]', ); @@ -260,15 +255,19 @@ function presentScene( } signalSection.hidden = next.signalGrid === null; + signalVisible.checked = display.signalVisible; + signalAxis.value = display.signalAxis; if (next.signalGrid !== null) { - options(signalChannel, next.signalGrid.signalCount, "Channel"); - if (!sameShape(previous?.signalGrid ?? null, next.signalGrid)) { - signalVisible.checked = true; - signalAxis.value = "z"; - signalRange.value = String( - Math.floor((next.signalGrid.shape[2] - 1) / 2), - ); - } + options( + signalChannel, + next.signalGrid.signalCount, + "Channel", + display.signalChannel, + ); + signalRange.max = String( + sliceDimension(next.signalGrid, display.signalAxis) - 1, + ); + signalRange.value = String(display.signalSlice); updateSignalRange(); } updateColors(); @@ -288,7 +287,7 @@ async function loadFile(file: File): Promise { } try { const next = await parseScene(await file.text()); - presentScene(next, file.name); + presentScene(next, file.name, { newDataset: true }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setStatus(message, "error"); @@ -305,15 +304,29 @@ fileInput.addEventListener("change", () => { fitButton.addEventListener("click", () => viewer.fitColony()); clearSelection.addEventListener("click", () => viewer.selectCell(null)); -colorMode.addEventListener("change", updateColors); -speciesChannel.addEventListener("change", updateColors); -signalVisible.addEventListener("change", updateSignal); -signalChannel.addEventListener("change", updateSignal); +colorMode.addEventListener("change", () => { + presentation.preferences.colorMode = colorMode.value as ColorMode; + updateColors(); +}); +speciesChannel.addEventListener("change", () => { + presentation.preferences.speciesChannel = selectedInteger(speciesChannel); + updateColors(); +}); +signalVisible.addEventListener("change", () => { + presentation.preferences.signalVisible = signalVisible.checked; + updateSignal(); +}); +signalChannel.addEventListener("change", () => { + presentation.preferences.signalChannel = selectedInteger(signalChannel); + updateSignal(); +}); signalAxis.addEventListener("change", () => { + presentation.preferences.signalAxis = signalAxis.value as SliceAxis; updateSignalRange(); updateSignal(); }); signalRange.addEventListener("input", () => { + presentation.preferences.signalSlice = selectedInteger(signalRange); sliceValue.value = signalRange.value; updateSignal(); }); @@ -387,7 +400,7 @@ function liveFrame(message: LiveFrameMessage): void { liveLabel.textContent = message.playing ? "Running" : "Paused"; } presentScene(message.frame, "live simulation", { - fit: first, + newDataset: first, announce: first, }); updateLiveControls(); diff --git a/viewer/src/presentation-state.ts b/viewer/src/presentation-state.ts new file mode 100644 index 0000000..4dd9e61 --- /dev/null +++ b/viewer/src/presentation-state.ts @@ -0,0 +1,67 @@ +import type { ColorMode } from "./color"; +import { sliceDimension, type SliceAxis } from "./grid"; +import type { SceneFrame } from "./scene"; + +export interface PresentationPreferences { + colorMode: ColorMode; + speciesChannel: number; + signalVisible: boolean; + signalChannel: number; + signalAxis: SliceAxis; + signalSlice: number | null; +} + +function defaults(): PresentationPreferences { + return { + colorMode: "cell-type", + speciesChannel: 0, + signalVisible: true, + signalChannel: 0, + signalAxis: "z", + signalSlice: null, + }; +} + +/** One identity per explicit file/session/recording open, never per frame. */ +export class DatasetPresentationState { + public datasetId = 0; + public preferences = defaults(); + + public beginDataset(): void { + this.datasetId += 1; + this.preferences = defaults(); + } + + /** Clamp only the displayed values, retaining choices for later frames. */ + public forFrame(frame: SceneFrame): PresentationPreferences & { + signalSlice: number; + } { + const grid = frame.signalGrid; + if (grid !== null && this.preferences.signalSlice === null) { + this.preferences.signalSlice = Math.floor( + (sliceDimension(grid, this.preferences.signalAxis) - 1) / 2, + ); + } + return { + ...this.preferences, + colorMode: + frame.speciesCount === 0 && this.preferences.colorMode === "species" + ? "cell-type" + : this.preferences.colorMode, + speciesChannel: Math.min( + this.preferences.speciesChannel, + Math.max(frame.speciesCount - 1, 0), + ), + signalChannel: Math.min( + this.preferences.signalChannel, + Math.max((grid?.signalCount ?? 0) - 1, 0), + ), + signalSlice: Math.min( + this.preferences.signalSlice ?? 0, + grid === null + ? 0 + : sliceDimension(grid, this.preferences.signalAxis) - 1, + ), + }; + } +} diff --git a/viewer/tests/presentation-state.test.ts b/viewer/tests/presentation-state.test.ts new file mode 100644 index 0000000..c6dbe86 --- /dev/null +++ b/viewer/tests/presentation-state.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { DatasetPresentationState } from "../src/presentation-state"; +import type { SceneFrame, SceneSignalGrid } from "../src/scene"; + +const boundary = { kind: "no_flux" as const, values: [] }; +const grid: SceneSignalGrid = { + signalCount: 3, + shape: [5, 7, 9], + origin: [0, 0, 0], + spacing: [1, 1, 1], + boundaries: { + xLower: boundary, + xUpper: boundary, + yLower: boundary, + yUpper: boundary, + zLower: boundary, + zUpper: boundary, + }, + levels: [], +}; +const frame: SceneFrame = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 3, + cells: [], + constraints: { planes: [], spheres: [], boxes: [], cylinders: [] }, + signalGrid: grid, +}; + +describe("dataset presentation lifecycle", () => { + it("resets defaults only on an explicit new dataset", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + expect(state.datasetId).toBe(1); + expect(state.forFrame(frame).signalSlice).toBe(4); + Object.assign(state.preferences, { + colorMode: "growth-rate", + signalVisible: false, + signalAxis: "x", + signalSlice: 3, + }); + for (const time of [1, 100, 0, 20, 2]) { + expect(state.forFrame({ ...frame, time })).toMatchObject({ + colorMode: "growth-rate", + signalVisible: false, + signalAxis: "x", + signalSlice: 3, + }); + expect(state.datasetId).toBe(1); + } + state.beginDataset(); + expect(state.datasetId).toBe(2); + expect(state.forFrame(frame)).toEqual({ + colorMode: "cell-type", + speciesChannel: 0, + signalVisible: true, + signalChannel: 0, + signalAxis: "z", + signalSlice: 4, + }); + }); + + it("retains channel and slice choices through absent or smaller grids", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + Object.assign(state.preferences, { + colorMode: "species", + speciesChannel: 2, + signalChannel: 2, + signalVisible: false, + signalAxis: "y", + signalSlice: 6, + }); + expect( + state.forFrame({ ...frame, speciesCount: 0, signalGrid: null }), + ).toMatchObject({ + colorMode: "cell-type", + speciesChannel: 0, + signalChannel: 0, + signalSlice: 0, + }); + expect( + state.forFrame({ + ...frame, + speciesCount: 1, + signalGrid: { ...grid, signalCount: 1, shape: [2, 2, 2] }, + }), + ).toMatchObject({ + colorMode: "species", + speciesChannel: 0, + signalChannel: 0, + signalSlice: 1, + }); + expect(state.forFrame(frame)).toMatchObject({ + colorMode: "species", + speciesChannel: 2, + signalChannel: 2, + signalVisible: false, + signalAxis: "y", + signalSlice: 6, + }); + }); + + it("initializes the slice once when an initially missing grid arrives", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + state.forFrame({ ...frame, signalGrid: null }); + expect(state.preferences.signalSlice).toBeNull(); + expect(state.forFrame(frame).signalSlice).toBe(4); + expect( + state.forFrame({ ...frame, signalGrid: { ...grid, shape: [5, 7, 99] } }) + .signalSlice, + ).toBe(4); + }); +}); From 571254a967ffc6a545832fa8dc013e2f1d714d4f Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:21:21 -0600 Subject: [PATCH 2/3] Keep reference grid geometry fixed within each dataset --- viewer/README.md | 4 + viewer/browser/reference-grid.mjs | 383 ++++++++++++++++++++++++ viewer/src/colony-viewer.ts | 31 +- viewer/src/main.ts | 2 +- viewer/src/reference-grid.ts | 85 ++++++ viewer/tests/presentation-state.test.ts | 11 + viewer/tests/reference-grid.test.ts | 204 +++++++++++++ 7 files changed, 706 insertions(+), 14 deletions(-) create mode 100644 viewer/browser/reference-grid.mjs create mode 100644 viewer/src/reference-grid.ts create mode 100644 viewer/tests/reference-grid.test.ts diff --git a/viewer/README.md b/viewer/README.md index 80b8478..237d6cc 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -68,3 +68,7 @@ The unit suite includes a Python-authored scene fixture whose digest contains fl Opening a scene file, live session, or recording begins a new dataset. Call `DatasetPresentationState.beginDataset()` and `ColonyViewer.beginDataset()` once, then present its first frame with `setFrame(frame, true)` to fit the camera. Ordinary updates, a reset of the same live model, and recording seeks use `setFrame(frame)` without beginning a dataset. Neither simulation time returning to zero nor a changed signal-grid shape identifies a new dataset. `DatasetPresentationState.datasetId` scopes numerical channel identities; display labels are not identities. Retained preferences are separate from the effective values returned by `forFrame()`. Temporarily absent channels or smaller grids use valid display indices without erasing the user's selections, signal visibility, or chosen slice. The first available signal grid initializes a default slice once. Feature-specific display state should reset only in the explicit `newDataset` block in `presentScene()`. + +The ground reference grid is separate from the scientific signal lattice. Its square extent and origin come from the first frame's finite device geometry (boxes, spheres, and cylinders), or from the initial cell capsule bounds when no finite device exists. Infinite plane constraints are excluded. The extent is at least 10 scene distance units with 20 equal divisions; the grid plane is 0.01 units below the lesser of the initial lower Z bound and zero. An initially empty dataset uses a 10-unit grid centered on the world origin. These values remain fixed even when cells or device geometry appear later, the colony expands beyond the grid, or all cells disappear. Opening another dataset initializes a new reference grid; camera Fit never changes its geometry. + +`browser/reference-grid.mjs` verifies the reference grid and presentation lifecycle in Chromium against a running Vite server. It uses Playwright (`@playwright/test`) and its installed Chromium; a shared installation can be supplied through `MICROSIMULATOR_PLAYWRIGHT_MODULE` as an absolute module filename. Set `VIEWER_URL` if the server is not on `http://127.0.0.1:4320`, and `EVIDENCE_DIR` to choose the screenshot directory. The test observes renderer transforms through test-only request instrumentation and introduces no production debug interface. diff --git a/viewer/browser/reference-grid.mjs b/viewer/browser/reference-grid.mjs new file mode 100644 index 0000000..d26c5f8 --- /dev/null +++ b/viewer/browser/reference-grid.mjs @@ -0,0 +1,383 @@ +// Run against the Vite development server. No production debug hooks are added. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4320"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-reference-grid"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); + +// Observe the real application instance rather than replacing its renderer. +await page.route("**/src/colony-viewer.ts", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const boundary = { kind: "no_flux", values: [] }; +function signalGrid(shape = [5, 7, 9], signalCount = 3) { + return { + signal_count: signalCount, + shape, + origin: [-2, -3, 0], + spacing: [1, 1, 1], + boundaries: { + x_lower: boundary, + x_upper: boundary, + y_lower: boundary, + y_upper: boundary, + z_lower: boundary, + z_upper: boundary, + }, + levels: Array(shape.reduce((a, b) => a * b, signalCount)).fill(1), + }; +} +const cell = { + id: "1", + parent_id: null, + slot: 0, + position: [0, 0, 0.6], + direction: [1, 0, 0], + length: 2, + radius: 0.5, + growth_rate: 0.1, + cell_type: 0, + fixed: false, + species: [1, 2, 3], +}; +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 3, + cells: [cell], + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: signalGrid(), +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 2, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ), + ); +} +async function snapshot() { + return page.evaluate(() => { + const v = globalThis.__testViewer; + v.grid.updateMatrixWorld(true); + v.camera.updateMatrixWorld(true); + const point = v.camera.position + .clone() + .set(0, 0, 0) + .applyMatrix4(v.grid.matrixWorld) + .project(v.camera); + return { + grid: v.grid.matrixWorld.elements.slice(), + camera: v.camera.position.toArray(), + target: v.controls.target.toArray(), + pixelOrigin: point.toArray(), + }; + }); +} +function assertStationary(actual, expected, message) { + assert.deepEqual(actual.grid, expected.grid, message); + for (const key of ["camera", "target", "pixelOrigin"]) { + actual[key].forEach((value, index) => { + assert.ok( + Math.abs(value - expected[key][index]) < 1e-9, + `${message}: ${key}[${index}]`, + ); + }); + } +} +try { + await page.goto(`${url}/?token=reference-grid-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(4); + v.controls.update(); + }); + const initial = await snapshot(); + await page.screenshot({ path: `${evidence}/initial.png` }); + await send({ ...base, time: 1, cells: [{ ...cell, length: 14 }] }); + assertStationary( + await snapshot(), + initial, + "growth must preserve world grid and stationary-camera projection", + ); + await page.screenshot({ path: `${evidence}/grown.png` }); + await send({ + ...base, + time: 2, + cells: [ + { ...cell, position: [5, -8, -20] }, + { ...cell, id: "2", slot: 1, position: [-5, 2, 3] }, + ], + }); + assertStationary( + await snapshot(), + initial, + "translation and division must preserve grid and camera", + ); + await send({ ...base, time: 3, cells: [] }); + assertStationary( + await snapshot(), + initial, + "empty frames must preserve grid and camera", + ); + await send(base); + assertStationary( + await snapshot(), + initial, + "live reset must preserve grid and camera", + ); + + // Exercise UI preferences through a missing grid and reduced channel counts. + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("2"); + await page.locator("#signal-channel").selectOption("2"); + await page.locator("#signal-slice").focus(); + await page.keyboard.press("End"); + await page.locator("#signal-axis").selectOption("x"); + await expect(page.locator("#signal-slice")).toHaveValue("4"); + await page.locator("#signal-axis").selectOption("z"); + await expect(page.locator("#signal-slice")).toHaveValue("8"); + await send({ + ...base, + time: 4, + species_count: 0, + cells: [], + signal_grid: null, + }); + await send({ + ...base, + time: 5, + species_count: 1, + cells: [], + signal_grid: signalGrid([2, 2, 2], 1), + }); + await expect(page.locator("#signal-slice")).toHaveValue("1"); + await send({ ...base, time: 6 }); + await expect(page.locator("#species-channel")).toHaveValue("2"); + await expect(page.locator("#signal-channel")).toHaveValue("2"); + await expect(page.locator("#signal-slice")).toHaveValue("8"); + await expect(page.locator("#signal-visible")).not.toBeChecked(); + + const canvas = page.locator("#canvas-host canvas"); + const bounds = await canvas.boundingBox(); + const x = bounds.x + bounds.width / 2; + const y = bounds.y + bounds.height / 2; + for (const button of ["left", "right"]) { + await page.mouse.move(x, y); + await page.mouse.down({ button }); + await page.mouse.move(x + 80, y + 40, { steps: 8 }); + await page.mouse.up({ button }); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + `${button} drag changes camera only`, + ); + } + await page.mouse.wheel(0, 150); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + "zoom changes camera only", + ); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + assert.deepEqual( + (await snapshot()).grid, + initial.grid, + "Fit changes camera only", + ); + + // Explicit new dataset, including the initially empty case, in the real renderer. + const emptyThenDevice = await page.evaluate(() => { + const v = globalThis.__testViewer; + const empty = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 0, + cells: [], + constraints: { + boxes: [], + cylinders: [], + planes: [ + { + id: "1", + point: [1e30, 0, 0], + inwardNormal: [1, 0, 0], + coefficient: 1, + }, + ], + spheres: [], + }, + signalGrid: null, + }; + v.beginDataset(); + v.setFrame(empty, true); + const before = { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }; + const device = { + ...empty, + constraints: { + ...empty.constraints, + boxes: [ + { + id: "2", + center: [30, 40, -5], + halfExtents: [50, 25, 3], + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }; + v.setFrame(device); + const retained = { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }; + v.beginDataset(); + v.setFrame(device, true); + return { + before, + retained, + reopened: { + position: v.grid.position.toArray(), + scale: v.grid.scale.toArray(), + }, + }; + }); + assert.deepEqual(emptyThenDevice.before, { + position: [0, 0, -0.01], + scale: [0.5, 0.5, 0.5], + }); + assert.deepEqual(emptyThenDevice.retained, emptyThenDevice.before); + assert.deepEqual(emptyThenDevice.reopened, { + position: [30, 40, -8.01], + scale: [5, 5, 5], + }); + // Opening files goes through the application's explicit dataset reset path. + await page.goto(url); + const openFile = async (frame) => { + await page.locator("#scene-file").setInputFiles({ + name: "same-name.scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(frame))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + }; + await openFile(base); + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("2"); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await openFile({ + ...base, + time: 11, + constraints: { + ...base.constraints, + boxes: [ + { + id: "1", + center: [30, 40, -5], + half_extents: [50, 25, 3], + coefficient: 1, + allowed_region: "inside", + }, + ], + }, + }); + await expect(page.locator("#color-mode")).toHaveValue("cell-type"); + await expect(page.locator("#species-channel")).toHaveValue("0"); + await expect(page.locator("#signal-visible")).toBeChecked(); + assert.deepEqual( + await page.evaluate(() => ({ + position: globalThis.__testViewer.grid.position.toArray(), + scale: globalThis.__testViewer.grid.scale.toArray(), + })), + { position: [30, 40, -8.01], scale: [5, 5, 5] }, + ); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + assertions: + "grid world/projected coordinates; camera orbit/pan/zoom/Fit; growth/XYZ/division/removal/reset; missing channels/grid; axis round trip; initially empty/new dataset", + evidence, + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 4c3038c..26609d7 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -36,6 +36,10 @@ import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { rgbBytes, viridis, type RGB } from "./color"; import type { SignalSlice } from "./grid"; +import { + DatasetReferenceGrid, + REFERENCE_GRID_DIVISIONS, +} from "./reference-grid"; import type { SceneCell, SceneConstraints, SceneFrame } from "./scene"; import { canonicalViewQuaternion, @@ -99,7 +103,13 @@ export class ColonyViewer { private readonly device = new Group(); private readonly signal = new Group(); private readonly highlight = new Group(); - private readonly grid = new GridHelper(20, 20, 0x34413c, 0x222b27); + private readonly grid = new GridHelper( + 20, + REFERENCE_GRID_DIVISIONS, + 0x34413c, + 0x222b27, + ); + private readonly referenceGrid = new DatasetReferenceGrid(); private readonly raycaster = new Raycaster(); private readonly pointer = new Vector2(); private readonly resizeObserver: ResizeObserver; @@ -159,6 +169,7 @@ export class ColonyViewer { this.device, this.highlight, ); + this.grid.name = "reference-grid"; this.grid.rotateX(Math.PI / 2); this.grid.position.z = -0.002; this.highlight.visible = false; @@ -196,12 +207,14 @@ export class ColonyViewer { /** Call once when opening a file, live session, or recording. */ public beginDataset(): void { + this.referenceGrid.beginDataset(); this.cancelCameraTransition(); this.selectCell(null); } /** Frame updates, including reset/seek, retain camera and dataset state. */ public setFrame(frame: SceneFrame, fit = false): void { + this.configureReferenceGrid(frame); this.viewCube.setVisible(true); disposeGroup(this.colony); this.cellMeshes = []; @@ -216,7 +229,6 @@ export class ColonyViewer { this.sceneBounds = deviceBounds.isEmpty() ? new Box3(new Vector3(-1, -1, -1), new Vector3(1, 1, 1)) : deviceBounds; - this.configureReferenceGrid(this.sceneBounds); if (fit) { this.fitColony(false); } @@ -302,7 +314,6 @@ export class ColonyViewer { this.colony.add(...this.cellMeshes); this.buildDevice(frame.constraints, bounds); this.sceneBounds = bounds; - this.configureReferenceGrid(bounds); const selectedIndex = frame.cells.findIndex( (cell) => cell.id === this.selectedCellId, ); @@ -790,16 +801,10 @@ export class ColonyViewer { this.highlight.visible = true; } - private configureReferenceGrid(bounds: Box3): void { - const size = bounds.getSize(new Vector3()); - const center = bounds.getCenter(new Vector3()); - const extent = Math.max(size.x, size.y, 10); - this.grid.scale.set(extent / 20, extent / 20, extent / 20); - this.grid.position.set( - center.x, - center.y, - Math.min(bounds.min.z, 0) - 0.01, - ); + private configureReferenceGrid(frame: SceneFrame): void { + const layout = this.referenceGrid.forFrame(frame); + this.grid.scale.setScalar(layout.extent / 20); + this.grid.position.fromArray(layout.position); } private resize(host: HTMLElement): void { diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 1b69adb..28aa1d8 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -150,7 +150,7 @@ function updateSignalRange(): void { const axis = signalAxis.value as SliceAxis; const maximum = sliceDimension(frame.signalGrid, axis) - 1; signalRange.max = String(maximum); - signalRange.value = String(Math.min(selectedInteger(signalRange), maximum)); + signalRange.value = String(presentation.forFrame(frame).signalSlice); sliceValue.value = signalRange.value; } diff --git a/viewer/src/reference-grid.ts b/viewer/src/reference-grid.ts new file mode 100644 index 0000000..9296410 --- /dev/null +++ b/viewer/src/reference-grid.ts @@ -0,0 +1,85 @@ +import { Box3, Vector3 } from "three"; + +import type { SceneFrame, Vector3 as SceneVector3 } from "./scene"; + +export const REFERENCE_GRID_DIVISIONS = 20; +export const MINIMUM_REFERENCE_GRID_EXTENT = 10; + +export interface ReferenceGridLayout { + readonly extent: number; + readonly spacing: number; + readonly position: SceneVector3; +} + +/** Finite device geometry is independent of the colony's current envelope. */ +function initialBounds(frame: SceneFrame): Box3 { + const device = new Box3(); + const include = (center: SceneVector3, extent: SceneVector3): void => { + const origin = new Vector3().fromArray(center); + const radius = new Vector3().fromArray(extent); + device.union( + new Box3(origin.clone().sub(radius), origin.clone().add(radius)), + ); + }; + for (const box of frame.constraints.boxes) { + include(box.center, box.halfExtents); + } + for (const sphere of frame.constraints.spheres) { + include(sphere.center, [sphere.radius, sphere.radius, sphere.radius]); + } + for (const cylinder of frame.constraints.cylinders) { + include(cylinder.center, [ + cylinder.radius, + cylinder.radius, + cylinder.halfHeight, + ]); + } + // Planes are infinite. Their visualization extent must never size the grid. + if (!device.isEmpty()) { + return device; + } + const colony = new Box3(); + for (const cell of frame.cells) { + const center = new Vector3().fromArray(cell.position); + const half = new Vector3() + .fromArray(cell.direction) + .normalize() + .multiplyScalar(cell.length / 2); + colony.union( + new Box3() + .setFromPoints([center.clone().sub(half), center.clone().add(half)]) + .expandByScalar(cell.radius), + ); + } + return colony.isEmpty() ? new Box3(new Vector3(), new Vector3()) : colony; +} + +export function initialReferenceGrid(frame: SceneFrame): ReferenceGridLayout { + const bounds = initialBounds(frame); + const size = bounds.getSize(new Vector3()); + const center = bounds.getCenter(new Vector3()); + const extent = Math.max(size.x, size.y, MINIMUM_REFERENCE_GRID_EXTENT); + return Object.freeze({ + extent, + spacing: extent / REFERENCE_GRID_DIVISIONS, + position: Object.freeze([ + center.x, + center.y, + Math.min(bounds.min.z, 0) - 0.01, + ]) as SceneVector3, + }); +} + +/** Geometry is chosen from the first frame, including an empty first frame. */ +export class DatasetReferenceGrid { + private layout: ReferenceGridLayout | null = null; + + public beginDataset(): void { + this.layout = null; + } + + public forFrame(frame: SceneFrame): ReferenceGridLayout { + this.layout ??= initialReferenceGrid(frame); + return this.layout; + } +} diff --git a/viewer/tests/presentation-state.test.ts b/viewer/tests/presentation-state.test.ts index c6dbe86..aca53de 100644 --- a/viewer/tests/presentation-state.test.ts +++ b/viewer/tests/presentation-state.test.ts @@ -34,6 +34,17 @@ const frame: SceneFrame = { }; describe("dataset presentation lifecycle", () => { + it("restores the desired slice after an axis round trip", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + state.preferences.signalSlice = 8; + expect(state.forFrame(frame).signalSlice).toBe(8); + state.preferences.signalAxis = "x"; + expect(state.forFrame(frame).signalSlice).toBe(4); + state.preferences.signalAxis = "z"; + expect(state.forFrame(frame).signalSlice).toBe(8); + }); + it("resets defaults only on an explicit new dataset", () => { const state = new DatasetPresentationState(); state.beginDataset(); diff --git a/viewer/tests/reference-grid.test.ts b/viewer/tests/reference-grid.test.ts new file mode 100644 index 0000000..d11c377 --- /dev/null +++ b/viewer/tests/reference-grid.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; + +import { + DatasetReferenceGrid, + initialReferenceGrid, +} from "../src/reference-grid"; +import type { SceneCell, SceneConstraints, SceneFrame } from "../src/scene"; + +const constraints: SceneConstraints = { + boxes: [], + spheres: [], + cylinders: [], + planes: [], +}; +const cell: SceneCell = { + id: "1", + parentId: null, + slot: 0, + position: [3, 4, 2], + direction: [1, 0, 0], + length: 4, + radius: 0.5, + growthRate: 0, + cellType: 0, + fixed: false, + species: [], +}; +const frame: SceneFrame = { + time: 0, + backend: { + kind: "cpu", + name: "CPU", + device: "host", + deviceIndex: 0, + native: true, + }, + speciesCount: 0, + cells: [cell], + constraints, + signalGrid: null, +}; + +describe("reference grid initialization", () => { + it("uses initial colony bounds with a minimum ten-unit extent", () => { + expect(initialReferenceGrid(frame)).toEqual({ + extent: 10, + spacing: 0.5, + position: [3, 4, -0.01], + }); + expect( + initialReferenceGrid({ ...frame, cells: [{ ...cell, length: 40 }] }), + ).toEqual({ extent: 41, spacing: 2.05, position: [3, 4, -0.01] }); + }); + + it("uses finite device bounds even when cells are far outside them", () => { + expect( + initialReferenceGrid({ + ...frame, + cells: [{ ...cell, position: [1000, 2000, -99] }], + constraints: { + ...constraints, + boxes: [ + { + id: "1", + center: [10, -20, -3], + halfExtents: [8, 2, 1], + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }), + ).toEqual({ extent: 16, spacing: 0.8, position: [10, -20, -4.01] }); + }); + + it("accounts for spherical and cylindrical device extents", () => { + expect( + initialReferenceGrid({ + ...frame, + constraints: { + ...constraints, + spheres: [ + { + id: "1", + center: [50, 60, -8], + radius: 6, + coefficient: 1, + allowedRegion: "outside", + }, + ], + }, + }), + ).toEqual({ extent: 12, spacing: 0.6, position: [50, 60, -14.01] }); + expect( + initialReferenceGrid({ + ...frame, + constraints: { + ...constraints, + cylinders: [ + { + id: "1", + center: [-100, 40, 10], + radius: 8, + halfHeight: 2, + coefficient: 1, + allowedRegion: "inside", + }, + ], + }, + }), + ).toEqual({ extent: 16, spacing: 0.8, position: [-100, 40, -0.01] }); + }); + + it("ignores unbounded planes, including their arbitrary origin", () => { + const planes = [ + { + id: "1", + point: [1e30, -1e30, -1e30] as const, + inwardNormal: [0, 0, 1] as const, + coefficient: 1, + }, + ]; + expect( + initialReferenceGrid({ + ...frame, + constraints: { ...constraints, planes }, + }), + ).toEqual(initialReferenceGrid(frame)); + expect( + initialReferenceGrid({ + ...frame, + cells: [], + constraints: { ...constraints, planes }, + }), + ).toEqual({ extent: 10, spacing: 0.5, position: [0, 0, -0.01] }); + }); +}); + +describe("reference grid dataset lifecycle", () => { + it("preserves grid intersections through growth, XYZ motion, division, empty frames, reset and seek", () => { + const grid = new DatasetReferenceGrid(); + grid.beginDataset(); + const initial = grid.forFrame(frame); + const changedFrames = [ + { ...frame, time: 5, cells: [{ ...cell, length: 400 }] }, + { + ...frame, + time: 6, + cells: [{ ...cell, position: [100, -200, -300] as const }], + }, + { + ...frame, + time: 7, + cells: [ + cell, + { ...cell, id: "2", slot: 1, position: [-50, 20, 30] as const }, + ], + }, + { ...frame, time: 8, cells: [] }, + { ...frame, time: 0 }, + { ...frame, time: 4 }, + ]; + const intersections = (value: typeof initial) => + Array.from({ length: 21 }, (_, i) => [ + value.position[0] - value.extent / 2 + i * value.spacing, + value.position[1] - value.extent / 2 + i * value.spacing, + value.position[2], + ]); + for (const next of changedFrames) { + expect(grid.forFrame(next)).toBe(initial); + expect(intersections(grid.forFrame(next))).toEqual( + intersections(initial), + ); + } + }); + + it("keeps the fallback from an initially empty dataset when cells or devices arrive", () => { + const grid = new DatasetReferenceGrid(); + const initial = grid.forFrame({ ...frame, cells: [] }); + expect(grid.forFrame(frame)).toBe(initial); + const next = { + ...frame, + constraints: { + ...constraints, + boxes: [ + { + id: "1", + center: [20, 30, 0] as const, + halfExtents: [50, 50, 50] as const, + coefficient: 1, + allowedRegion: "inside" as const, + }, + ], + }, + }; + expect(grid.forFrame(next)).toBe(initial); + grid.beginDataset(); + expect(grid.forFrame(next)).toEqual({ + extent: 100, + spacing: 5, + position: [20, 30, -50.01], + }); + }); +}); From 2a6bdc0340994afb1c6fe8b6d9f92ecc11257c18 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 16:41:43 -0600 Subject: [PATCH 3/3] Match Vite query strings in reference grid browser hook --- viewer/browser/reference-grid.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/browser/reference-grid.mjs b/viewer/browser/reference-grid.mjs index d26c5f8..9d2a26a 100644 --- a/viewer/browser/reference-grid.mjs +++ b/viewer/browser/reference-grid.mjs @@ -17,7 +17,7 @@ const errors = []; page.on("pageerror", (error) => errors.push(error.message)); // Observe the real application instance rather than replacing its renderer. -await page.route("**/src/colony-viewer.ts", async (route) => { +await page.route("**/src/colony-viewer.ts*", async (route) => { const response = await route.fetch(); const source = await response.text(); const marker = "this.onSelection = onSelection;";