Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ The ground reference grid is separate from the scientific signal lattice. Its sq

`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.

## Channel labels

Model-defined species and signal names appear in channel selectors, the species legend, and cell inspection. Duplicate names include their channel indices; unnamed channels retain `Channel N`. Names are presentation text; indices continue to identify selected channels. Current readers accept scene v2 and v3, while writers emit v3. See the [authoring guide](../docs/models/channel-labels.md) and [scene v3 schema](../docs/formats/scene-v3.md).
273 changes: 273 additions & 0 deletions viewer/browser/device-visibility.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
4 changes: 4 additions & 0 deletions viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ <h2>Signal grid</h2>

<section class="scene-notes">
<h2>Scene</h2>
<label class="device-visibility">
<span>Show device geometry</span>
<input id="device-visible" type="checkbox" checked disabled />
</label>
<dl>
<div>
<dt>Backend</dt>
Expand Down
6 changes: 6 additions & 0 deletions viewer/src/colony-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,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);
Expand Down
10 changes: 10 additions & 0 deletions viewer/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const legendMaximum = required<HTMLElement>("legend-max");
const legendTitle = required<HTMLElement>("legend-title");
const signalSection = required<HTMLElement>("signal-section");
const signalVisible = required<HTMLInputElement>("signal-visible");
const deviceVisible = required<HTMLInputElement>("device-visible");
const signalChannel = required<HTMLSelectElement>("signal-channel");
const signalAxis = required<HTMLSelectElement>("signal-axis");
const signalRange = required<HTMLInputElement>("signal-slice");
Expand Down Expand Up @@ -237,6 +238,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;
Expand Down Expand Up @@ -322,6 +328,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();
Expand Down
2 changes: 2 additions & 0 deletions viewer/src/presentation-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface PresentationPreferences {
colorMode: ColorMode;
speciesChannel: number;
signalVisible: boolean;
deviceVisible: boolean;
signalChannel: number;
signalAxis: SliceAxis;
signalSlice: number | null;
Expand All @@ -16,6 +17,7 @@ function defaults(): PresentationPreferences {
colorMode: "cell-type",
speciesChannel: 0,
signalVisible: true,
deviceVisible: true,
signalChannel: 0,
signalAxis: "z",
signalSlice: null,
Expand Down
17 changes: 17 additions & 0 deletions viewer/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -721,3 +721,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);
}
Loading