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
10 changes: 10 additions & 0 deletions viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ 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.

## Concentration color ranges

Species coloring and signal slices each offer Automatic and Fixed color ranges. Automatic uses the current frame's species extrema or the selected signal slice's extrema. Constant automatic data uses the midpoint color and a uniform legend; empty automatic data shows “no values” without numerical bounds. Fixed uses the entered minimum and maximum across frames and slices. Values outside that interval use endpoint colors; the underlying concentrations and inspector values remain unchanged.

Switching to Fixed starts from the current extrema (with finite padding for constant data), or restores that channel's previously entered fixed bounds. Edit both bounds and choose Apply range or press Enter. Bounds accept finite decimal numbers, including negative numbers and scientific notation, with minimum strictly less than maximum. Invalid or incomplete edits show an explanation and leave the last valid range active. Legends always describe the active range rather than unsubmitted text.

Settings belong to the numerical species or signal channel within the current dataset. They survive temporarily missing channels/grids, live updates, same-model reset, and frame seeking; opening another dataset restores automatic defaults. The shared `resolveScalarRange()` and `normalizeScalar()` APIs reject non-finite data explicitly, retain zero-valued and negative data, and avoid overflowing the difference between extreme finite bounds. They produce display intensity only and do not modify model data.

`browser/scalar-ranges.mjs` checks actual cell instance colors, signal texture pixels, legends, validation messages, keyboard interaction, and dataset transitions in Chromium. Run it with a Vite server on port 4315, or set `VIEWER_URL`, using the same optional Playwright module and evidence-directory environment variables as the reference-grid test.

### 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.
Expand Down
321 changes: 321 additions & 0 deletions viewer/browser/scalar-ranges.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
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:4315";
const evidence =
process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-scalar-ranges";
await mkdir(evidence, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
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: [] };
const grid = {
signal_count: 2,
shape: [2, 1, 2],
origin: [-2, -2, -1],
spacing: [4, 1, 1],
boundaries: {
x_lower: boundary,
x_upper: boundary,
y_lower: boundary,
y_upper: boundary,
z_lower: boundary,
z_upper: boundary,
},
levels: [2, 2, 4, 40, 8, 8, 6, 60],
};
const first = {
id: "1",
parent_id: null,
slot: 0,
position: [-1, 0, 0.6],
direction: [0, 1, 0],
length: 2,
radius: 0.5,
growth_rate: 0.1,
cell_type: 0,
fixed: false,
species: [2, 8],
};
const second = {
...first,
id: "2",
slot: 1,
position: [1, 0, 0.6],
species: [4, 6],
};
const base = {
backend: {
kind: "cpu",
name: "CPU fixture",
device: "host",
device_index: 0,
native: true,
},
time: 0,
species_count: 2,
cells: [first, second],
constraints: { boxes: [], cylinders: [], planes: [], spheres: [] },
signal_grid: grid,
};
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}`);
}
const control = (kind, field) =>
page.getByRole(field === "range mode" ? "combobox" : "textbox", {
name: `${kind} ${field}`,
exact: true,
});
async function fixed(kind, minimum, maximum) {
await control(kind, "range mode").focus();
await page.keyboard.press("f");
await page.keyboard.press("Tab");
await expect(control(kind, "range mode")).toHaveValue("fixed");
await control(kind, "minimum").fill(String(minimum));
await control(kind, "maximum").fill(String(maximum));
await control(kind, "maximum").press("Enter");
}
async function colors() {
return page.evaluate(() => ({
cells: Array.from(
globalThis.__testViewer.cellMeshes[0]?.instanceColor?.array ?? [],
),
signal: Array.from(globalThis.__testViewer.signalTexture?.image.data ?? []),
}));
}
async function expectedColor(intensity) {
return page.evaluate(async (intensity) => {
const { rgbBytes, viridis } = await import("/src/color.ts");
const { Color, SRGBColorSpace } =
await import("/node_modules/.vite/deps/three.js");
const rgb = viridis(intensity);
const linear = new Color().setRGB(...rgb, SRGBColorSpace);
return {
signal: [...rgbBytes(rgb), 205],
cell: [linear.r, linear.g, linear.b].map(Math.fround),
};
}, intensity);
}
try {
await page.goto(`${url}/?token=scalar-range-test`);
await expect.poll(() => socket !== undefined).toBe(true);
await send(base);
await page.locator("#color-mode").selectOption("species");
await fixed("Species", 0, 10);
await fixed("Signal", 0, 10);
await expect(page.locator("#legend-mode")).toHaveText("Fixed");
await expect(page.locator("#signal-legend-mode")).toHaveText("Fixed");
const initial = await colors();
const at02 = await expectedColor(0.2);
assert.deepEqual(initial.cells.slice(0, 3), at02.cell);
assert.deepEqual(initial.signal.slice(0, 4), at02.signal);
await page.screenshot({ path: `${evidence}/fixed-ranges.png` });

await send({
...base,
time: 1,
cells: [first, { ...second, species: [40, 6] }],
signal_grid: { ...grid, levels: [2, 2, 40, 40, 8, 8, 6, 60] },
});
const changed = await colors();
assert.deepEqual(changed.cells.slice(0, 3), initial.cells.slice(0, 3));
assert.deepEqual(changed.signal.slice(0, 4), initial.signal.slice(0, 4));
await page.locator("#signal-slice").focus();
await page.keyboard.press("End");
assert.deepEqual(
(await colors()).signal.slice(0, 4),
initial.signal.slice(0, 4),
);
await expect(page.locator("#legend-max")).toHaveText("10");
await expect(page.locator("#signal-legend-max")).toHaveText("10");

const clipped = {
...base,
time: 2,
cells: [
{ ...first, species: [-5, 8] },
{ ...second, species: [50, 6] },
],
signal_grid: { ...grid, levels: [-5, -5, 50, 50, 8, 8, 6, 60] },
};
await send(clipped);
const endpoints = await colors();
const low = await expectedColor(0);
const high = await expectedColor(1);
assert.deepEqual(endpoints.cells, [...low.cell, ...high.cell]);
assert.deepEqual(endpoints.signal, [...low.signal, ...high.signal]);
await page.evaluate(() => globalThis.__testViewer.selectCell(0));
await expect(page.locator("#species-values code").first()).toHaveText("-5");
assert.deepEqual(
await page.evaluate(() =>
globalThis.__testViewer.cells.map((cell) => cell.species[0]),
),
[-5, 50],
);

for (const kind of ["Species", "Signal"]) {
await control(kind, "minimum").fill("20");
await control(kind, "maximum").press("Enter");
await expect(
page
.getByRole("form", { name: `${kind} concentration range` })
.getByRole("status"),
).toContainText("Minimum must be less than maximum");
assert.deepEqual(await colors(), endpoints);
await control(kind, "minimum").fill("Infinity");
await control(kind, "maximum").press("Enter");
await expect(
page
.getByRole("form", { name: `${kind} concentration range` })
.getByRole("status"),
).toContainText("finite number");
assert.deepEqual(await colors(), endpoints);
await control(kind, "minimum").fill("");
await control(kind, "maximum").press("Enter");
await expect(
page
.getByRole("form", { name: `${kind} concentration range` })
.getByRole("status"),
).toContainText("finite number");
await fixed(kind, 0, 10);
}

await page.locator("#species-channel").selectOption("1");
await expect(control("Species", "range mode")).toHaveValue("automatic");
await fixed("Species", -100, 100);
await page.locator("#species-channel").selectOption("0");
await expect(control("Species", "minimum")).toHaveValue("0");
await expect(control("Species", "maximum")).toHaveValue("10");
await page.locator("#signal-channel").selectOption("1");
await expect(control("Signal", "range mode")).toHaveValue("automatic");
await fixed("Signal", -2, 2);
await page.locator("#signal-channel").selectOption("0");
await expect(control("Signal", "maximum")).toHaveValue("10");
await send({
...base,
time: 3,
species_count: 0,
cells: [],
signal_grid: null,
});
await send(base); // Same live model reset / seek back to time zero.
await expect(control("Species", "range mode")).toHaveValue("fixed");
await expect(control("Species", "maximum")).toHaveValue("10");
await expect(control("Signal", "maximum")).toHaveValue("10");
assert.deepEqual((await colors()).cells, initial.cells);

for (const kind of ["Species", "Signal"]) {
await control(kind, "range mode").focus();
await page.keyboard.press("a");
await page.keyboard.press("Tab");
}
await send({
...base,
time: 4,
cells: [
{ ...first, species: [7, 7] },
{ ...second, species: [7, 7] },
],
signal_grid: { ...grid, levels: Array(8).fill(7) },
});
await expect(page.locator("#legend-mode")).toHaveText("Automatic · constant");
await expect(page.locator("#signal-legend-mode")).toHaveText(
"Automatic · constant",
);
const midpoint = await expectedColor(0.5);
assert.deepEqual((await colors()).cells, [
...midpoint.cell,
...midpoint.cell,
]);
assert.deepEqual((await colors()).signal, [
...midpoint.signal,
...midpoint.signal,
]);
await send({ ...base, time: 5, cells: [], signal_grid: null });
await expect(page.locator("#legend-mode")).toHaveText(
"Automatic · no values",
);
await expect(page.locator("#legend-min")).toHaveText("—");
await expect(page.locator("#legend-ramp")).toBeHidden();
await send(base);
await control("Species", "range mode").selectOption("fixed");
await expect(control("Species", "maximum")).toHaveValue("10");
await control("Signal", "range mode").selectOption("fixed");
await expect(control("Signal", "maximum")).toHaveValue("10");

await page.goto(url);
const open = async (value) => {
await page.locator("#scene-file").setInputFiles({
name: "scene.json",
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(scene(value))),
});
await expect(page.locator("#time-chip")).toHaveText(`t = ${value.time}`);
};
await open(base);
await page.locator("#color-mode").selectOption("species");
await fixed("Species", 0, 10);
await fixed("Signal", 0, 10);
await open({ ...base, time: 99 });
await page.locator("#color-mode").selectOption("species");
await expect(control("Species", "range mode")).toHaveValue("automatic");
await expect(control("Signal", "range mode")).toHaveValue("automatic");
assert.deepEqual(errors, []);
console.log(
JSON.stringify(
{
result: "passed",
browser: browser.version(),
evidence,
coverage:
"cell instance RGB, signal texture RGBA, fixed scales across frames/slices, endpoints and inspector, invalid bounds, keyboard controls, independent channels, missing data, reset/seek, constant/empty, new dataset",
},
null,
2,
),
);
} finally {
await browser.close();
}
22 changes: 21 additions & 1 deletion viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,14 @@ <h2>Cells</h2>
<span>Species channel</span>
<select id="species-channel"></select>
</label>
<div id="species-range" hidden></div>
<div id="color-legend" class="legend" hidden>
<div class="legend-ramp" aria-hidden="true"></div>
<div
id="legend-ramp"
class="legend-ramp"
aria-hidden="true"
></div>
<div id="legend-mode" class="legend-caption"></div>
<div class="legend-labels">
<span id="legend-min">—</span>
<span id="legend-title">Value</span>
Expand Down Expand Up @@ -108,6 +114,20 @@ <h2>Signal grid</h2>
value="0"
/>
</label>
<div id="signal-color-range"></div>
<div id="signal-legend" class="legend" hidden>
<div
id="signal-legend-ramp"
class="legend-ramp"
aria-hidden="true"
></div>
<div id="signal-legend-mode" class="legend-caption"></div>
<div class="legend-labels">
<span id="signal-legend-min">—</span>
<span id="signal-legend-title">Signal</span>
<span id="signal-legend-max">—</span>
</div>
</div>
</section>

<section class="scene-notes">
Expand Down
Loading