Skip to content

Extending inSiliScope

The design rule: every piece of the world is a pure function of (seed, address, channel), and consumers see only the C ABI. Keep that and a new feature works in the Micro-Manager adapter, the viewer and webSMLM at once.

Layers

Layer Directory Rule
World model (samples: the cell field, the nanorulers; dyes, schedules) core/ C++17, no MMDevice or GPU/OS dependencies, extern "C" ABI only
Rendering (PSF, noise, widefield) adapter/inSiliScope/Simulation/ no MM includes; also built by the CLI and the viewer's WASM target
Device adapter adapter/inSiliScope/ MSBuild, MM properties
Viewer web/ generated module + UI
webSMLM block tools/make_cellfield_block.mjs generated

Recipe: a new structure or cell type

  1. Pick a fresh hash channel number; never reuse one (a reused channel correlates two decisions).
  2. Generate it per cell in the cell's local frame from Pcg4d(seed ^ SALT, cx, cy, channel). Decide everything cheap (exists? labelled?) from hashes before building geometry.
  3. Generate in blocks with a stable address so a window query touches only the blocks it needs; cache them in an LRU.
  4. Expose the sites through the existing dye/event queries, so both modalities pick it up.
  5. Add world_checks cases: order independence, tiling, 8 threads = 1 thread.
  6. Document the model on its sample's pages under docs/samples/ (equations, parameters with units and defaults, sources, limits).
  7. Show it in the overview: add its geometry to ScopeGeometryJson (Simulation/ScopeMovie.cpp, insiliscope_cli --geometry-json) and one draw function to STRUCTURE_LAYERS in tools/build_overview.py.
  8. A labelled structure is a target: a core structure id (ISC_STRUCT_*, raising ISC_STRUCT_COUNT), a registry entry (below) and its linkage axis in LinkageOffset. The nuclear pores (core/src/npcs.*, 2026-10-12) are the worked example: per-target label options, MM rows, viewer label group and the combined light preset follow from the registry by themselves.

Recipe: a new sample

A sample (specimen in the code) is a geometry source; the photophysics, light path, PSF and camera are shared. The nanorulers (core/src/origami.*) are the worked example.

  1. Core: a param specimen value; the world builds the sample's geometry from it (World in core/src/world.cpp branches on it: an origami world holds an OrigamiField) and yields its sites as dye blocks with their own address (BlockKey), structure id and draws, so the event, continuous, density and site queries work unchanged. Its params in Params/kFields (core/src/params.*), skipped by PackingFingerprint; queries of its own for the hosts (ABI: isc_origami_*); world_checks cases (determinism under any query order, tiling, statistics); the golden vectors re-frozen with its lines added.
  2. Registry: an entry in data/specimens.json with its targets (option prefix, core structure id; a new structure id raises ISC_STRUCT_COUNT) and their typicalLabels in data/dyes/library.json; node tools/gen_dye_library.mjs.
  3. Engine: its params as options (ScopeSpecimenParams in Simulation/ScopeMovie.cpp); the per-target label options come from the registry by themselves. A cli ctest.
  4. Viewer and block: an option group shown for it (show: predicates in web/index.html), its drawing (a worker job and a layer in web/scene/core.js), buildWindow({specimen}) in tools/make_cellfield_block.mjs.
  5. Micro-Manager: a device for it (spec/MM_DEVICES.md "A new specimen"); SampleHolder lists it by itself; tools/test_specimens.py.
  6. Docs: a page under docs/samples/, a row in Samples, figures (tools/physics_figures/<page>.py) and a gallery entry.

Recipe: a new DNA-origami design

A design is data: add it to data/origami/designs.json (schema in its about), run node tools/gen_origami_designs.mjs (validates it and writes the core's and the viewer's tables; --check in CI) and re-freeze the golden vectors if a golden case uses it. Its index is the origamiDesign param; the viewer, Micro-Manager (Nanorulers.Design) and the CLI list it by itself. A body, deformation or landing model the code does not implement yet (cuboid, 3D bending, stiffness patches, ...) is validated as "not implemented": add it to core/src/origami.cpp and the generator's validation together.

Positions are in lattice steps of the site spacing (origamiSpacingNm, one value for all designs), so a design scales with it. Two kinds exist:

  • A rod ("body": {"shape": "cylinder", "diameterNm", "endNm"}): spots at "at" = 0, 1, 2, ... steps along the axis, each a ring of strand positions around it; the rod reaches endNm beyond the outer spots.
  • A 2D origami ("body": {"shape": "plate", "thicknessNm", "marginNm"}, deformation rigid, landing roll flat): a lattice of flat-topped hexagons ("type": "hex", cols, rows, odd columns half a row down) and the cells carrying a strand, each named CCRR (column, row from the top-left, both two digits), the labels a hex-map editor prints. Drawing a new pattern in such an editor and pasting the labels of its filled cells is a new design; the plate covers the whole map. Rows run down the image and down the screen, so the pattern reads unmirrored.

Recipe: a new photophysics preset

dSTORM/PALM/DNA-PAINT presets are combinations of the labelling fractions, activation rate and the blink parameters (Kinetics on the World). Add a named preset in the consumers (cli option, viewer, MM property) and a gallery entry.

Recipe: a new MM property

Add one row to the property registry (adapter/inSiliScope/Registry/PropertyTable.cpp) on the device and tier that spec/MM_DEVICES.md picks, with a one-line help, and add the same option to ScopeMovieOptions() so the CLI and viewer reach it. Regenerate the property section of docs/micro-manager.md (tools/gen_property_reference.py) and, if a config group sets it, the shipped configurations (tools/gen_mm_configs.py).

Recipe: a new modality

Follow WideField or BrightField: a render path in Simulation/, a light source (a shutter device in Micro-Manager, an engine option light-<name>; spec/MM_DEVICES.md), a CLI/viewer option, an entry in gallery/manifest.json, a CI check, and a section in the physics docs. A modality that needs new geometry gets it from a core query (BrightField: isc_optical_volume_in_window, pure geometry; the optics live in the renderer). A new structure should also say what it does to brightfield (its refractive index) and be added to the optical volume, so brightfield never shows structures the world does not simulate.

Recipe: a gallery entry

Add an entry to gallery/manifest.json (options are insiliscope_cli options without the leading --) and give it a place on the page: a panel in a row of the manifest's sections (each section is a short story followed by rows of side-by-side panels; the panel's label says what changed, the row's caption what to look at; "shared": true puts a row on one display range, for comparisons of brightness). Prefer a comparison with the default, or a new row in an existing section, over a movie on its own. build_gallery.py refuses an entry no row shows. The release workflow renders it. The 2x2 overview at the top of the gallery and on the Home page comes from the manifest's overview block (tools/build_overview.py; --suggest proposes a spot); build_gallery.py --only overview renders just that.

Performance work

Speed-ups keep every output bit-identical (cli TIFF pixel data, adapter_pixel_hash, the golden vectors, world_checks 8 threads = 1 thread): hoist values computed identically, cache pure functions of (seed, address, parameters), vectorise independent lanes, thread work that is consumed in serial order, re-lay data; never reassociate, fuse (ctest world_checks NoContraction guards the build flags, link-time optimisation included), or change a sampler or a default. Measure first: ISC_TIMING=1 makes a cli or viewer movie print its phase times (Simulation/Timing.h), ISC_TEST=1 python tools/bench_live.py --profile --json out.json times the Micro-Manager live path per phase (the release's numbers are on the Benchmarks page), isc_core_bench (native) and node tools/bench_core.mjs (WASM) time the core per phase, sr_render_check --bench the splat, tools/bench.py whole movies. A consumer with several worlds (the viewer's workers) packs each block once and hands the rows around with ABI 7 isc_world_pack_block / isc_world_set_block (spec/PORT.md 6.5); across runs the same rows live in a small per-user file (ABI 8 isc_world_set_cache_dir, spec/PORT.md 6.6) or, in the viewer, in local storage. Both are keyed on ISC_WORLD_VERSION: bump it with every change that moves a cell, i.e. whenever spec/golden is re-frozen.

Bit-exactness and world_version

The content ported from the JS prototype (retired 2026-10-28) is frozen by golden vectors that were made by the JS; content added since has the C++ itself as its reference:

  • add golden vectors generated from the C++ and commit them; a change to them is a deliberate, reviewed act;
  • changing existing output (a default, a fix that moves cells) is a breaking change for seeds: record it in CHANGELOG.md;
  • the plan is a world_version in the ABI, stamped in every output, so a seed can be reproduced on the version that made it.

A deliberate change of existing output re-freezes the goldens from the C++: node tests/parity/golden.mjs --freeze build/msvc/tests/parity/Release/isc_parity.exe, then bash tools/core_check.sh; update spec/ in that commit.

Checklist for a pull request

  • bash tools/core_check.sh runs the native and WASM builds and tests and the module check;
  • ctest passes natively and under WASM (see CLAUDE.md);
  • node tools/embed_web_module.mjs --check is clean (regenerate after a core change);
  • adapter output unchanged by a refactor: tools/adapter_pixel_hash.py before and after;
  • docs and the gallery updated;
  • Micro-Manager: tools/test_insiliscope.py, test_cellfield_stage.py, test_history.py, test_specimens.py, test_mm_configs.py (see CLAUDE.md).