From af9954886c74cde9e0d96fa384d0adf64cbf4dfd Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 15:32:14 -0600 Subject: [PATCH 1/2] Preserve device visibility as a dataset presentation preference --- viewer/README.md | 6 + viewer/browser/device-visibility.mjs | 273 ++++++++++++++++++++++++ viewer/index.html | 4 + viewer/src/colony-viewer.ts | 6 + viewer/src/main.ts | 10 + viewer/src/presentation-state.ts | 2 + viewer/src/style.css | 17 ++ viewer/tests/presentation-state.test.ts | 12 ++ 8 files changed, 330 insertions(+) create mode 100644 viewer/browser/device-visibility.mjs diff --git a/viewer/README.md b/viewer/README.md index 237d6cc..3b4d0eb 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -72,3 +72,9 @@ Opening a scene file, live session, or recording begins a new dataset. Call `Dat 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. + +### Device geometry visibility + +Use **Show device geometry** in the Scene panel to hide all mechanical constraint meshes and their outlines. The control is disabled when the current frame contains no geometry, while its preference is retained for later frames. Visibility persists through live updates, reset, and replay seeks, and defaults to enabled on opening another dataset. Cells, selection, the reference grid, signal slices, camera pose, and the existing Fit bounds policy are independent of this display setting. No simulation constraint or transport obstacle is changed. + +`browser/device-visibility.mjs` verifies all four constraint types, outlines, keyboard toggling, cell picking, sibling visibility, frame/reset retention, missing geometry, camera and Fit invariance, and new-dataset defaults against Vite on port 4323. It uses the same Playwright module and evidence-directory options as the reference-grid browser test. diff --git a/viewer/browser/device-visibility.mjs b/viewer/browser/device-visibility.mjs new file mode 100644 index 0000000..83a4bfc --- /dev/null +++ b/viewer/browser/device-visibility.mjs @@ -0,0 +1,273 @@ +// 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:4323"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-device-visibility"; +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)), + ), + ); +} +const constraints = { + boxes: [ + { + id: "1", + center: [4, 0, 1], + half_extents: [1, 2, 1], + allowed_region: "outside", + coefficient: 1, + }, + ], + spheres: [ + { + id: "2", + center: [-4, 0, 1], + radius: 1.5, + allowed_region: "outside", + coefficient: 1, + }, + ], + cylinders: [ + { + id: "3", + center: [0, 4, 1], + radius: 1, + half_height: 2, + allowed_region: "outside", + coefficient: 1, + }, + ], + planes: [ + { id: "4", point: [0, 0, -0.5], inward_normal: [0, 0, 1], coefficient: 1 }, + ], +}; +const all = { ...base, constraints }; +async function snapshot() { + return page.evaluate(() => { + const v = globalThis.__testViewer; + v.grid.updateMatrixWorld(true); + return { + camera: [ + ...v.camera.position.toArray(), + ...v.camera.quaternion.toArray(), + ], + bounds: [v.sceneBounds.min.toArray(), v.sceneBounds.max.toArray()], + grid: v.grid.matrixWorld.elements.slice(), + visible: v.device.visible, + deviceChildren: v.device.children.length, + cells: v.colony.visible && v.cellMeshes.length > 0, + highlight: v.highlight.visible, + signal: v.signal.visible && v.signal.children.length > 0, + }; + }); +} +function assertSnapshot(actual, expected, message) { + actual.camera.forEach((value, index) => + assert.ok(Math.abs(value - expected.camera[index]) < 1e-9, message), + ); + assert.deepEqual( + { ...actual, camera: [] }, + { ...expected, camera: [] }, + message, + ); +} +async function pickCell() { + const point = await page.evaluate(() => { + const v = globalThis.__testViewer; + const rect = v.renderer.domElement.getBoundingClientRect(); + const p = v.camera.position.clone().set(0, 0, 0.6).project(v.camera); + return [ + rect.left + ((p.x + 1) * rect.width) / 2, + rect.top + ((1 - p.y) * rect.height) / 2, + ]; + }); + await page.mouse.click(...point); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); +} +try { + await page.goto(`${url}/?token=device-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(all); + const toggle = page.getByLabel("Show device geometry", { exact: true }); + await expect(toggle).toBeEnabled(); + await expect(toggle).toBeChecked(); + await pickCell(); + const initial = await snapshot(); + assert.equal( + initial.deviceChildren, + 6, + "all four meshes plus box/cylinder outlines", + ); + assert.ok(initial.cells && initial.signal && initial.highlight); + await page.screenshot({ path: `${evidence}/visible.png` }); + await toggle.focus(); + await page.keyboard.press("Space"); + await expect(toggle).not.toBeChecked(); + const hidden = await snapshot(); + assertSnapshot(hidden, { ...initial, visible: false }); + await page.screenshot({ path: `${evidence}/hidden.png` }); + await page.locator("#clear-selection").click(); + await pickCell(); + for (const time of [1, 20, 3, 0]) { + await send({ ...all, time }); + await expect(toggle).not.toBeChecked(); + const current = await snapshot(); + assertSnapshot( + current, + hidden, + "updates/reset/reverse-time retain display state", + ); + } + await send({ ...base, time: 21 }); + await expect(toggle).toBeDisabled(); + await expect(toggle).not.toBeChecked(); + await send({ ...all, time: 22 }); + await expect(toggle).toBeEnabled(); + await expect(toggle).not.toBeChecked(); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + const fittedHidden = await snapshot(); + await toggle.focus(); + await page.keyboard.press("Space"); + const shown = await snapshot(); + assertSnapshot(shown, { ...fittedHidden, visible: true }); + await page.locator("#fit-button").click(); + await expect + .poll(async () => + page.evaluate(() => globalThis.__testViewer.cameraTransition === null), + ) + .toBe(true); + assertSnapshot( + await snapshot(), + shown, + "Fit bounds and pose are visibility independent", + ); + // A separate standalone file opening starts a new dataset and restores enabled. + await page.goto(url); + const file = { + name: "device.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(all))), + }; + await page.locator("#scene-file").setInputFiles(file); + await expect(toggle).toBeChecked(); + await toggle.uncheck(); + await page.locator("#scene-file").setInputFiles(file); + await expect(toggle).toBeChecked(); + await pickCell(); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + result: "passed", + browser: browser.version(), + assertions: + "device meshes/outlines, sibling visibility, keyboard, picking, camera, Fit, live updates/reset, missing geometry, new dataset", + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/index.html b/viewer/index.html index 0b1a8a3..d12b8f4 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -112,6 +112,10 @@

Signal grid

Scene

+
Backend
diff --git a/viewer/src/colony-viewer.ts b/viewer/src/colony-viewer.ts index 26609d7..f27ea0c 100644 --- a/viewer/src/colony-viewer.ts +++ b/viewer/src/colony-viewer.ts @@ -208,10 +208,16 @@ export class ColonyViewer { /** Call once when opening a file, live session, or recording. */ public beginDataset(): void { this.referenceGrid.beginDataset(); + this.setDeviceVisible(true); this.cancelCameraTransition(); this.selectCell(null); } + /** Presentation only: the group retains visibility when its children rebuild. */ + public setDeviceVisible(visible: boolean): void { + this.device.visible = visible; + } + /** Frame updates, including reset/seek, retain camera and dataset state. */ public setFrame(frame: SceneFrame, fit = false): void { this.configureReferenceGrid(frame); diff --git a/viewer/src/main.ts b/viewer/src/main.ts index 28aa1d8..8267cfb 100644 --- a/viewer/src/main.ts +++ b/viewer/src/main.ts @@ -45,6 +45,7 @@ const legendMaximum = required("legend-max"); const legendTitle = required("legend-title"); const signalSection = required("signal-section"); const signalVisible = required("signal-visible"); +const deviceVisible = required("device-visible"); const signalChannel = required("signal-channel"); const signalAxis = required("signal-axis"); const signalRange = required("signal-slice"); @@ -232,6 +233,11 @@ function presentScene( const display = presentation.forFrame(next); frame = next; viewer.setFrame(next, newDataset); + deviceVisible.checked = display.deviceVisible; + deviceVisible.disabled = !Object.values(next.constraints).some( + (constraints) => constraints.length > 0, + ); + viewer.setDeviceVisible(display.deviceVisible); fitButton.disabled = false; colorMode.disabled = false; emptyState.hidden = true; @@ -312,6 +318,10 @@ speciesChannel.addEventListener("change", () => { presentation.preferences.speciesChannel = selectedInteger(speciesChannel); updateColors(); }); +deviceVisible.addEventListener("change", () => { + presentation.preferences.deviceVisible = deviceVisible.checked; + viewer.setDeviceVisible(deviceVisible.checked); +}); signalVisible.addEventListener("change", () => { presentation.preferences.signalVisible = signalVisible.checked; updateSignal(); diff --git a/viewer/src/presentation-state.ts b/viewer/src/presentation-state.ts index 4dd9e61..7f6c2ed 100644 --- a/viewer/src/presentation-state.ts +++ b/viewer/src/presentation-state.ts @@ -6,6 +6,7 @@ export interface PresentationPreferences { colorMode: ColorMode; speciesChannel: number; signalVisible: boolean; + deviceVisible: boolean; signalChannel: number; signalAxis: SliceAxis; signalSlice: number | null; @@ -16,6 +17,7 @@ function defaults(): PresentationPreferences { colorMode: "cell-type", speciesChannel: 0, signalVisible: true, + deviceVisible: true, signalChannel: 0, signalAxis: "z", signalSlice: null, diff --git a/viewer/src/style.css b/viewer/src/style.css index 0466d88..b4c6367 100644 --- a/viewer/src/style.css +++ b/viewer/src/style.css @@ -714,3 +714,20 @@ dd { transition-duration: 0ms; } } + +.device-visibility { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0.75rem 0; + color: var(--muted); + font-size: 0.75rem; +} + +.device-visibility input { + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--accent); +} diff --git a/viewer/tests/presentation-state.test.ts b/viewer/tests/presentation-state.test.ts index aca53de..8c0b926 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("retains device preference through absent geometry and resets for a new dataset", () => { + const state = new DatasetPresentationState(); + state.beginDataset(); + state.preferences.deviceVisible = false; + for (const time of [0, 2, 1, 0]) { + expect(state.forFrame({ ...frame, time }).deviceVisible).toBe(false); + } + state.beginDataset(); + expect(state.forFrame(frame).deviceVisible).toBe(true); + }); + it("restores the desired slice after an axis round trip", () => { const state = new DatasetPresentationState(); state.beginDataset(); @@ -71,6 +82,7 @@ describe("dataset presentation lifecycle", () => { colorMode: "cell-type", speciesChannel: 0, signalVisible: true, + deviceVisible: true, signalChannel: 0, signalAxis: "z", signalSlice: 4, From c1cb4d6fd20d0f0ca5f5bb59f3164413e4ca1f97 Mon Sep 17 00:00:00 2001 From: Mike Arpaia Date: Thu, 24 Sep 2026 16:41:59 -0600 Subject: [PATCH 2/2] Match Vite query strings in device visibility browser hook --- viewer/browser/device-visibility.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewer/browser/device-visibility.mjs b/viewer/browser/device-visibility.mjs index 83a4bfc..4b24c05 100644 --- a/viewer/browser/device-visibility.mjs +++ b/viewer/browser/device-visibility.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;";