From db1f41505cedd4c5128c13b0fba5dc659478881d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:39:24 +0200 Subject: [PATCH 01/82] feat(mri-scanner): add the ids, sequence model and layout --- explainers/mri-scanner/src/ids.test.ts | 34 ++++ explainers/mri-scanner/src/ids.ts | 143 ++++++++++++++++ .../mri-scanner/src/model/constants.test.ts | 27 ++++ explainers/mri-scanner/src/model/constants.ts | 19 +++ .../mri-scanner/src/model/field.test.ts | 38 +++++ explainers/mri-scanner/src/model/field.ts | 53 ++++++ explainers/mri-scanner/src/model/index.ts | 6 + .../mri-scanner/src/model/layout.test.ts | 95 +++++++++++ explainers/mri-scanner/src/model/layout.ts | 153 ++++++++++++++++++ .../mri-scanner/src/model/scale.test.ts | 14 ++ explainers/mri-scanner/src/model/scale.ts | 11 ++ .../mri-scanner/src/model/sequence.test.ts | 101 ++++++++++++ explainers/mri-scanner/src/model/sequence.ts | 117 ++++++++++++++ .../mri-scanner/src/model/tissues.test.ts | 23 +++ explainers/mri-scanner/src/model/tissues.ts | 21 +++ explainers/mri-scanner/src/theme.test.ts | 26 +++ explainers/mri-scanner/src/theme.ts | 64 ++++++++ 17 files changed, 945 insertions(+) create mode 100644 explainers/mri-scanner/src/ids.test.ts create mode 100644 explainers/mri-scanner/src/ids.ts create mode 100644 explainers/mri-scanner/src/model/constants.test.ts create mode 100644 explainers/mri-scanner/src/model/constants.ts create mode 100644 explainers/mri-scanner/src/model/field.test.ts create mode 100644 explainers/mri-scanner/src/model/field.ts create mode 100644 explainers/mri-scanner/src/model/index.ts create mode 100644 explainers/mri-scanner/src/model/layout.test.ts create mode 100644 explainers/mri-scanner/src/model/layout.ts create mode 100644 explainers/mri-scanner/src/model/scale.test.ts create mode 100644 explainers/mri-scanner/src/model/scale.ts create mode 100644 explainers/mri-scanner/src/model/sequence.test.ts create mode 100644 explainers/mri-scanner/src/model/sequence.ts create mode 100644 explainers/mri-scanner/src/model/tissues.test.ts create mode 100644 explainers/mri-scanner/src/model/tissues.ts create mode 100644 explainers/mri-scanner/src/theme.test.ts create mode 100644 explainers/mri-scanner/src/theme.ts diff --git a/explainers/mri-scanner/src/ids.test.ts b/explainers/mri-scanner/src/ids.test.ts new file mode 100644 index 00000000..2e6cf648 --- /dev/null +++ b/explainers/mri-scanner/src/ids.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + AXIS_CHOICE_IDS, + FOLLOW_SEQUENCE, + GRADIENT_AXIS_IDS, + MOMENT_IDS, + PART_IDS, + PHASE_IDS, + PRESET_IDS, +} from './ids'; + +describe('ids', () => { + it('names every part once', () => { + expect(PART_IDS).toHaveLength(27); + expect(new Set(PART_IDS).size).toBe(PART_IDS.length); + }); + + it('orders the steps of one repetition and the chapters', () => { + expect(PHASE_IDS).toEqual(['excite', 'encode', 'refocus', 'echo', 'recover']); + expect(MOMENT_IDS).toEqual(['pulse90', 'pulse180', 'echoPeak', 'repetitionEnd']); + expect(PRESET_IDS).toEqual([ + 'overview', + 'magnet', + 'spins', + 'resonance', + 'gradients', + 'picture', + ]); + }); + + it('offers the three axes plus following the sequence', () => { + expect(AXIS_CHOICE_IDS).toEqual([...GRADIENT_AXIS_IDS, FOLLOW_SEQUENCE]); + }); +}); diff --git a/explainers/mri-scanner/src/ids.ts b/explainers/mri-scanner/src/ids.ts new file mode 100644 index 00000000..2d1e911a --- /dev/null +++ b/explainers/mri-scanner/src/ids.ts @@ -0,0 +1,143 @@ +export const PART_IDS = [ + 'room', + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'shims', + 'coldHead', + 'quenchPipe', + 'gradientX', + 'gradientY', + 'gradientZ', + 'bodyCoil', + 'bore', + 'table', + 'patient', + 'headCoil', + 'sliceSlab', + 'spinArrows', + 'netMagnet', + 'mainField', + 'rfWave', + 'echoWave', + 'fieldLinesGroup', + 'fringeLine', + 'screen', +] as const; + +export type PartId = (typeof PART_IDS)[number]; + +export const PHASE_IDS = ['excite', 'encode', 'refocus', 'echo', 'recover'] as const; + +export type PhaseId = (typeof PHASE_IDS)[number]; + +export const MOMENT_IDS = ['pulse90', 'pulse180', 'echoPeak', 'repetitionEnd'] as const; + +export type MomentId = (typeof MOMENT_IDS)[number]; + +export const PRESET_IDS = [ + 'overview', + 'magnet', + 'spins', + 'resonance', + 'gradients', + 'picture', +] as const; + +export type PresetId = (typeof PRESET_IDS)[number]; + +export const FIELD_IDS = ['field15', 'field30'] as const; + +export type FieldId = (typeof FIELD_IDS)[number]; + +export const WEIGHTING_IDS = ['t1', 't2'] as const; + +export type WeightingId = (typeof WEIGHTING_IDS)[number]; + +export const TISSUE_IDS = ['fat', 'whiteMatter', 'greyMatter', 'fluid'] as const; + +export type TissueId = (typeof TISSUE_IDS)[number]; + +export const GRADIENT_AXIS_IDS = ['x', 'y', 'z'] as const; + +export type GradientAxisId = (typeof GRADIENT_AXIS_IDS)[number]; + +export const CHOICE_IDS = ['field', 'weighting'] as const; + +export type ChoiceId = (typeof CHOICE_IDS)[number]; + +export const FOLLOW_SEQUENCE = 'sequence'; + +export const AXIS_CHOICE_IDS = [...GRADIENT_AXIS_IDS, FOLLOW_SEQUENCE] as const; + +export type AxisChoiceId = (typeof AXIS_CHOICE_IDS)[number]; + +export const LINE_CHOICE_IDS = ['8', '16', '32', '64'] as const; + +export type LineChoiceId = (typeof LINE_CHOICE_IDS)[number]; + +export const CHAPTER_ACTION_IDS = [ + 'field', + 'weighting', + 'tissue', + 'gradientAxis', + 'moment', + 'lines', +] as const; + +export type ChapterActionId = (typeof CHAPTER_ACTION_IDS)[number]; + +export type CameraView = 'room' | 'cryostat' | 'voxel' | 'coil' | 'gradient' | 'console'; + +export type RegionId = 'room' | 'scanner' | 'layers' | 'voxel' | 'bore' | 'console'; + +export type AnchorId = 'isocentre' | 'voxel' | 'headCoil' | 'screen' | 'coldHead'; + +export type Point = readonly [x: number, y: number, z: number]; + +export type RfPulse = 0 | 90 | 180; + +export interface Fringe { + along: number; + side: number; +} + +export interface SequenceReading { + phase: number; + step: PhaseId; + rf: RfPulse | null; + gradient: GradientAxisId | null; + gradientLevel: number; + echo: number; +} + +export interface SpinReading { + net: Point; + arrows: readonly Point[]; + precession: number; +} + +export interface ViewOptions { + cutaway: boolean; + fieldLines: boolean; + voxel: boolean; + labels: boolean; +} + +export interface AssemblyState { + phase: number; + playing: boolean; + field: FieldId; + weighting: WeightingId; + sequence: SequenceReading; + spins: SpinReading; + tissue: TissueId; + gradientAxis: GradientAxisId | null; + picture: Float32Array; + pictureVersion: number; + fringe: Fringe; + view: ViewOptions; +} diff --git a/explainers/mri-scanner/src/model/constants.test.ts b/explainers/mri-scanner/src/model/constants.test.ts new file mode 100644 index 00000000..1657a8a4 --- /dev/null +++ b/explainers/mri-scanner/src/model/constants.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + BODY_K, + EARTH_UT, + GAMMA_MHZ_PER_T, + HELIUM_K, + KELVIN_AT_ZERO_CELSIUS, + MODEL_SIZE, + REAL_LINES, +} from './constants'; + +describe('constants', () => { + it('takes the gyromagnetic ratio of hydrogen from the facts sheet', () => { + expect(GAMMA_MHZ_PER_T).toBeCloseTo(42.58, 2); + }); + + it('puts the body at 37 °C and liquid helium at about −269 °C', () => { + expect(BODY_K - KELVIN_AT_ZERO_CELSIUS).toBeCloseTo(37, 9); + expect(HELIUM_K - KELVIN_AT_ZERO_CELSIUS).toBeCloseTo(-268.95, 2); + }); + + it('keeps the Earth field band and the picture sizes', () => { + expect(EARTH_UT).toEqual({ min: 25, typical: 50, max: 65 }); + expect(REAL_LINES).toBe(256); + expect(MODEL_SIZE).toBe(64); + }); +}); diff --git a/explainers/mri-scanner/src/model/constants.ts b/explainers/mri-scanner/src/model/constants.ts new file mode 100644 index 00000000..818d5e25 --- /dev/null +++ b/explainers/mri-scanner/src/model/constants.ts @@ -0,0 +1,19 @@ +export const GAMMA_MHZ_PER_T = 42.577478; +export const PLANCK = 6.62607015e-34; +export const BOLTZMANN = 1.380649e-23; +export const BODY_K = 310.15; +export const EARTH_UT = { min: 25, typical: 50, max: 65 } as const; +export const HELIUM_K = 4.2; +export const HYDROGEN_PER_MM3 = 6.7e19; +export const GRADIENT_MT_PER_M = 45; +export const SLEW_T_PER_M_S = 200; +export const HEAD_HALF_FOV_M = 0.12; +export const REAL_LINES = 256; +export const MODEL_SIZE = 64; +export const DISPLAY_TURNS_PER_S = 0.5; + +export const HZ_PER_MHZ = 1e6; +export const TESLA_PER_UT = 1e-6; +export const MS_PER_S = 1000; +export const SECONDS_PER_MINUTE = 60; +export const KELVIN_AT_ZERO_CELSIUS = 273.15; diff --git a/explainers/mri-scanner/src/model/field.test.ts b/explainers/mri-scanner/src/model/field.test.ts new file mode 100644 index 00000000..a9d3ea17 --- /dev/null +++ b/explainers/mri-scanner/src/model/field.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { FIELDS, earthMultiple, larmorMHz, slowdown, spinSurplus, surplusPerMm3 } from './field'; + +describe('main field', () => { + it('compares a 1.5 T and a 3 T magnet with their 0.5 mT lines', () => { + expect(FIELDS.field15).toEqual({ tesla: 1.5, fringe: { along: 4.0, side: 2.5 } }); + expect(FIELDS.field30).toEqual({ tesla: 3, fringe: { along: 5.2, side: 2.8 } }); + }); + + it('makes hydrogen precess at 63.87 MHz and 127.73 MHz', () => { + expect(larmorMHz('field15')).toBeCloseTo(63.87, 2); + expect(larmorMHz('field30')).toBeCloseTo(127.73, 2); + }); + + it('leans about 4.9 and 9.9 spins in a million the field way', () => { + expect(spinSurplus('field15') * 1e6).toBeCloseTo(4.9, 1); + expect(spinSurplus('field30') * 1e6).toBeCloseTo(9.9, 1); + }); + + it('still adds to about 3 × 10¹⁴ spare spins per cubic millimetre at 1.5 T', () => { + expect(surplusPerMm3('field15')).toBeGreaterThan(3e14); + expect(surplusPerMm3('field15')).toBeLessThan(3.5e14); + expect(surplusPerMm3('field30')).toBeCloseTo(2 * surplusPerMm3('field15'), -10); + }); + + it('is tens of thousands of times the Earth field', () => { + const times = earthMultiple('field15'); + expect(times.min).toBeCloseTo(23077, 0); + expect(times.typical).toBeCloseTo(30000, 6); + expect(times.max).toBeCloseTo(60000, 6); + expect(earthMultiple('field30').typical).toBeCloseTo(60000, 6); + }); + + it('draws the precession about 128 million times slower than real at 1.5 T', () => { + expect(slowdown('field15') / 1e6).toBeCloseTo(127.7, 1); + expect(slowdown('field30') / 1e6).toBeCloseTo(255.5, 1); + }); +}); diff --git a/explainers/mri-scanner/src/model/field.ts b/explainers/mri-scanner/src/model/field.ts new file mode 100644 index 00000000..56a65ad5 --- /dev/null +++ b/explainers/mri-scanner/src/model/field.ts @@ -0,0 +1,53 @@ +import type { FieldId, Fringe } from '../ids'; +import { + BODY_K, + BOLTZMANN, + DISPLAY_TURNS_PER_S, + EARTH_UT, + GAMMA_MHZ_PER_T, + HYDROGEN_PER_MM3, + HZ_PER_MHZ, + PLANCK, + TESLA_PER_UT, +} from './constants'; + +export interface FieldSpec { + tesla: number; + fringe: Fringe; +} + +export interface EarthMultiple { + min: number; + typical: number; + max: number; +} + +export const FIELDS: Readonly> = { + field15: { tesla: 1.5, fringe: { along: 4.0, side: 2.5 } }, + field30: { tesla: 3, fringe: { along: 5.2, side: 2.8 } }, +}; + +export function larmorMHz(field: FieldId): number { + return GAMMA_MHZ_PER_T * FIELDS[field].tesla; +} + +export function larmorHz(field: FieldId): number { + return larmorMHz(field) * HZ_PER_MHZ; +} + +export function spinSurplus(field: FieldId): number { + return (PLANCK * larmorHz(field)) / (2 * BOLTZMANN * BODY_K); +} + +export function surplusPerMm3(field: FieldId): number { + return HYDROGEN_PER_MM3 * spinSurplus(field); +} + +export function earthMultiple(field: FieldId): EarthMultiple { + const times = (microtesla: number): number => FIELDS[field].tesla / (microtesla * TESLA_PER_UT); + return { min: times(EARTH_UT.max), typical: times(EARTH_UT.typical), max: times(EARTH_UT.min) }; +} + +export function slowdown(field: FieldId): number { + return larmorHz(field) / DISPLAY_TURNS_PER_S; +} diff --git a/explainers/mri-scanner/src/model/index.ts b/explainers/mri-scanner/src/model/index.ts new file mode 100644 index 00000000..002558c1 --- /dev/null +++ b/explainers/mri-scanner/src/model/index.ts @@ -0,0 +1,6 @@ +export * from './constants'; +export * from './field'; +export * from './layout'; +export * from './scale'; +export * from './sequence'; +export * from './tissues'; diff --git a/explainers/mri-scanner/src/model/layout.test.ts b/explainers/mri-scanner/src/model/layout.test.ts new file mode 100644 index 00000000..e13316bc --- /dev/null +++ b/explainers/mri-scanner/src/model/layout.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + BODY_COIL, + BORE, + COLD_HEAD, + HEAD_COIL, + ISOCENTRE, + LAYERS, + MAGNET, + MAIN_COILS, + PATIENT, + QUENCH_PIPE, + REGIONS, + ROOM, + SCREEN, + SHIELD_COILS, + SHIMS, + SLICE_THICKNESS, + TABLE, + VOXEL, + VOXEL_ARROWS, + gradientShell, +} from './layout'; + +describe('scanner layout', () => { + it('builds a 1.7 m long magnet, 2.33 m tall, around a 70 cm bore at the isocentre', () => { + expect(ISOCENTRE).toEqual([0, 1.05, 0]); + expect(2 * MAGNET.halfLength).toBeCloseTo(1.7, 9); + expect(MAGNET.top).toBe(2.33); + expect(ISOCENTRE[1] - MAGNET.radius).toBe(0); + expect(2 * BORE.radius).toBeCloseTo(0.7, 9); + }); + + it('nests every layer inside the one around it', () => { + const order = [LAYERS.cover, LAYERS.vacuumVessel, LAYERS.radiationShield, LAYERS.heliumVessel]; + order.slice(1).forEach((shell, index) => { + expect(shell.outer).toBeLessThan(order[index].outer); + expect(shell.inner).toBeGreaterThanOrEqual(order[index].inner); + expect(shell.halfLength).toBeLessThan(order[index].halfLength); + }); + expect(LAYERS.gradientCoil.outer).toBe(LAYERS.vacuumVessel.inner); + expect(BORE.radius).toBeLessThan(BODY_COIL.radius); + expect(BODY_COIL.radius).toBeLessThan(LAYERS.gradientCoil.inner); + expect(SHIMS.radius).toBeGreaterThan(LAYERS.gradientCoil.outer); + expect(BODY_COIL.rungs).toBe(16); + }); + + it('keeps the coil windings inside the helium vessel', () => { + const helium = LAYERS.heliumVessel; + for (const coils of [MAIN_COILS, SHIELD_COILS]) { + expect(coils.inner).toBeGreaterThan(helium.inner); + expect(coils.outer).toBeLessThan(helium.outer); + coils.z.forEach((z) => expect(Math.abs(z) + coils.width / 2).toBeLessThan(helium.halfLength)); + } + expect(MAIN_COILS.z).toHaveLength(6); + expect(SHIELD_COILS.z).toEqual([-0.5, 0.5]); + }); + + it('stacks the gradient shells x outside, y in the middle, z inside', () => { + const [x, y, z] = (['x', 'y', 'z'] as const).map(gradientShell); + expect(x.outer).toBeCloseTo(LAYERS.gradientCoil.outer, 9); + expect(x.inner).toBeCloseTo(y.outer, 9); + expect(y.inner).toBeCloseTo(z.outer, 9); + expect(z.inner).toBeCloseTo(LAYERS.gradientCoil.inner, 9); + }); + + it('puts the cold head on top and the quench pipe up to the ceiling', () => { + expect(COLD_HEAD.centre).toEqual([0, 2.05, -0.35]); + expect(COLD_HEAD.top).toBe(MAGNET.top); + expect(QUENCH_PIPE.to).toBe(ROOM.height); + }); + + it('lays the head at the isocentre inside the head coil, feet out of the bore', () => { + expect(TABLE.top).toBe(0.92); + expect(TABLE.z).toEqual([0.85, 3.1]); + expect(PATIENT.head[1] - PATIENT.headRadius).toBeGreaterThan(TABLE.top); + expect(PATIENT.feetZ).toBe(1.75); + expect(HEAD_COIL).toMatchObject({ centre: ISOCENTRE, radius: 0.15, length: 0.3 }); + expect(HEAD_COIL.radius).toBeGreaterThan(PATIENT.headRadius); + expect(SLICE_THICKNESS).toBeCloseTo(0.005, 9); + }); + + it('places the screen, the voxel inset and the regions inside the room', () => { + expect(SCREEN.centre).toEqual([2.6, 1.55, 1.0]); + expect(VOXEL.centre).toEqual([0, 1.75, 1.35]); + expect(VOXEL_ARROWS).toBe(64); + for (const region of Object.values(REGIONS)) { + expect(region.x[0]).toBeGreaterThanOrEqual(ROOM.x[0]); + expect(region.x[1]).toBeLessThanOrEqual(ROOM.x[1]); + expect(region.z[0]).toBeGreaterThanOrEqual(ROOM.z[0]); + expect(region.z[1]).toBeLessThanOrEqual(ROOM.z[1]); + expect(region.y[1]).toBeLessThanOrEqual(ROOM.height); + } + }); +}); diff --git a/explainers/mri-scanner/src/model/layout.ts b/explainers/mri-scanner/src/model/layout.ts new file mode 100644 index 00000000..4fc0281d --- /dev/null +++ b/explainers/mri-scanner/src/model/layout.ts @@ -0,0 +1,153 @@ +import { toRadians } from '@core/math'; +import type { Extent, RegionSpec } from '@core/scene/regions'; +import type { GradientAxisId, Point, RegionId } from '../ids'; +import { GRADIENT_AXIS_IDS } from '../ids'; +import { MILLIMETRES_PER_METRE } from './scale'; + +export interface Shell { + inner: number; + outer: number; + halfLength: number; +} + +export interface CoilRings { + inner: number; + outer: number; + width: number; + z: readonly number[]; +} + +export type LayerId = + 'cover' | 'vacuumVessel' | 'radiationShield' | 'heliumVessel' | 'gradientCoil'; + +export const FLOOR_Y = 0; +export const CEILING_Y = 3.0; +export const ISOCENTRE: Point = [0, 1.05, 0]; +export const FIELD_DIRECTION: Point = [0, 0, -1]; + +export const MAGNET = { + halfLength: 0.85, + radius: 1.05, + top: 2.33, + baseHalfWidth: 0.75, +} as const; + +export const BORE = { radius: 0.35, halfLength: MAGNET.halfLength } as const; + +export const LAYERS: Readonly> = { + cover: { inner: BORE.radius, outer: MAGNET.radius, halfLength: MAGNET.halfLength }, + vacuumVessel: { inner: 0.46, outer: 0.98, halfLength: 0.8 }, + radiationShield: { inner: 0.52, outer: 0.9, halfLength: 0.76 }, + heliumVessel: { inner: 0.56, outer: 0.85, halfLength: 0.72 }, + gradientCoil: { inner: 0.39, outer: 0.46, halfLength: 0.7 }, +}; + +export const MAIN_COILS: CoilRings = { + inner: 0.62, + outer: 0.7, + width: 0.08, + z: [-0.62, -0.38, -0.12, 0.12, 0.38, 0.62], +}; + +export const SHIELD_COILS: CoilRings = { inner: 0.8, outer: 0.84, width: 0.08, z: [-0.5, 0.5] }; + +export const SHIMS = { + radius: 0.47, + count: 12, + width: 0.04, + thickness: 0.01, + halfLength: 0.6, +} as const; + +const GRADIENT_SHELL_ORDER: readonly GradientAxisId[] = ['z', 'y', 'x']; + +export function gradientShell(axis: GradientAxisId): Shell { + const { inner, outer, halfLength } = LAYERS.gradientCoil; + const thickness = (outer - inner) / GRADIENT_AXIS_IDS.length; + const from = inner + GRADIENT_SHELL_ORDER.indexOf(axis) * thickness; + return { inner: from, outer: from + thickness, halfLength }; +} + +export const BODY_COIL = { radius: 0.37, rungs: 16, halfLength: 0.3, rungRadius: 0.006 } as const; + +export const COLD_HEAD = { centre: [0, 2.05, -0.35] as Point, top: MAGNET.top, radius: 0.16 }; + +export const QUENCH_PIPE = { + x: COLD_HEAD.centre[0], + z: COLD_HEAD.centre[2], + from: COLD_HEAD.top, + to: CEILING_Y, + radius: 0.06, +} as const; + +export const TABLE = { + top: 0.92, + z: [0.85, 3.1] as Extent, + halfWidth: 0.3, + cradleZ: [-0.35, 3.1] as Extent, + cradleHalfWidth: 0.22, + cradleThickness: 0.04, +}; + +export const PATIENT = { head: ISOCENTRE, headRadius: 0.1, feetZ: 1.75, halfWidth: 0.22 } as const; + +export const HEAD_COIL = { centre: ISOCENTRE, radius: 0.15, length: 0.3 } as const; + +export const SLICE_THICKNESS = 5 / MILLIMETRES_PER_METRE; + +export const ROOM = { x: [-3, 3] as Extent, z: [-2, 3] as Extent, height: CEILING_Y }; + +export const CONTROL_WINDOW = { x: ROOM.x[1], centreZ: 1.0, width: 1.4, sill: 0.9, top: 2.0 }; + +export const SCREEN = { + centre: [2.6, 1.55, 1.0] as Point, + width: 0.9, + height: 0.6, + yawTowardTable: toRadians(25), +}; + +export const VOXEL = { centre: [0, 1.75, 1.35] as Point, size: 0.36, perSide: 4 } as const; + +export const VOXEL_ARROWS = VOXEL.perSide ** 3; + +export const VOXEL_LEADER: { from: Point; to: Point } = { + from: [VOXEL.centre[0], VOXEL.centre[1] - VOXEL.size / 2, VOXEL.centre[2]], + to: [ISOCENTRE[0], ISOCENTRE[1] + PATIENT.headRadius, ISOCENTRE[2]], +}; + +export const MAIN_FIELD_ARROW = { + tail: [VOXEL.centre[0] + 0.32, VOXEL.centre[1], VOXEL.centre[2] + 0.2] as Point, + length: 0.4, +}; + +export const REGIONS: Readonly> = { + room: { x: ROOM.x, y: [FLOOR_Y, CEILING_Y], z: ROOM.z }, + scanner: { + x: [-MAGNET.radius, MAGNET.radius], + y: [FLOOR_Y, MAGNET.top], + z: [-MAGNET.halfLength, MAGNET.halfLength], + }, + layers: { + x: [ISOCENTRE[0], MAGNET.radius], + y: [ISOCENTRE[1], ISOCENTRE[1] + MAGNET.radius], + z: [-MAGNET.halfLength, MAGNET.halfLength], + }, + voxel: { + x: [VOXEL.centre[0] - VOXEL.size / 2, MAIN_FIELD_ARROW.tail[0]], + y: [VOXEL.centre[1] - VOXEL.size / 2, VOXEL.centre[1] + VOXEL.size / 2], + z: [VOXEL.centre[2] - VOXEL.size / 2, VOXEL.centre[2] + VOXEL.size / 2], + }, + bore: { + x: [-BORE.radius, BORE.radius], + y: [ISOCENTRE[1] - BORE.radius, ISOCENTRE[1] + BORE.radius], + z: [-BORE.halfLength, BORE.halfLength], + }, + console: { + x: [SCREEN.centre[0] - SCREEN.height, ROOM.x[1]], + y: [CONTROL_WINDOW.sill, CONTROL_WINDOW.top], + z: [ + CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2, + CONTROL_WINDOW.centreZ + CONTROL_WINDOW.width / 2, + ], + }, +}; diff --git a/explainers/mri-scanner/src/model/scale.test.ts b/explainers/mri-scanner/src/model/scale.test.ts new file mode 100644 index 00000000..19abc4b4 --- /dev/null +++ b/explainers/mri-scanner/src/model/scale.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { BORE } from './layout'; +import { METRES_PER_UNIT, metresToCentimetres, metresToUnits } from './scale'; + +describe('scale', () => { + it('draws the machine at one metre per unit', () => { + expect(METRES_PER_UNIT).toBe(1); + expect(metresToUnits(1.7)).toBe(1.7); + }); + + it('turns the bore radius into a 70 cm tunnel', () => { + expect(metresToCentimetres(2 * BORE.radius)).toBeCloseTo(70, 9); + }); +}); diff --git a/explainers/mri-scanner/src/model/scale.ts b/explainers/mri-scanner/src/model/scale.ts new file mode 100644 index 00000000..1ba8ddd1 --- /dev/null +++ b/explainers/mri-scanner/src/model/scale.ts @@ -0,0 +1,11 @@ +export const METRES_PER_UNIT = 1; +export const CENTIMETRES_PER_METRE = 100; +export const MILLIMETRES_PER_METRE = 1000; + +export function metresToUnits(metres: number): number { + return metres / METRES_PER_UNIT; +} + +export function metresToCentimetres(metres: number): number { + return metres * CENTIMETRES_PER_METRE; +} diff --git a/explainers/mri-scanner/src/model/sequence.test.ts b/explainers/mri-scanner/src/model/sequence.test.ts new file mode 100644 index 00000000..0b4598c5 --- /dev/null +++ b/explainers/mri-scanner/src/model/sequence.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS, WEIGHTING_IDS } from '../ids'; +import { + CYCLE_UNITS, + DEFAULT_SPEED, + LINE_DONE_UNITS, + MOMENTS, + PHASE_RANGES, + PICTURE_SPEED, + SPEED_RANGE, + WEIGHTINGS, + activeGradient, + phaseAt, + phaseOf, + rate, + realMs, + rfPulse, + scanSeconds, +} from './sequence'; +import { MODEL_SIZE } from './constants'; + +const KNOT_UNITS = [0, 150, 300, 420, 535, 650, 1000]; +const KNOT_MS = { + t1: [0, 3, 6.5, 8.5, 15, 19, 500], + t2: [0, 3, 49, 51, 100, 104, 2500], +} as const; + +describe('spin echo sequence', () => { + it('uses TR 500 ms, TE 15 ms for T1 and TR 2,500 ms, TE 100 ms for T2', () => { + expect(WEIGHTINGS).toEqual({ t1: { tr: 500, te: 15 }, t2: { tr: 2500, te: 100 } }); + }); + + it('splits one repetition into five touching steps', () => { + expect(PHASE_RANGES.excite[0]).toBe(0); + PHASE_IDS.slice(1).forEach((id, index) => { + expect(PHASE_RANGES[id][0]).toBe(PHASE_RANGES[PHASE_IDS[index]][1]); + }); + expect(PHASE_RANGES.recover[1]).toBe(CYCLE_UNITS); + expect(LINE_DONE_UNITS).toBe(650); + }); + + it('puts each moment in its step', () => { + expect(MOMENTS).toEqual({ pulse90: 75, pulse180: 360, echoPeak: 535, repetitionEnd: 990 }); + expect(phaseOf(MOMENTS.pulse90)).toBe('excite'); + expect(phaseOf(MOMENTS.pulse180)).toBe('refocus'); + expect(phaseOf(MOMENTS.echoPeak)).toBe('echo'); + expect(phaseOf(MOMENTS.repetitionEnd)).toBe('recover'); + }); + + it.each(WEIGHTING_IDS)('maps the %s knots to real milliseconds and back', (weighting) => { + KNOT_UNITS.forEach((units, index) => { + expect(realMs(units, weighting)).toBeCloseTo(KNOT_MS[weighting][index], 9); + expect(phaseAt(KNOT_MS[weighting][index], weighting)).toBeCloseTo(units, 9); + }); + expect(realMs(MOMENTS.echoPeak, weighting)).toBe(WEIGHTINGS[weighting].te); + expect(realMs(MOMENTS.pulse180, weighting)).toBeCloseTo(WEIGHTINGS[weighting].te / 2, 9); + }); + + it.each(WEIGHTING_IDS)('keeps the %s map rising and its inverse exact', (weighting) => { + let previous = -1; + for (let units = 0; units <= CYCLE_UNITS; units += 5) { + const ms = realMs(units, weighting); + expect(ms).toBeGreaterThan(previous); + expect(phaseAt(ms, weighting)).toBeCloseTo(units, 6); + previous = ms; + } + }); + + it('clamps outside one repetition', () => { + expect(realMs(-10, 't1')).toBe(0); + expect(realMs(CYCLE_UNITS + 10, 't2')).toBe(2500); + expect(phaseAt(9000, 't2')).toBe(CYCLE_UNITS); + }); + + it('names the step, the gradient and the radio pulse at each unit', () => { + expect([0, 149, 150, 300, 420, 649, 650, 1000].map(phaseOf)).toEqual([ + 'excite', + 'excite', + 'encode', + 'refocus', + 'echo', + 'echo', + 'recover', + 'recover', + ]); + expect([75, 200, 360, 535, 800].map(activeGradient)).toEqual(['z', 'y', 'z', 'x', null]); + expect([75, 200, 360, 535, 800].map(rfPulse)).toEqual([90, null, 180, null, null]); + }); + + it('runs 6 repetitions a minute by default and fills the 64 lines in about two minutes at 30', () => { + expect(SPEED_RANGE).toEqual({ min: 2, max: 60, step: 1, default: 6 }); + expect(DEFAULT_SPEED).toBe(6); + expect(rate(DEFAULT_SPEED)).toBe(100); + expect((MODEL_SIZE * CYCLE_UNITS) / rate(PICTURE_SPEED)).toBe(128); + }); + + it('takes 128 s for a T1 scan and 640 s for a T2 scan of 256 lines', () => { + expect(scanSeconds('t1')).toBe(128); + expect(scanSeconds('t2')).toBe(640); + }); +}); diff --git a/explainers/mri-scanner/src/model/sequence.ts b/explainers/mri-scanner/src/model/sequence.ts new file mode 100644 index 00000000..39fdae58 --- /dev/null +++ b/explainers/mri-scanner/src/model/sequence.ts @@ -0,0 +1,117 @@ +import { clamp, lerp } from '@core/math'; +import type { Extent } from '@core/scene/regions'; +import type { GradientAxisId, MomentId, PhaseId, RfPulse, WeightingId } from '../ids'; +import { PHASE_IDS } from '../ids'; +import { MS_PER_S, REAL_LINES, SECONDS_PER_MINUTE } from './constants'; + +export interface Weighting { + tr: number; + te: number; +} + +export const WEIGHTINGS: Readonly> = { + t1: { tr: 500, te: 15 }, + t2: { tr: 2500, te: 100 }, +}; + +export const CYCLE_UNITS = 1000; + +export const PHASE_RANGES: Readonly> = { + excite: [0, 150], + encode: [150, 300], + refocus: [300, 420], + echo: [420, 650], + recover: [650, CYCLE_UNITS], +}; + +export const MOMENTS: Readonly> = { + pulse90: 75, + pulse180: 360, + echoPeak: 535, + repetitionEnd: 990, +}; + +export const LINE_DONE_UNITS = PHASE_RANGES.echo[1]; + +export const PULSE_MS = 3; +export const REFOCUS_HALF_MS = 1; +export const READOUT_HALF_MS = 4; + +export const DEFAULT_SPEED = 6; +export const PICTURE_SPEED = 30; +export const SPEED_RANGE = { min: 2, max: 60, step: 1, default: DEFAULT_SPEED } as const; + +const GRADIENTS: Readonly> = { + excite: 'z', + encode: 'y', + refocus: 'z', + echo: 'x', + recover: null, +}; + +const RF_PULSES: Readonly> = { + excite: 90, + encode: null, + refocus: 180, + echo: null, + recover: null, +}; + +const KNOT_UNITS = [ + PHASE_RANGES.excite[0], + PHASE_RANGES.encode[0], + PHASE_RANGES.refocus[0], + PHASE_RANGES.echo[0], + MOMENTS.echoPeak, + PHASE_RANGES.recover[0], + CYCLE_UNITS, +] as const; + +function knotMs({ tr, te }: Weighting): readonly number[] { + return [ + 0, + PULSE_MS, + te / 2 - REFOCUS_HALF_MS, + te / 2 + REFOCUS_HALF_MS, + te, + te + READOUT_HALF_MS, + tr, + ]; +} + +function piecewise(value: number, from: readonly number[], to: readonly number[]): number { + const last = from.length - 1; + const clamped = clamp(value, from[0], from[last]); + const index = from.findIndex((knot, at) => at > 0 && clamped <= knot); + const start = index - 1; + const span = from[index] - from[start]; + return lerp(to[start], to[index], (clamped - from[start]) / span); +} + +export function realMs(phase: number, weighting: WeightingId): number { + return piecewise(phase, KNOT_UNITS, knotMs(WEIGHTINGS[weighting])); +} + +export function phaseAt(ms: number, weighting: WeightingId): number { + return piecewise(ms, knotMs(WEIGHTINGS[weighting]), KNOT_UNITS); +} + +export function phaseOf(phase: number): PhaseId { + return PHASE_IDS.find((id) => phase < PHASE_RANGES[id][1]) ?? 'recover'; +} + +export function activeGradient(phase: number): GradientAxisId | null { + return GRADIENTS[phaseOf(phase)]; +} + +export function rfPulse(phase: number): RfPulse | null { + return RF_PULSES[phaseOf(phase)]; +} + +export function rate(speed: number): number { + return (CYCLE_UNITS * speed) / SECONDS_PER_MINUTE; +} + +export function scanSeconds(weighting: WeightingId): number { + return (WEIGHTINGS[weighting].tr * REAL_LINES) / MS_PER_S; +} diff --git a/explainers/mri-scanner/src/model/tissues.test.ts b/explainers/mri-scanner/src/model/tissues.test.ts new file mode 100644 index 00000000..8591dec2 --- /dev/null +++ b/explainers/mri-scanner/src/model/tissues.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_IDS, TISSUE_IDS } from '../ids'; +import { RELAXATION } from './tissues'; + +describe('tissue relaxation', () => { + it('takes the 1.5 T and 3 T times from the facts sheet', () => { + expect(RELAXATION.field15.whiteMatter).toEqual({ t1: 884, t2: 72 }); + expect(RELAXATION.field15.greyMatter).toEqual({ t1: 1124, t2: 95 }); + expect(RELAXATION.field15.fat).toEqual({ t1: 260, t2: 80 }); + expect(RELAXATION.field30.whiteMatter).toEqual({ t1: 1084, t2: 69 }); + expect(RELAXATION.field30.greyMatter).toEqual({ t1: 1820, t2: 99 }); + expect(RELAXATION.field30.fat).toEqual({ t1: 382, t2: 68 }); + }); + + it('keeps fluid the slowest at both fields, with T2 shorter than T1 everywhere', () => { + for (const field of FIELD_IDS) { + expect(RELAXATION[field].fluid).toEqual({ t1: 4300, t2: 2000 }); + for (const tissue of TISSUE_IDS) { + expect(RELAXATION[field][tissue].t2).toBeLessThan(RELAXATION[field][tissue].t1); + } + } + }); +}); diff --git a/explainers/mri-scanner/src/model/tissues.ts b/explainers/mri-scanner/src/model/tissues.ts new file mode 100644 index 00000000..b1aad38e --- /dev/null +++ b/explainers/mri-scanner/src/model/tissues.ts @@ -0,0 +1,21 @@ +import type { FieldId, TissueId } from '../ids'; + +export interface Relaxation { + t1: number; + t2: number; +} + +export const RELAXATION: Readonly>>> = { + field15: { + fat: { t1: 260, t2: 80 }, + whiteMatter: { t1: 884, t2: 72 }, + greyMatter: { t1: 1124, t2: 95 }, + fluid: { t1: 4300, t2: 2000 }, + }, + field30: { + fat: { t1: 382, t2: 68 }, + whiteMatter: { t1: 1084, t2: 69 }, + greyMatter: { t1: 1820, t2: 99 }, + fluid: { t1: 4300, t2: 2000 }, + }, +}; diff --git a/explainers/mri-scanner/src/theme.test.ts b/explainers/mri-scanner/src/theme.test.ts new file mode 100644 index 00000000..07251584 --- /dev/null +++ b/explainers/mri-scanner/src/theme.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { THEME as CORE_THEME } from '@core/theme'; +import { GRADIENT_AXIS_IDS, PHASE_IDS, TISSUE_IDS } from './ids'; +import { GRADIENT_TONES, PHASE_TONES, THEME, TISSUE_TONES } from './theme'; + +describe('theme', () => { + it('keeps the core tokens', () => { + expect(THEME).toMatchObject(CORE_THEME); + }); + + it('colours the five steps amber, green, amber, cyan and grey', () => { + expect(Object.keys(PHASE_TONES)).toEqual([...PHASE_IDS]); + expect(PHASE_TONES.excite).toBe(THEME.rf); + expect(PHASE_TONES.encode).toBe(THEME.gradY); + expect(PHASE_TONES.refocus).toBe(THEME.rf); + expect(PHASE_TONES.echo).toBe(THEME.echo); + expect(PHASE_TONES.recover).toBe(THEME.rest); + }); + + it('gives each gradient axis and each tissue its own colour', () => { + expect(Object.keys(GRADIENT_TONES)).toEqual([...GRADIENT_AXIS_IDS]); + expect(new Set(Object.values(GRADIENT_TONES)).size).toBe(GRADIENT_AXIS_IDS.length); + expect(Object.keys(TISSUE_TONES)).toEqual([...TISSUE_IDS]); + expect(new Set(Object.values(TISSUE_TONES)).size).toBe(TISSUE_IDS.length); + }); +}); diff --git a/explainers/mri-scanner/src/theme.ts b/explainers/mri-scanner/src/theme.ts new file mode 100644 index 00000000..67b959e4 --- /dev/null +++ b/explainers/mri-scanner/src/theme.ts @@ -0,0 +1,64 @@ +import { THEME as CORE_THEME } from '@core/theme'; +import type { GradientAxisId, PhaseId, TissueId } from './ids'; + +export const THEME = { + ...CORE_THEME, + rf: '#ffb347', + gradX: '#ff7a59', + gradY: '#6fd38a', + gradZ: '#b48cff', + echo: '#4fd1c5', + rest: '#8b929c', + cover: '#e8ecef', + coverTrim: '#c8cdd2', + vacuumVessel: '#9aa3ad', + radiationShield: '#c9cfd6', + helium: '#9fd8ff', + winding: '#c8783c', + shieldWinding: '#a9632f', + shim: '#5a6068', + boreLiner: '#f4f5f6', + table: '#d8dce0', + cradle: '#eef0f2', + blanket: '#7fa7c9', + skin: '#e0b69a', + headCoil: '#f2f3f5', + copper: '#b87333', + copperDark: '#7a4a22', + floor: '#d9d6cf', + ceiling: '#cfd3d8', + windowGlass: '#16222e', + consoleGlow: '#3a5a7a', + screenFrame: '#1b1d20', + fieldLine: '#5fa8ff', + fringe: '#ffd24c', + mainField: '#5fa8ff', + spinArrow: '#e6e9ee', + netMagnet: '#ff5d73', + slice: '#ffb347', + fat: '#ffd27a', + whiteMatter: '#f0ece4', + greyMatter: '#a7a2b4', + fluid: '#5fb8ff', +} as const; + +export const PHASE_TONES: Readonly> = { + excite: THEME.rf, + encode: THEME.gradY, + refocus: THEME.rf, + echo: THEME.echo, + recover: THEME.rest, +}; + +export const GRADIENT_TONES: Readonly> = { + x: THEME.gradX, + y: THEME.gradY, + z: THEME.gradZ, +}; + +export const TISSUE_TONES: Readonly> = { + fat: THEME.fat, + whiteMatter: THEME.whiteMatter, + greyMatter: THEME.greyMatter, + fluid: THEME.fluid, +}; From d080ed95c433c58850ba782a58b2dbe955c7c955 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:41:43 +0200 Subject: [PATCH 02/82] feat(mri-scanner): add the assembly contract and placeholder --- explainers/mri-scanner/src/scene/assembly.ts | 25 ++ .../src/scene/placeholderAssembly.dom.test.ts | 83 +++++ .../src/scene/placeholderAssembly.ts | 333 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/assembly.ts create mode 100644 explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/placeholderAssembly.ts diff --git a/explainers/mri-scanner/src/scene/assembly.ts b/explainers/mri-scanner/src/scene/assembly.ts new file mode 100644 index 00000000..f75204c9 --- /dev/null +++ b/explainers/mri-scanner/src/scene/assembly.ts @@ -0,0 +1,25 @@ +import type { Box3, Group, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import type { SceneTextures } from '@core/scene/textures'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { createPlaceholderAssembly } from './placeholderAssembly'; + +export interface AssemblyResources { + materials: MaterialLibrary; + textures: SceneTextures; +} + +export interface Assembly { + readonly root: Group; + setState(state: AssemblyState): void; + update(deltaSeconds: number, cameraDistance: number): boolean; + labelAnchors(): ReadonlyMap; + anchor(id: AnchorId): Object3D; + region(id: RegionId): Box3; + warmUp?(compile: (object: Object3D) => void): void; + dispose(): void; +} + +export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { + return createPlaceholderAssembly(resources, state); +} diff --git a/explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts b/explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts new file mode 100644 index 00000000..0c263b38 --- /dev/null +++ b/explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { createSceneTextures } from '@core/scene/textures'; +import { PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, RegionId } from '../ids'; +import { FIELDS } from '../model/field'; +import { ISOCENTRE, SCREEN, VOXEL } from '../model/layout'; +import { MODEL_SIZE } from '../model/constants'; +import type { Assembly } from './assembly'; +import { createAssembly } from './assembly'; + +const ANCHOR_IDS: readonly AnchorId[] = ['isocentre', 'voxel', 'headCoil', 'screen', 'coldHead']; +const REGION_IDS: readonly RegionId[] = ['room', 'scanner', 'layers', 'voxel', 'bore', 'console']; + +function stateWith(overrides: Partial = {}): AssemblyState { + return { + phase: 0, + playing: false, + field: 'field15', + weighting: 't2', + sequence: { phase: 0, step: 'excite', rf: 90, gradient: 'z', gradientLevel: 1, echo: 0 }, + spins: { net: [0, 0, -1], arrows: [], precession: 0 }, + tissue: 'whiteMatter', + gradientAxis: null, + picture: new Float32Array(MODEL_SIZE * MODEL_SIZE), + pictureVersion: 0, + fringe: FIELDS.field15.fringe, + view: { cutaway: false, fieldLines: false, voxel: false, labels: true }, + ...overrides, + }; +} + +function worldPoint(object: Object3D): readonly number[] { + object.updateWorldMatrix(true, false); + return object.getWorldPosition(new Vector3()).toArray(); +} + +let textures: ReturnType; +let assembly: Assembly; + +beforeAll(() => { + textures = createSceneTextures(); + assembly = createAssembly({ materials: new MaterialLibrary(), textures }, stateWith()); +}); + +afterAll(() => { + assembly.dispose(); + textures.dispose(); +}); + +describe('placeholder assembly', () => { + it('gives every part a label anchor', () => { + expect([...assembly.labelAnchors().keys()]).toEqual([...PART_IDS]); + }); + + it('places the named anchors at the layout points', () => { + ANCHOR_IDS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); + expect(worldPoint(assembly.anchor('isocentre'))).toEqual([...ISOCENTRE]); + expect(worldPoint(assembly.anchor('voxel'))).toEqual([...VOXEL.centre]); + expect(worldPoint(assembly.anchor('screen'))).toEqual([...SCREEN.centre]); + }); + + it('frames every region with a box that holds something', () => { + REGION_IDS.forEach((id) => expect(assembly.region(id).isEmpty()).toBe(false)); + expect(assembly.region('room').containsBox(assembly.region('scanner'))).toBe(true); + expect(assembly.region('scanner').containsBox(assembly.region('bore'))).toBe(true); + }); + + it('follows the view flags and keeps moving only while something turns', () => { + const view = { cutaway: true, fieldLines: true, voxel: true, labels: true }; + assembly.setState(stateWith({ playing: true, view })); + expect(assembly.root.getObjectByName('cover')?.visible).toBe(false); + expect(assembly.root.getObjectByName('spinArrows')?.visible).toBe(true); + expect(assembly.root.getObjectByName('fringeLine')?.visible).toBe(true); + expect(assembly.update(0.016, 5)).toBe(true); + assembly.setState(stateWith()); + expect(assembly.root.getObjectByName('cover')?.visible).toBe(true); + expect(assembly.root.getObjectByName('spinArrows')?.visible).toBe(false); + expect(assembly.update(0.016, 5)).toBe(false); + }); +}); diff --git a/explainers/mri-scanner/src/scene/placeholderAssembly.ts b/explainers/mri-scanner/src/scene/placeholderAssembly.ts new file mode 100644 index 00000000..7315b2cc --- /dev/null +++ b/explainers/mri-scanner/src/scene/placeholderAssembly.ts @@ -0,0 +1,333 @@ +import { CylinderGeometry, Group, Mesh } from 'three'; +import type { BufferGeometry, Box3, Object3D } from 'three'; +import { clamp } from '@core/math'; +import { box } from '@core/scene/geometry/box'; +import type { BoxBounds } from '@core/scene/geometry/box'; +import { anchorAt } from '@core/scene/parts'; +import { regionFromSpec } from '@core/scene/regions'; +import type { AnchorId, AssemblyState, GradientAxisId, PartId, Point, RegionId } from '../ids'; +import { GRADIENT_AXIS_IDS, PART_IDS } from '../ids'; +import { + BODY_COIL, + BORE, + COLD_HEAD, + FLOOR_Y, + HEAD_COIL, + ISOCENTRE, + LAYERS, + MAGNET, + MAIN_COILS, + MAIN_FIELD_ARROW, + PATIENT, + QUENCH_PIPE, + REGIONS, + ROOM, + SCREEN, + SHIELD_COILS, + SHIMS, + SLICE_THICKNESS, + TABLE, + VOXEL, + gradientShell, +} from '../model/layout'; +import type { CoilRings, Shell } from '../model/layout'; +import { GRADIENT_TONES, THEME } from '../theme'; +import type { Assembly, AssemblyResources } from './assembly'; + +const SEGMENTS = 32; +const FLOOR_THICKNESS = 0.02; +const ARROW_THICKNESS = 0.03; +const SCREEN_DEPTH = 0.03; +const ECHO_GAP = 0.02; +const ECHO_RADIUS = HEAD_COIL.radius + ECHO_GAP; +const FIELD_LINE_REACH = 1.5; +const DIAGONAL = Math.SQRT1_2; +const GRADIENT_PARTS: Readonly> = { + x: 'gradientX', + y: 'gradientY', + z: 'gradientZ', +}; + +function bounds(centre: Point, size: Point): BoxBounds { + const [x, y, z] = centre; + const [width, height, depth] = size; + return { + minX: x - width / 2, + maxX: x + width / 2, + minY: y - height / 2, + maxY: y + height / 2, + minZ: z - depth / 2, + maxZ: z + depth / 2, + }; +} + +function tube(radius: number, length: number): BufferGeometry { + return new CylinderGeometry(radius, radius, length, SEGMENTS, 1, true).rotateX(Math.PI / 2); +} + +function post(radius: number, from: number, to: number): BufferGeometry { + return new CylinderGeometry(radius, radius, to - from, SEGMENTS).translate(0, (from + to) / 2, 0); +} + +function diagonal(radius: number, z = 0): Point { + return [radius * DIAGONAL, ISOCENTRE[1] + radius * DIAGONAL, z]; +} + +const PART_ANCHORS: Readonly> = { + room: [ROOM.x[0], FLOOR_Y, ROOM.z[0]], + cover: diagonal(MAGNET.radius, MAGNET.halfLength), + vacuumVessel: diagonal(LAYERS.vacuumVessel.outer), + radiationShield: diagonal(LAYERS.radiationShield.outer), + heliumVessel: diagonal(LAYERS.heliumVessel.outer), + mainCoils: diagonal(MAIN_COILS.outer, MAIN_COILS.z[3]), + shieldCoils: diagonal(SHIELD_COILS.outer, SHIELD_COILS.z[1]), + shims: diagonal(SHIMS.radius), + coldHead: [COLD_HEAD.centre[0], COLD_HEAD.top, COLD_HEAD.centre[2]], + quenchPipe: [QUENCH_PIPE.x, QUENCH_PIPE.to, QUENCH_PIPE.z], + gradientX: diagonal(gradientShell('x').outer), + gradientY: diagonal(gradientShell('y').outer), + gradientZ: diagonal(gradientShell('z').outer), + bodyCoil: diagonal(BODY_COIL.radius), + bore: [0, ISOCENTRE[1] - BORE.radius, BORE.halfLength], + table: [TABLE.halfWidth, TABLE.top, TABLE.z[1]], + patient: [0, TABLE.top, PATIENT.feetZ], + headCoil: [0, ISOCENTRE[1] + HEAD_COIL.radius, 0], + sliceSlab: [BORE.radius, ISOCENTRE[1], 0], + spinArrows: VOXEL.centre, + netMagnet: [VOXEL.centre[0], VOXEL.centre[1] + VOXEL.size / 2, VOXEL.centre[2]], + mainField: MAIN_FIELD_ARROW.tail, + rfWave: diagonal(BODY_COIL.radius, -BODY_COIL.halfLength), + echoWave: diagonal(ECHO_RADIUS), + fieldLinesGroup: [0, ISOCENTRE[1], -BORE.halfLength - FIELD_LINE_REACH], + fringeLine: [0, FLOOR_Y, ROOM.z[0]], + screen: SCREEN.centre, +}; + +class PlaceholderAssembly implements Assembly { + readonly root = new Group(); + private readonly resources: AssemblyResources; + private readonly parts = new Map(); + private readonly anchors = new Map(); + private readonly named: Readonly>; + private readonly regions: Readonly>; + private state: AssemblyState; + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.resources = resources; + this.state = state; + for (const id of PART_IDS) { + const group = new Group(); + group.name = id; + this.parts.set(id, group); + this.root.add(group); + this.anchors.set(id, anchorAt(group, ...PART_ANCHORS[id])); + } + this.buildRoom(); + this.buildMagnet(); + this.buildPatient(); + this.buildInset(); + this.named = { + isocentre: anchorAt(this.root, ...ISOCENTRE), + voxel: anchorAt(this.root, ...VOXEL.centre), + headCoil: anchorAt(this.root, ...PART_ANCHORS.headCoil), + screen: anchorAt(this.root, ...SCREEN.centre), + coldHead: anchorAt(this.root, ...PART_ANCHORS.coldHead), + }; + this.regions = Object.fromEntries( + Object.entries(REGIONS).map(([id, spec]) => [id, regionFromSpec(spec)]), + ) as Record; + this.setState(state); + } + + private part(id: PartId): Group { + const group = this.parts.get(id); + if (!group) throw new Error(`Unknown part ${id}`); + return group; + } + + private add(id: PartId, color: string, geometry: BufferGeometry, at: Point = ISOCENTRE): Mesh { + const mesh = new Mesh(geometry, this.resources.materials.get(id, { color })); + mesh.position.set(...at); + this.part(id).add(mesh); + return mesh; + } + + private shell(id: PartId, color: string, { outer, halfLength }: Shell): void { + this.add(id, color, tube(outer, 2 * halfLength)); + } + + private rings(id: PartId, color: string, { outer, width, z }: CoilRings): void { + z.forEach((at) => this.add(id, color, tube(outer, width), [0, ISOCENTRE[1], at])); + } + + private buildRoom(): void { + const floor: Point = [0, FLOOR_Y - FLOOR_THICKNESS / 2, (ROOM.z[0] + ROOM.z[1]) / 2]; + const size: Point = [ROOM.x[1] - ROOM.x[0], FLOOR_THICKNESS, ROOM.z[1] - ROOM.z[0]]; + this.add('room', THEME.floor, box(bounds(floor, size)), [0, 0, 0]); + const screen = this.add( + 'screen', + THEME.screenFrame, + box(bounds([0, 0, 0], [SCREEN_DEPTH, SCREEN.height, SCREEN.width])), + SCREEN.centre, + ); + screen.rotation.y = SCREEN.yawTowardTable; + } + + private buildMagnet(): void { + this.shell('cover', THEME.cover, LAYERS.cover); + this.shell('vacuumVessel', THEME.vacuumVessel, LAYERS.vacuumVessel); + this.shell('radiationShield', THEME.radiationShield, LAYERS.radiationShield); + this.shell('heliumVessel', THEME.helium, LAYERS.heliumVessel); + this.rings('mainCoils', THEME.winding, MAIN_COILS); + this.rings('shieldCoils', THEME.shieldWinding, SHIELD_COILS); + this.add('shims', THEME.shim, tube(SHIMS.radius, 2 * SHIMS.halfLength)); + for (const axis of GRADIENT_AXIS_IDS) { + this.shell(GRADIENT_PARTS[axis], GRADIENT_TONES[axis], gradientShell(axis)); + } + this.add('bodyCoil', THEME.metal, tube(BODY_COIL.radius, 2 * BODY_COIL.halfLength)); + this.add('bore', THEME.boreLiner, tube(BORE.radius, 2 * BORE.halfLength)); + const turretFoot = 2 * COLD_HEAD.centre[1] - COLD_HEAD.top; + this.add('coldHead', THEME.metal, post(COLD_HEAD.radius, turretFoot, COLD_HEAD.top), [ + COLD_HEAD.centre[0], + 0, + COLD_HEAD.centre[2], + ]); + this.add( + 'quenchPipe', + THEME.metalDark, + post(QUENCH_PIPE.radius, QUENCH_PIPE.from, QUENCH_PIPE.to), + [QUENCH_PIPE.x, 0, QUENCH_PIPE.z], + ); + this.add('rfWave', THEME.rf, tube(BODY_COIL.radius, 2 * BODY_COIL.halfLength)); + this.add( + 'sliceSlab', + THEME.slice, + box(bounds([0, 0, 0], [2 * BORE.radius, 2 * BORE.radius, SLICE_THICKNESS])), + ); + this.add( + 'fieldLinesGroup', + THEME.fieldLine, + box( + bounds( + [0, 0, 0], + [ARROW_THICKNESS, ARROW_THICKNESS, 2 * (BORE.halfLength + FIELD_LINE_REACH)], + ), + ), + ); + this.add('fringeLine', THEME.fringe, box(bounds([0, 0, 0], [2, FLOOR_THICKNESS, 2])), [ + 0, + FLOOR_Y, + 0, + ]); + } + + private buildPatient(): void { + const [tableFrom, tableTo] = TABLE.z; + const [cradleFrom, cradleTo] = TABLE.cradleZ; + const cradleBase = TABLE.top - TABLE.cradleThickness; + const tableSize: Point = [2 * TABLE.halfWidth, cradleBase, tableTo - tableFrom]; + this.add( + 'table', + THEME.table, + box(bounds([0, cradleBase / 2, (tableFrom + tableTo) / 2], tableSize)), + [0, 0, 0], + ); + const cradleSize: Point = [ + 2 * TABLE.cradleHalfWidth, + TABLE.cradleThickness, + cradleTo - cradleFrom, + ]; + this.add( + 'table', + THEME.cradle, + box( + bounds( + [0, cradleBase + TABLE.cradleThickness / 2, (cradleFrom + cradleTo) / 2], + cradleSize, + ), + ), + [0, 0, 0], + ); + const headSize = 2 * PATIENT.headRadius; + this.add('patient', THEME.skin, box(bounds([0, 0, 0], [headSize, headSize, headSize]))); + const bodyFrom = PATIENT.head[2] + PATIENT.headRadius; + const bodySize: Point = [2 * PATIENT.halfWidth, headSize, PATIENT.feetZ - bodyFrom]; + this.add( + 'patient', + THEME.blanket, + box(bounds([0, TABLE.top + PATIENT.headRadius, (bodyFrom + PATIENT.feetZ) / 2], bodySize)), + [0, 0, 0], + ); + this.add('headCoil', THEME.headCoil, tube(HEAD_COIL.radius, HEAD_COIL.length)); + this.add('echoWave', THEME.echo, tube(ECHO_RADIUS, HEAD_COIL.length)); + } + + private buildInset(): void { + const { centre, size } = VOXEL; + this.add('spinArrows', THEME.spinArrow, box(bounds([0, 0, 0], [size, size, size])), centre); + this.add( + 'netMagnet', + THEME.netMagnet, + box(bounds([0, 0, 0], [ARROW_THICKNESS, ARROW_THICKNESS, size])), + centre, + ); + const { tail, length } = MAIN_FIELD_ARROW; + this.add( + 'mainField', + THEME.mainField, + box(bounds([0, 0, -length / 2], [ARROW_THICKNESS, ARROW_THICKNESS, length])), + tail, + ); + } + + setState(state: AssemblyState): void { + this.state = state; + const { view, sequence, fringe } = state; + const show = (id: PartId, visible: boolean): void => { + this.part(id).visible = visible; + }; + show('cover', !view.cutaway); + show('fieldLinesGroup', view.fieldLines); + show('fringeLine', view.fieldLines); + for (const id of ['spinArrows', 'netMagnet', 'mainField'] as const) show(id, view.voxel); + show('rfWave', sequence.rf !== null); + show('echoWave', sequence.echo > 0); + show('sliceSlab', sequence.rf !== null); + this.part('fringeLine').scale.set( + clamp(fringe.side, 0, ROOM.x[1]), + 1, + clamp(fringe.along, 0, Math.min(-ROOM.z[0], ROOM.z[1])), + ); + } + + update(): boolean { + const { playing, view } = this.state; + return playing && (view.voxel || view.fieldLines); + } + + labelAnchors(): ReadonlyMap { + return this.anchors; + } + + anchor(id: AnchorId): Object3D { + return this.named[id]; + } + + region(id: RegionId): Box3 { + return this.regions[id]; + } + + dispose(): void { + this.root.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose(); + }); + this.root.clear(); + } +} + +export function createPlaceholderAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new PlaceholderAssembly(resources, state); +} From 13a09298ccff1f20712092357a665dd3ee442df9 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:50:23 +0200 Subject: [PATCH 03/82] feat(mri-scanner): add the timeline, camera views, part info and page metadata --- explainers/mri-scanner/explainer.json | 12 ++ explainers/mri-scanner/public/cover.webp | Bin 0 -> 1246 bytes .../mri-scanner/public/social/og-image.png | Bin 0 -> 3160 bytes .../mri-scanner/src/scene/cameraViews.test.ts | 82 +++++++++ .../mri-scanner/src/scene/cameraViews.ts | 49 ++++++ .../mri-scanner/src/scene/partInfo.test.ts | 48 ++++++ explainers/mri-scanner/src/scene/partInfo.ts | 53 ++++++ .../mri-scanner/src/timeline.dom.test.ts | 86 +++++++++ explainers/mri-scanner/src/timeline.ts | 88 ++++++++++ scripts/cards/mri-scanner.html | 163 ++++++++++++++++++ 10 files changed, 581 insertions(+) create mode 100644 explainers/mri-scanner/explainer.json create mode 100644 explainers/mri-scanner/public/cover.webp create mode 100644 explainers/mri-scanner/public/social/og-image.png create mode 100644 explainers/mri-scanner/src/scene/cameraViews.test.ts create mode 100644 explainers/mri-scanner/src/scene/cameraViews.ts create mode 100644 explainers/mri-scanner/src/scene/partInfo.test.ts create mode 100644 explainers/mri-scanner/src/scene/partInfo.ts create mode 100644 explainers/mri-scanner/src/timeline.dom.test.ts create mode 100644 explainers/mri-scanner/src/timeline.ts create mode 100644 scripts/cards/mri-scanner.html diff --git a/explainers/mri-scanner/explainer.json b/explainers/mri-scanner/explainer.json new file mode 100644 index 00000000..2794585f --- /dev/null +++ b/explainers/mri-scanner/explainer.json @@ -0,0 +1,12 @@ +{ + "slug": "mri-scanner", + "tags": ["physics", "biology"], + "cover": "cover.webp", + "entry": "src/index.ts", + "chapters": "chapters.html", + "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"], + "social": { + "image": "social/og-image.png", + "alt": "A closed-bore MRI scanner in a shielded room, beside the title How an MRI scanner works" + } +} diff --git a/explainers/mri-scanner/public/cover.webp b/explainers/mri-scanner/public/cover.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b50f2e35f2528eb41b43bb88d4c6b8e11e1b6ec GIT binary patch literal 1246 zcmWIYbaT7L!oU#j>J$(bV4-jd$o|mBFqctl3G;3yyIiwbo=cP#EmYuOXyEP4%zId# zDDA*?_SVOuBDvowPi()PJW>62@`UfV)fL}w^K-tRGpBvuO-ugTO{3DI!7`d0MzaCQ UrGv#k28MtCpGlO-IWSNR0Ehpa)Bpeg literal 0 HcmV?d00001 diff --git a/explainers/mri-scanner/public/social/og-image.png b/explainers/mri-scanner/public/social/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..9b9accebe38152e54a7c5abdfaa5c4775e8653a5 GIT binary patch literal 3160 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz*NS;1Qa=-^oE;(fk)oc#WAGf*4yhFIT;u@ zST??QE%=`~_uwrDwH@z*Y=P=*M1T?uJc>Y~O#nzZb1*P8D6#+z5C9sJz`@ACV9){- zmvEw{GCEoXcDVBjpl4|BE!04wX$YcP1&+1^j!NkEyOFAky kA0c(}Xq%4GsxnV(^^^{llE3%P0vnDDp00i_>zopr00-DtyZ`_I literal 0 HcmV?d00001 diff --git a/explainers/mri-scanner/src/scene/cameraViews.test.ts b/explainers/mri-scanner/src/scene/cameraViews.test.ts new file mode 100644 index 00000000..3d915cb3 --- /dev/null +++ b/explainers/mri-scanner/src/scene/cameraViews.test.ts @@ -0,0 +1,82 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { frameBox } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import { regionFromSpec } from '@core/scene/regions'; +import type { CameraView } from '../ids'; +import { CONTROL_WINDOW, FLOOR_Y, MAGNET, REGIONS } from '../model'; +import { CAMERA_VIEWS, STAGE_VARIANTS, stageVariant } from './cameraViews'; +import type { StageVariant } from './cameraViews'; + +const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; +const TABLE_END_VIEWS: readonly CameraView[] = ['room', 'cryostat', 'voxel', 'coil']; +const SLOPES: Readonly> = { + phone: { vertical: 0.75, horizontal: 0.38 }, + desktop: { vertical: 0.42, horizontal: 0.75 }, +}; + +function cameraOf(view: CameraView, variant: StageVariant): Vector3 { + const spec = CAMERA_VIEWS[view]; + const direction = new Vector3(...spec.direction[variant]).normalize(); + const box = regionFromSpec(REGIONS[spec.region]); + return frameBox(box, direction, SLOPES[variant], spec.margin).position; +} + +function eachVariant(check: (variant: StageVariant) => void): void { + STAGE_VARIANTS.forEach(check); +} + +describe('camera views', () => { + it('offers the six fixed views, none following anything', () => { + expect(VIEWS).toEqual(['room', 'cryostat', 'voxel', 'coil', 'gradient', 'console']); + VIEWS.forEach((view) => { + expect(CAMERA_VIEWS[view].follow, view).toBeUndefined(); + expect(REGIONS[CAMERA_VIEWS[view].region], view).toBeDefined(); + }); + }); + + it('picks the phone framing below 600 pixels of stage width', () => { + expect(stageVariant(599)).toBe('phone'); + expect(stageVariant(600)).toBe('desktop'); + }); + + it('keeps every camera above the floor', () => { + VIEWS.forEach((view) => + eachVariant((variant) => expect(cameraOf(view, variant).y, view).toBeGreaterThan(FLOOR_Y)), + ); + }); + + it('looks from the table end on the window side for the scanner views', () => { + TABLE_END_VIEWS.forEach((view) => + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS[view].direction[variant]; + expect(x, view).toBeGreaterThanOrEqual(0); + expect(z, view).toBeGreaterThan(0); + expect(cameraOf(view, variant).z, view).toBeGreaterThan(MAGNET.halfLength); + }), + ); + }); + + it('looks into the cutaway along x for the gradient shells', () => { + const [x, y, z] = CAMERA_VIEWS.gradient.direction.desktop; + expect(x).toBeGreaterThan(Math.max(Math.abs(y), Math.abs(z))); + }); + + it('turns more end-on on a phone so the rings and arrows stay large', () => { + (['cryostat', 'voxel'] as const).forEach((view) => { + const ratio = (variant: StageVariant): number => { + const [x, , z] = CAMERA_VIEWS[view].direction[variant]; + return x / z; + }; + expect(ratio('phone'), view).toBeLessThan(ratio('desktop')); + }); + }); + + it('looks from behind the table toward the screen at the control window', () => { + eachVariant((variant) => { + const camera = cameraOf('console', variant); + expect(camera.x).toBeLessThan(CONTROL_WINDOW.x); + expect(camera.z).toBeGreaterThan(CONTROL_WINDOW.centreZ); + }); + }); +}); diff --git a/explainers/mri-scanner/src/scene/cameraViews.ts b/explainers/mri-scanner/src/scene/cameraViews.ts new file mode 100644 index 00000000..40200989 --- /dev/null +++ b/explainers/mri-scanner/src/scene/cameraViews.ts @@ -0,0 +1,49 @@ +import type { Direction, FramedView } from '@core/scene/cameraViews'; +import type { CameraView, RegionId } from '../ids'; + +export const STAGE_VARIANTS = ['phone', 'desktop'] as const; + +export type StageVariant = (typeof STAGE_VARIANTS)[number]; + +export interface StageView extends FramedView { + direction: Readonly>; +} + +const PHONE_STAGE_WIDTH_PX = 600; + +export function stageVariant(width: number): StageVariant { + return width < PHONE_STAGE_WIDTH_PX ? 'phone' : 'desktop'; +} + +export const CAMERA_VIEWS: Readonly> = { + room: { + region: 'room', + direction: { phone: [0.5, 0.5, 1], desktop: [0.75, 0.45, 1] }, + margin: 0.85, + }, + cryostat: { + region: 'layers', + direction: { phone: [0.3, 0.25, 1], desktop: [0.5, 0.35, 1] }, + margin: 1.2, + }, + voxel: { + region: 'voxel', + direction: { phone: [0.12, 0.08, 1], desktop: [0.3, 0.15, 1] }, + margin: 1.35, + }, + coil: { + region: 'bore', + direction: { phone: [0.7, 0.6, 1], desktop: [1, 0.55, 0.75] }, + margin: 1.15, + }, + gradient: { + region: 'bore', + direction: { phone: [1, 0.35, 0.6], desktop: [1, 0.25, 0.2] }, + margin: 1.1, + }, + console: { + region: 'console', + direction: { phone: [-1, 0.35, 0.65], desktop: [-1, 0.3, 0.45] }, + margin: 2.8, + }, +}; diff --git a/explainers/mri-scanner/src/scene/partInfo.test.ts b/explainers/mri-scanner/src/scene/partInfo.test.ts new file mode 100644 index 00000000..a6af3126 --- /dev/null +++ b/explainers/mri-scanner/src/scene/partInfo.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS } from '../ids'; +import { LABEL_PRIORITY, PART_INFO } from './partInfo'; + +describe('part labels', () => { + it('names every part from its locale key', () => { + PART_IDS.forEach((id) => expect(PART_INFO[id].labelKey).toBe(`parts.${id}`)); + expect(Object.keys(PART_INFO)).toHaveLength(PART_IDS.length); + }); + + it('ranks every part exactly once, the net magnet first and the screen second', () => { + expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort()); + expect(LABEL_PRIORITY.slice(0, 3)).toEqual(['netMagnet', 'screen', 'headCoil']); + expect(LABEL_PRIORITY.indexOf('spinArrows')).toBe(13); + }); + + it('keeps the unranked parts in their part order after the ranked ones', () => { + const rest = LABEL_PRIORITY.slice(14); + expect(rest).toEqual(PART_IDS.filter((id) => rest.includes(id))); + expect(rest[0]).toBe('room'); + }); + + it('labels the magnet layers, the gradient shells and the patient on the left', () => { + ( + [ + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'table', + 'patient', + 'gradientX', + 'gradientY', + 'gradientZ', + 'spinArrows', + 'mainField', + 'rfWave', + 'fringeLine', + 'room', + ] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('left')); + ( + ['netMagnet', 'screen', 'headCoil', 'bodyCoil', 'coldHead', 'bore', 'echoWave'] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('right')); + }); +}); diff --git a/explainers/mri-scanner/src/scene/partInfo.ts b/explainers/mri-scanner/src/scene/partInfo.ts new file mode 100644 index 00000000..6c424f00 --- /dev/null +++ b/explainers/mri-scanner/src/scene/partInfo.ts @@ -0,0 +1,53 @@ +import type { PartInfo } from '@core/explainer'; +import type { LabelSide } from '@core/scene/labelLayout'; +import { PART_IDS } from '../ids'; +import type { PartId } from '../ids'; + +const LEFT_PARTS: ReadonlySet = new Set([ + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'table', + 'patient', + 'gradientX', + 'gradientY', + 'gradientZ', + 'spinArrows', + 'mainField', + 'rfWave', + 'fringeLine', + 'room', +]); + +const RANKED_FIRST: readonly PartId[] = [ + 'netMagnet', + 'screen', + 'headCoil', + 'mainCoils', + 'gradientZ', + 'gradientY', + 'gradientX', + 'bodyCoil', + 'coldHead', + 'bore', + 'echoWave', + 'rfWave', + 'heliumVessel', + 'spinArrows', +]; + +function labelSide(id: PartId): LabelSide { + return LEFT_PARTS.has(id) ? 'left' : 'right'; +} + +export const PART_INFO: Record = Object.fromEntries( + PART_IDS.map((id) => [id, { labelKey: `parts.${id}`, side: labelSide(id) }]), +) as Record; + +export const LABEL_PRIORITY: readonly PartId[] = [ + ...RANKED_FIRST, + ...PART_IDS.filter((id) => !RANKED_FIRST.includes(id)), +]; diff --git a/explainers/mri-scanner/src/timeline.dom.test.ts b/explainers/mri-scanner/src/timeline.dom.test.ts new file mode 100644 index 00000000..1398ada6 --- /dev/null +++ b/explainers/mri-scanner/src/timeline.dom.test.ts @@ -0,0 +1,86 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import en from '../locales/en.json'; +import { MOMENT_IDS, PHASE_IDS } from './ids'; +import type { PhaseId } from './ids'; +import { CYCLE_UNITS, PHASE_RANGES } from './model'; +import { PHASE_TONES } from './theme'; +import { + DEFAULT_SPEED, + MOMENTS, + MRI_SCANNER_TIMELINE, + PICTURE_SPEED, + SPEED_RANGE, + describePhase, + describeSpeed, + formatPhase, + formatSpeed, + momentAt, +} from './timeline'; + +describe('mri scanner timeline', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('loops one repetition through the five steps of the spin echo', () => { + const { phases, cycle, loop } = MRI_SCANNER_TIMELINE; + expect(cycle).toBe(CYCLE_UNITS); + expect(loop).toBe(true); + expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]); + phases.forEach((phase) => { + const [start, end] = PHASE_RANGES[phase.id as PhaseId]; + expect(phase).toMatchObject({ start, end }); + }); + expect(phases[0].start).toBe(0); + expect(phases.at(-1)?.end).toBe(cycle); + phases.slice(1).forEach((phase, index) => expect(phase.start).toBe(phases[index].end)); + }); + + it('colours each step with its theme tone and names it from the locale', () => { + expect(MRI_SCANNER_TIMELINE.phases.map((phase) => phase.tone)).toEqual( + PHASE_IDS.map((id) => PHASE_TONES[id]), + ); + MRI_SCANNER_TIMELINE.phases.forEach((phase) => { + expect(phase.labelKey).toBe(`timeline.phase.${phase.id}`); + expect(phase.jumpLabelKey).toBe(`timeline.jump.${phase.id}`); + }); + }); + + it('scrubs one unit at a time and nudges five, or fifty with shift', () => { + expect(MRI_SCANNER_TIMELINE.step).toBe(1); + expect(MRI_SCANNER_TIMELINE.nudge).toEqual({ fine: 5, coarse: 50 }); + expect(MRI_SCANNER_TIMELINE.labelKey).toBe('timeline.label'); + expect(MRI_SCANNER_TIMELINE.phasesLabelKey).toBe('timeline.phases'); + }); + + it('counts speed in repetitions per minute, six by default and thirty for the picture', () => { + const { rate, speed } = MRI_SCANNER_TIMELINE; + expect(speed).toMatchObject({ min: 2, max: 60, step: 1, labelKey: 'timeline.speed' }); + expect(DEFAULT_SPEED).toBe(6); + expect(SPEED_RANGE.default).toBe(DEFAULT_SPEED); + expect(PICTURE_SPEED).toBe(30); + expect(CYCLE_UNITS / rate(DEFAULT_SPEED)).toBe(10); + expect(CYCLE_UNITS / rate(PICTURE_SPEED)).toBe(2); + }); + + it('names the step on the scrubber, never a time or a percentage', () => { + expect(formatPhase(0)).toBe('Radio pulse'); + expect(formatPhase(PHASE_RANGES.echo[0])).toBe('Echo'); + expect(formatPhase(CYCLE_UNITS - 1)).toBe('Recover'); + PHASE_IDS.forEach((id) => expect(formatPhase(PHASE_RANGES[id][0])).not.toMatch(/\d|%/)); + }); + + it('tells a screen reader which step of five is playing', () => { + expect(describePhase(0)).toBe('Step 1 of 5, Radio pulse'); + expect(describePhase(PHASE_RANGES.refocus[0])).toBe('Step 3 of 5, Refocus'); + }); + + it('shows the speed short on the slider and in full words for a screen reader', () => { + expect(formatSpeed(6)).toBe('6/min'); + expect(describeSpeed(30)).toBe('30 repetitions per minute'); + }); + + it('finds the moment a paused phase sits on', () => { + MOMENT_IDS.forEach((id) => expect(momentAt(MOMENTS[id])).toBe(id)); + expect(momentAt(MOMENTS.pulse90 + 1)).toBeNull(); + }); +}); diff --git a/explainers/mri-scanner/src/timeline.ts b/explainers/mri-scanner/src/timeline.ts new file mode 100644 index 00000000..cd04ea81 --- /dev/null +++ b/explainers/mri-scanner/src/timeline.ts @@ -0,0 +1,88 @@ +import type { Phase, Timeline } from '@core/explainer'; +import { formatNumber } from '@core/format'; +import { t } from '@core/i18n'; +import { MOMENT_IDS, PHASE_IDS } from './ids'; +import type { MomentId, PhaseId } from './ids'; +import { + CYCLE_UNITS, + DEFAULT_SPEED, + MOMENTS, + PHASE_RANGES, + PICTURE_SPEED, + SPEED_RANGE, + phaseOf, + rate, +} from './model'; +import { PHASE_TONES } from './theme'; + +const SCRUBBER_STEP_UNITS = 1; +const FINE_STEP_UNITS = 5; +const COARSE_STEP_UNITS = 50; + +function phaseKey(id: PhaseId): string { + return `timeline.phase.${id}`; +} + +function jumpKey(id: PhaseId): string { + return `timeline.jump.${id}`; +} + +function phaseName(phase: number): string { + return t(phaseKey(phaseOf(phase))); +} + +export function formatPhase(phase: number): string { + return t('timeline.value', { phase: phaseName(phase) }); +} + +export function describePhase(phase: number): string { + return t('timeline.during', { + step: formatNumber(PHASE_IDS.indexOf(phaseOf(phase)) + 1), + total: formatNumber(PHASE_IDS.length), + phase: phaseName(phase), + }); +} + +export function formatSpeed(speed: number): string { + return t('timeline.speedValue', { value: formatNumber(speed) }); +} + +export function describeSpeed(speed: number): string { + return t('timeline.speedFormat', { value: formatNumber(speed) }); +} + +export function momentAt(phase: number): MomentId | null { + return MOMENT_IDS.find((id) => MOMENTS[id] === phase) ?? null; +} + +const SEQUENCE_PHASES: readonly Phase[] = PHASE_IDS.map((id) => ({ + id, + start: PHASE_RANGES[id][0], + end: PHASE_RANGES[id][1], + labelKey: phaseKey(id), + jumpLabelKey: jumpKey(id), + tone: PHASE_TONES[id], +})); + +export const MRI_SCANNER_TIMELINE: Timeline = { + cycle: CYCLE_UNITS, + loop: true, + step: SCRUBBER_STEP_UNITS, + nudge: { fine: FINE_STEP_UNITS, coarse: COARSE_STEP_UNITS }, + labelKey: 'timeline.label', + phasesLabelKey: 'timeline.phases', + formatPhase, + describePhase, + rate, + phases: SEQUENCE_PHASES, + speed: { + min: SPEED_RANGE.min, + max: SPEED_RANGE.max, + step: SPEED_RANGE.step, + labelKey: 'timeline.speed', + format: formatSpeed, + describe: describeSpeed, + }, +}; + +export { DEFAULT_SPEED, MOMENTS, PICTURE_SPEED, SPEED_RANGE }; diff --git a/scripts/cards/mri-scanner.html b/scripts/cards/mri-scanner.html new file mode 100644 index 00000000..4f3bbc9a --- /dev/null +++ b/scripts/cards/mri-scanner.html @@ -0,0 +1,163 @@ + + + + + How an MRI scanner works: social card + + + + + + +
+ +
+
+

+ + Medical imaging +

+

How an MRI scanner works

+

+ A giant magnet, a radio pulse and the hydrogen in your body: how a scanner hears a faint + echo and turns it into a picture. +

+
    +
  1. Magnet
  2. +
  3. Spins
  4. +
  5. Pulse
  6. +
  7. Gradients
  8. +
  9. Lines
  10. +
  11. Picture
  12. +
+
+

whatmakesittick.github.io/mri-scanner

+ + + From dad0615fc0a0022f45558596866b97940cc87546 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:51:43 +0200 Subject: [PATCH 04/82] feat(mri-scanner): add the signal, spin, gradient, phantom and k-space model --- .../mri-scanner/src/model/gradient.test.ts | 43 +++++ explainers/mri-scanner/src/model/gradient.ts | 40 +++++ explainers/mri-scanner/src/model/index.ts | 5 + .../mri-scanner/src/model/kspace.test.ts | 94 +++++++++++ explainers/mri-scanner/src/model/kspace.ts | 148 ++++++++++++++++++ .../mri-scanner/src/model/phantom.test.ts | 48 ++++++ explainers/mri-scanner/src/model/phantom.ts | 60 +++++++ .../mri-scanner/src/model/signal.test.ts | 45 ++++++ explainers/mri-scanner/src/model/signal.ts | 46 ++++++ .../mri-scanner/src/model/spins.test.ts | 113 +++++++++++++ explainers/mri-scanner/src/model/spins.ts | 143 +++++++++++++++++ package-lock.json | 7 + package.json | 1 + 13 files changed, 793 insertions(+) create mode 100644 explainers/mri-scanner/src/model/gradient.test.ts create mode 100644 explainers/mri-scanner/src/model/gradient.ts create mode 100644 explainers/mri-scanner/src/model/kspace.test.ts create mode 100644 explainers/mri-scanner/src/model/kspace.ts create mode 100644 explainers/mri-scanner/src/model/phantom.test.ts create mode 100644 explainers/mri-scanner/src/model/phantom.ts create mode 100644 explainers/mri-scanner/src/model/signal.test.ts create mode 100644 explainers/mri-scanner/src/model/signal.ts create mode 100644 explainers/mri-scanner/src/model/spins.test.ts create mode 100644 explainers/mri-scanner/src/model/spins.ts diff --git a/explainers/mri-scanner/src/model/gradient.test.ts b/explainers/mri-scanner/src/model/gradient.test.ts new file mode 100644 index 00000000..698ea069 --- /dev/null +++ b/explainers/mri-scanner/src/model/gradient.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + edgeShiftMT, + frequencySpreadKHz, + gradientLevel, + RAMP_UNITS, + riseTimeMs, + shareOfField, +} from './gradient'; +import { PHASE_RANGES } from './sequence'; + +describe('gradient figures', () => { + it('shifts the field by 5.4 mT at the edge of a 24 cm head', () => { + expect(edgeShiftMT()).toBeCloseTo(5.4, 10); + }); + + it('is a small share of the main field', () => { + expect(shareOfField('field15')).toBeCloseTo(0.36, 10); + expect(shareOfField('field30')).toBeCloseTo(0.18, 10); + }); + + it('spreads the frequency by about 230 kHz', () => { + expect(frequencySpreadKHz()).toBeCloseTo(229.9, 1); + }); + + it('rises in 0.225 ms', () => { + expect(riseTimeMs()).toBeCloseTo(0.225, 10); + }); +}); + +describe('gradient level', () => { + it('is off while the spins recover', () => { + expect(gradientLevel(800)).toBe(0); + }); + + it('ramps up and down at the edges of each step', () => { + const [start, end] = PHASE_RANGES.encode; + expect(gradientLevel(start)).toBe(0); + expect(gradientLevel(start + RAMP_UNITS / 2)).toBeCloseTo(0.5, 10); + expect(gradientLevel((start + end) / 2)).toBe(1); + expect(gradientLevel(end - RAMP_UNITS / 4)).toBeCloseTo(0.25, 10); + }); +}); diff --git a/explainers/mri-scanner/src/model/gradient.ts b/explainers/mri-scanner/src/model/gradient.ts new file mode 100644 index 00000000..0330df14 --- /dev/null +++ b/explainers/mri-scanner/src/model/gradient.ts @@ -0,0 +1,40 @@ +import { clamp } from '@core/math'; +import type { FieldId } from '../ids'; +import { + GAMMA_MHZ_PER_T, + GRADIENT_MT_PER_M, + HEAD_HALF_FOV_M, + MS_PER_S, + SLEW_T_PER_M_S, +} from './constants'; +import { FIELDS } from './field'; +import { activeGradient, PHASE_RANGES, phaseOf } from './sequence'; + +const MT_PER_T = 1000; +const KHZ_PER_MHZ = 1000; +const PERCENT = 100; +export const RAMP_UNITS = 12; + +export function edgeShiftMT(): number { + return GRADIENT_MT_PER_M * HEAD_HALF_FOV_M; +} + +export function shareOfField(field: FieldId): number { + return (edgeShiftMT() / (FIELDS[field].tesla * MT_PER_T)) * PERCENT; +} + +export function frequencySpreadKHz(): number { + return GAMMA_MHZ_PER_T * (edgeShiftMT() / MT_PER_T) * KHZ_PER_MHZ; +} + +export function riseTimeMs(): number { + return (GRADIENT_MT_PER_M / MT_PER_T / SLEW_T_PER_M_S) * MS_PER_S; +} + +export function gradientLevel(phase: number): number { + if (activeGradient(phase) === null) return 0; + const [start, end] = PHASE_RANGES[phaseOf(phase)]; + const rampUp = (phase - start) / RAMP_UNITS; + const rampDown = (end - phase) / RAMP_UNITS; + return clamp(Math.min(rampUp, rampDown), 0, 1); +} diff --git a/explainers/mri-scanner/src/model/index.ts b/explainers/mri-scanner/src/model/index.ts index 002558c1..8505d250 100644 --- a/explainers/mri-scanner/src/model/index.ts +++ b/explainers/mri-scanner/src/model/index.ts @@ -1,6 +1,11 @@ export * from './constants'; export * from './field'; +export * from './gradient'; +export * from './kspace'; export * from './layout'; +export * from './phantom'; export * from './scale'; export * from './sequence'; +export * from './signal'; +export * from './spins'; export * from './tissues'; diff --git a/explainers/mri-scanner/src/model/kspace.test.ts b/explainers/mri-scanner/src/model/kspace.test.ts new file mode 100644 index 00000000..22c27b24 --- /dev/null +++ b/explainers/mri-scanner/src/model/kspace.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { MODEL_SIZE } from './constants'; +import { kspaceDisplay, kspaceOf, lineOrder, pictureFor, reconstruct } from './kspace'; +import { phantomImage } from './phantom'; + +const PARTIAL_LINES = 16; +const TOLERANCE = 1e-5; +const CENTROID_TOLERANCE = 0.5; +const HALF = MODEL_SIZE / 2; + +function normalised(image: Float32Array): Float32Array { + const brightest = Math.max(...image); + return image.map((value) => value / brightest); +} + +function edgeEnergy(image: Float32Array): number { + let energy = 0; + for (let row = 0; row < MODEL_SIZE; row += 1) { + for (let column = 1; column < MODEL_SIZE; column += 1) { + const pixel = row * MODEL_SIZE + column; + energy += (image[pixel] - image[pixel - 1]) ** 2; + if (row > 0) energy += (image[pixel] - image[pixel - MODEL_SIZE]) ** 2; + } + } + return energy; +} + +function centroid(image: Float32Array): [x: number, y: number] { + let total = 0; + let x = 0; + let y = 0; + image.forEach((value, pixel) => { + total += value; + x += value * (pixel % MODEL_SIZE); + y += value * Math.floor(pixel / MODEL_SIZE); + }); + return [x / total, y / total]; +} + +describe('line order', () => { + it('fills k-space from the centre outwards', () => { + const order = lineOrder(); + expect(order.slice(0, 5)).toEqual([0, 1, -1, 2, -2]); + expect(order).toHaveLength(MODEL_SIZE); + expect(new Set(order).size).toBe(MODEL_SIZE); + expect(order.at(-1)).toBe(-HALF); + }); +}); + +describe('reconstruction', () => { + const phantom = phantomImage('field15', 't2'); + const kspace = kspaceOf(phantom); + + it('gives back the phantom from a full k-space', () => { + const picture = reconstruct(kspace, MODEL_SIZE); + const expected = normalised(phantom); + picture.forEach((value, pixel) => + expect(Math.abs(value - expected[pixel])).toBeLessThan(TOLERANCE), + ); + }); + + it('blurs but does not move the head with only the middle lines', () => { + const full = reconstruct(kspace, MODEL_SIZE); + const partial = reconstruct(kspace, PARTIAL_LINES); + expect(edgeEnergy(partial)).toBeLessThan(edgeEnergy(full)); + const [fullX, fullY] = centroid(full); + const [partialX, partialY] = centroid(partial); + expect(Math.abs(partialX - fullX)).toBeLessThan(CENTROID_TOLERANCE); + expect(Math.abs(partialY - fullY)).toBeLessThan(CENTROID_TOLERANCE); + }); + + it('stays dark before the first line', () => { + expect(Math.max(...reconstruct(kspace, 0))).toBe(0); + }); +}); + +describe('memoised pictures', () => { + it('returns the same array for the same settings', () => { + expect(pictureFor('field30', 't1', PARTIAL_LINES)).toBe( + pictureFor('field30', 't1', PARTIAL_LINES), + ); + expect(kspaceDisplay('field30', 't1', PARTIAL_LINES)).toBe( + kspaceDisplay('field30', 't1', PARTIAL_LINES), + ); + }); + + it('shows filled lines around the centre on a 0 to 1 scale', () => { + const display = kspaceDisplay('field15', 't2', 1); + expect(display[HALF * MODEL_SIZE + HALF]).toBe(1); + expect(display[0]).toBe(0); + expect(display[(HALF + 1) * MODEL_SIZE + HALF]).toBe(0); + for (const value of display) expect(value).toBeLessThanOrEqual(1); + }); +}); diff --git a/explainers/mri-scanner/src/model/kspace.ts b/explainers/mri-scanner/src/model/kspace.ts new file mode 100644 index 00000000..21b86659 --- /dev/null +++ b/explainers/mri-scanner/src/model/kspace.ts @@ -0,0 +1,148 @@ +import { clamp } from '@core/math'; +import FFT from 'fft.js'; +import type { FieldId, WeightingId } from '../ids'; +import { MODEL_SIZE } from './constants'; +import { phantomImage } from './phantom'; + +interface ComplexTransform { + transform(out: Float64Array, data: Float64Array): void; + inverseTransform(out: Float64Array, data: Float64Array): void; +} + +type Direction = 'forward' | 'inverse'; + +const SIZE = MODEL_SIZE; +const HALF = SIZE / 2; +const PARTS = 2; +const PIXELS = SIZE * SIZE; + +const transformer: ComplexTransform = new FFT(SIZE); +const lineIn = new Float64Array(SIZE * PARTS); +const lineOut = new Float64Array(SIZE * PARTS); + +const kspaceCache = new Map(); +const pictureCache = new Map(); +const displayCache = new Map(); + +function memoised(cache: Map, key: string, build: () => T): T { + const cached = cache.get(key); + if (cached !== undefined) return cached; + const built = build(); + cache.set(key, built); + return built; +} + +function transformLine( + data: Float64Array, + start: number, + stride: number, + direction: Direction, +): void { + for (let step = 0; step < SIZE; step += 1) { + const at = (start + step * stride) * PARTS; + lineIn[step * PARTS] = data[at]; + lineIn[step * PARTS + 1] = data[at + 1]; + } + if (direction === 'forward') transformer.transform(lineOut, lineIn); + else transformer.inverseTransform(lineOut, lineIn); + for (let step = 0; step < SIZE; step += 1) { + const at = (start + step * stride) * PARTS; + data[at] = lineOut[step * PARTS]; + data[at + 1] = lineOut[step * PARTS + 1]; + } +} + +function transform2d(source: Float64Array, direction: Direction): Float64Array { + const data = Float64Array.from(source); + for (let row = 0; row < SIZE; row += 1) transformLine(data, row * SIZE, 1, direction); + for (let column = 0; column < SIZE; column += 1) transformLine(data, column, SIZE, direction); + return data; +} + +function magnitudeAt(data: Float64Array, pixel: number): number { + return Math.hypot(data[pixel * PARTS], data[pixel * PARTS + 1]); +} + +function storedRow(offset: number): number { + return (offset + SIZE) % SIZE; +} + +function filledRows(linesFilled: number): ReadonlySet { + const count = clamp(Math.floor(linesFilled), 0, SIZE); + return new Set(lineOrder().slice(0, count).map(storedRow)); +} + +function rowOf(pixel: number): number { + return Math.floor(pixel / SIZE); +} + +function keyOf(...parts: readonly (string | number)[]): string { + return parts.join('|'); +} + +export function kspaceOf(image: Float32Array): Float64Array { + const complex = new Float64Array(PIXELS * PARTS); + image.forEach((value, pixel) => { + complex[pixel * PARTS] = value; + }); + return transform2d(complex, 'forward'); +} + +export function lineOrder(): number[] { + const pairs = Array.from({ length: HALF - 1 }, (_, index) => [index + 1, -(index + 1)]); + return [0, ...pairs.flat(), -HALF]; +} + +export function reconstruct(kspace: Float64Array, linesFilled: number): Float32Array { + const rows = filledRows(linesFilled); + const masked = kspace.map((value, index) => + rows.has(rowOf(Math.floor(index / PARTS))) ? value : 0, + ); + const image = transform2d(masked, 'inverse'); + const picture = Float32Array.from({ length: PIXELS }, (_, pixel) => magnitudeAt(image, pixel)); + const brightest = Math.max(...picture); + return brightest === 0 ? picture : picture.map((value) => value / brightest); +} + +function kspaceFor(field: FieldId, weighting: WeightingId): Float64Array { + return memoised(kspaceCache, keyOf(field, weighting), () => + kspaceOf(phantomImage(field, weighting)), + ); +} + +export function pictureFor( + field: FieldId, + weighting: WeightingId, + linesFilled: number, +): Float32Array { + return memoised(pictureCache, keyOf(field, weighting, linesFilled), () => + reconstruct(kspaceFor(field, weighting), linesFilled), + ); +} + +function shiftedPixel(pixel: number): number { + const row = (rowOf(pixel) + HALF) % SIZE; + const column = ((pixel % SIZE) + HALF) % SIZE; + return row * SIZE + column; +} + +function logDisplay(kspace: Float64Array, rows: ReadonlySet): Float32Array { + const levels = Float32Array.from({ length: PIXELS }, (_, pixel) => + Math.log1p(magnitudeAt(kspace, pixel)), + ); + const top = Math.max(...levels); + return Float32Array.from({ length: PIXELS }, (_, pixel) => { + const source = shiftedPixel(pixel); + return rows.has(rowOf(source)) && top > 0 ? levels[source] / top : 0; + }); +} + +export function kspaceDisplay( + field: FieldId, + weighting: WeightingId, + linesFilled: number, +): Float32Array { + return memoised(displayCache, keyOf(field, weighting, linesFilled), () => + logDisplay(kspaceFor(field, weighting), filledRows(linesFilled)), + ); +} diff --git a/explainers/mri-scanner/src/model/phantom.test.ts b/explainers/mri-scanner/src/model/phantom.test.ts new file mode 100644 index 00000000..96e99739 --- /dev/null +++ b/explainers/mri-scanner/src/model/phantom.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { TISSUE_IDS } from '../ids'; +import { MODEL_SIZE } from './constants'; +import { phantomImage, phantomTissue } from './phantom'; +import { tissueSignals } from './signal'; + +const MIDDLE = MODEL_SIZE / 2; + +function columnThroughCentre(): (string | null)[] { + return Array.from({ length: MODEL_SIZE }, (_, y) => phantomTissue(MIDDLE, y)); +} + +describe('phantom head', () => { + it('leaves the corners as air', () => { + expect(phantomTissue(0, 0)).toBeNull(); + expect(phantomTissue(MODEL_SIZE - 1, MODEL_SIZE - 1)).toBeNull(); + }); + + it('nests scalp, skull, fluid, cortex and white matter from the edge inwards', () => { + const layers = columnThroughCentre().filter( + (tissue, index, all) => index === 0 || tissue !== all[index - 1], + ); + expect(layers.slice(0, 6)).toEqual([null, 'fat', null, 'fluid', 'greyMatter', 'whiteMatter']); + }); + + it('holds two fluid ventricles left and right of the middle', () => { + expect(phantomTissue(MIDDLE - 4, MIDDLE)).toBe('fluid'); + expect(phantomTissue(MIDDLE + 3, MIDDLE)).toBe('fluid'); + expect(phantomTissue(MIDDLE, MIDDLE)).toBe('whiteMatter'); + }); + + it('contains every tissue', () => { + const tissues = new Set( + columnThroughCentre().concat( + Array.from({ length: MODEL_SIZE }, (_, x) => phantomTissue(x, MIDDLE)), + ), + ); + for (const tissue of TISSUE_IDS) expect(tissues.has(tissue)).toBe(true); + }); + + it('paints each tissue with its signal', () => { + const image = phantomImage('field15', 't2'); + const signals = tissueSignals('field15', 't2'); + expect(image).toHaveLength(MODEL_SIZE * MODEL_SIZE); + expect(image[0]).toBe(0); + expect(image[MIDDLE * MODEL_SIZE + MIDDLE]).toBeCloseTo(signals.whiteMatter, 6); + }); +}); diff --git a/explainers/mri-scanner/src/model/phantom.ts b/explainers/mri-scanner/src/model/phantom.ts new file mode 100644 index 00000000..c6b0f164 --- /dev/null +++ b/explainers/mri-scanner/src/model/phantom.ts @@ -0,0 +1,60 @@ +import type { FieldId, TissueId, WeightingId } from '../ids'; +import { MODEL_SIZE } from './constants'; +import { tissueSignals } from './signal'; + +interface Ellipse { + centre: readonly [x: number, y: number]; + radii: readonly [x: number, y: number]; +} + +interface Layer { + shape: Ellipse; + tissue: TissueId | null; +} + +const HALF_SIZE = MODEL_SIZE / 2; +const PIXEL_CENTRE = 0.5; +const CENTRE = [0, 0] as const; + +const LAYERS: readonly Layer[] = [ + { shape: { centre: CENTRE, radii: [0.72, 0.9] }, tissue: 'fat' }, + { shape: { centre: CENTRE, radii: [0.66, 0.84] }, tissue: null }, + { shape: { centre: CENTRE, radii: [0.6, 0.78] }, tissue: 'fluid' }, + { shape: { centre: CENTRE, radii: [0.55, 0.73] }, tissue: 'greyMatter' }, + { shape: { centre: CENTRE, radii: [0.45, 0.62] }, tissue: 'whiteMatter' }, + { shape: { centre: [-0.11, -0.04], radii: [0.07, 0.22] }, tissue: 'fluid' }, + { shape: { centre: [0.11, -0.04], radii: [0.07, 0.22] }, tissue: 'fluid' }, +]; + +function toUnit(pixel: number): number { + return (pixel + PIXEL_CENTRE) / HALF_SIZE - 1; +} + +function contains({ centre, radii }: Ellipse, u: number, v: number): boolean { + const across = (u - centre[0]) / radii[0]; + const along = (v - centre[1]) / radii[1]; + return across * across + along * along <= 1; +} + +export function phantomTissue(x: number, y: number): TissueId | null { + const u = toUnit(x); + const v = toUnit(y); + return LAYERS.reduce( + (tissue, layer) => (contains(layer.shape, u, v) ? layer.tissue : tissue), + null, + ); +} + +let tissueMap: readonly (TissueId | null)[] | null = null; + +function phantomMap(): readonly (TissueId | null)[] { + tissueMap ??= Array.from({ length: MODEL_SIZE * MODEL_SIZE }, (_, index) => + phantomTissue(index % MODEL_SIZE, Math.floor(index / MODEL_SIZE)), + ); + return tissueMap; +} + +export function phantomImage(field: FieldId, weighting: WeightingId): Float32Array { + const signals = tissueSignals(field, weighting); + return Float32Array.from(phantomMap(), (tissue) => (tissue === null ? 0 : signals[tissue])); +} diff --git a/explainers/mri-scanner/src/model/signal.test.ts b/explainers/mri-scanner/src/model/signal.test.ts new file mode 100644 index 00000000..4dfddec6 --- /dev/null +++ b/explainers/mri-scanner/src/model/signal.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_IDS } from '../ids'; +import { + brightnessOrder, + relativeBrightness, + tipComponents, + tissueSignal, + tissueSignals, +} from './signal'; + +describe('tissue signal', () => { + it('follows the spin echo signal equation', () => { + const expected = (1 - Math.exp(-500 / 260)) * Math.exp(-15 / 80); + expect(tissueSignal('fat', 'field15', 't1')).toBeCloseTo(expected, 10); + }); + + it.each(FIELD_IDS)('makes fat brightest and fluid darkest on T1 at %s', (field) => { + expect(brightnessOrder(field, 't1')).toEqual(['fat', 'whiteMatter', 'greyMatter', 'fluid']); + }); + + it.each(FIELD_IDS)('makes fluid brightest and white matter dark on T2 at %s', (field) => { + const signals = tissueSignals(field, 't2'); + expect(brightnessOrder(field, 't2')[0]).toBe('fluid'); + expect(signals.fluid).toBeGreaterThan(signals.greyMatter); + expect(signals.greyMatter).toBeGreaterThan(signals.whiteMatter); + }); + + it('scales every tissue against the brightest one', () => { + const relative = relativeBrightness('field30', 't2'); + expect(relative.fluid).toBe(1); + for (const share of Object.values(relative)) { + expect(share).toBeGreaterThan(0); + expect(share).toBeLessThanOrEqual(1); + } + }); +}); + +describe('tip components', () => { + it('splits the net magnet along and across the field', () => { + expect(tipComponents(0)).toEqual({ along: 1, across: 0 }); + expect(tipComponents(90).along).toBeCloseTo(0, 12); + expect(tipComponents(90).across).toBeCloseTo(1, 12); + expect(tipComponents(180).along).toBeCloseTo(-1, 12); + }); +}); diff --git a/explainers/mri-scanner/src/model/signal.ts b/explainers/mri-scanner/src/model/signal.ts new file mode 100644 index 00000000..ec09be4f --- /dev/null +++ b/explainers/mri-scanner/src/model/signal.ts @@ -0,0 +1,46 @@ +import { toRadians } from '@core/math'; +import type { FieldId, TissueId, WeightingId } from '../ids'; +import { TISSUE_IDS } from '../ids'; +import { WEIGHTINGS } from './sequence'; +import { RELAXATION } from './tissues'; + +export interface TipComponents { + along: number; + across: number; +} + +export function tissueSignal(tissue: TissueId, field: FieldId, weighting: WeightingId): number { + const { t1, t2 } = RELAXATION[field][tissue]; + const { tr, te } = WEIGHTINGS[weighting]; + return (1 - Math.exp(-tr / t1)) * Math.exp(-te / t2); +} + +function mapTissues(value: (tissue: TissueId) => number): Record { + return Object.fromEntries(TISSUE_IDS.map((tissue) => [tissue, value(tissue)])) as Record< + TissueId, + number + >; +} + +export function tissueSignals(field: FieldId, weighting: WeightingId): Record { + return mapTissues((tissue) => tissueSignal(tissue, field, weighting)); +} + +export function brightnessOrder(field: FieldId, weighting: WeightingId): TissueId[] { + const signals = tissueSignals(field, weighting); + return [...TISSUE_IDS].sort((first, second) => signals[second] - signals[first]); +} + +export function relativeBrightness( + field: FieldId, + weighting: WeightingId, +): Record { + const signals = tissueSignals(field, weighting); + const brightest = Math.max(...Object.values(signals)); + return mapTissues((tissue) => signals[tissue] / brightest); +} + +export function tipComponents(angleDeg: number): TipComponents { + const angle = toRadians(angleDeg); + return { along: Math.cos(angle), across: Math.sin(angle) }; +} diff --git a/explainers/mri-scanner/src/model/spins.test.ts b/explainers/mri-scanner/src/model/spins.test.ts new file mode 100644 index 00000000..639a861a --- /dev/null +++ b/explainers/mri-scanner/src/model/spins.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import type { Point } from '../ids'; +import { CYCLE_UNITS, MOMENTS, PHASE_RANGES } from './sequence'; +import { tissueSignal } from './signal'; +import { echoAmplitude, magnetisation, SPIN_COUNT, spinArrows } from './spins'; + +const RIGHT_ANGLE = 90; +const LOOSE = 3; + +function length([x, y, z]: Point): number { + return Math.hypot(x, y, z); +} + +function expectPointClose(actual: Point, expected: Point, digits = LOOSE): void { + actual.forEach((value, axis) => expect(value).toBeCloseTo(expected[axis], digits)); +} + +describe('net magnetisation', () => { + it('rests along the main field, which points along minus z', () => { + const [x, y, z] = magnetisation(0, 't2', 'whiteMatter', 'field15', RIGHT_ANGLE); + expect(x).toBe(0); + expect(y).toBe(0); + expect(z).toBeLessThan(0); + }); + + it('lies across the field after a 90 degree pulse', () => { + const [across, , along] = magnetisation( + PHASE_RANGES.excite[1], + 't1', + 'fat', + 'field15', + RIGHT_ANGLE, + ); + expect(across).toBeGreaterThan(0.5); + expect(Math.abs(along)).toBeLessThan(0.05); + }); + + it('loses its sideways part while the spins fan out and gets it back at the echo', () => { + const fanned = magnetisation(MOMENTS.pulse180, 't2', 'greyMatter', 'field30', RIGHT_ANGLE); + const echo = magnetisation(MOMENTS.echoPeak, 't2', 'greyMatter', 'field30', RIGHT_ANGLE); + expect(fanned[0]).toBeCloseTo(0, 6); + expect(echo[0]).toBeCloseTo(tissueSignal('greyMatter', 'field30', 't2'), 6); + }); + + it('regrows along the field during recovery', () => { + const early = magnetisation( + PHASE_RANGES.recover[0], + 't1', + 'whiteMatter', + 'field15', + RIGHT_ANGLE, + ); + const late = magnetisation(MOMENTS.repetitionEnd, 't1', 'whiteMatter', 'field15', RIGHT_ANGLE); + expect(late[2]).toBeLessThan(early[2]); + }); + + it.each([30, RIGHT_ANGLE, 150])('closes the loop smoothly for a %d degree pulse', (tip) => { + const start = magnetisation(0, 't1', 'greyMatter', 'field15', tip); + const end = magnetisation(CYCLE_UNITS, 't1', 'greyMatter', 'field15', tip); + expectPointClose(start, end, 6); + }); +}); + +describe('spin arrows', () => { + it('draws the same 64 unit arrows every time', () => { + const arrows = spinArrows(MOMENTS.pulse180, 't2', 'fluid', 'field15', RIGHT_ANGLE); + expect(arrows).toHaveLength(SPIN_COUNT); + for (const arrow of arrows) expect(length(arrow)).toBeCloseTo(1, 10); + expect(spinArrows(MOMENTS.pulse180, 't2', 'fluid', 'field15', RIGHT_ANGLE)).toEqual(arrows); + }); + + it('gathers around the net vector at the echo', () => { + const net = magnetisation(MOMENTS.echoPeak, 't2', 'fluid', 'field15', RIGHT_ANGLE); + const arrows = spinArrows(MOMENTS.echoPeak, 't2', 'fluid', 'field15', RIGHT_ANGLE); + const mean = arrows.reduce( + (sum, arrow) => [sum[0] + arrow[0], sum[1] + arrow[1], sum[2] + arrow[2]], + [0, 0, 0], + ); + const cosine = + (mean[0] * net[0] + mean[1] * net[1] + mean[2] * net[2]) / (length(mean) * length(net)); + expect(cosine).toBeGreaterThan(0.95); + }); + + it('matches at both ends of the loop', () => { + const start = spinArrows(0, 't2', 'fluid', 'field15', RIGHT_ANGLE); + const end = spinArrows(CYCLE_UNITS, 't2', 'fluid', 'field15', RIGHT_ANGLE); + start.forEach((arrow, index) => expectPointClose(arrow, end[index])); + }); +}); + +describe('echo amplitude', () => { + it('peaks at the echo with the tissue signal and is silent outside the echo', () => { + const peak = echoAmplitude(MOMENTS.echoPeak, 't1', 'fat', 'field15', RIGHT_ANGLE); + expect(peak).toBeCloseTo(tissueSignal('fat', 'field15', 't1'), 6); + expect(echoAmplitude(MOMENTS.echoPeak - 40, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeLessThan( + peak, + ); + expect(echoAmplitude(MOMENTS.echoPeak + 40, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeLessThan( + peak, + ); + expect(echoAmplitude(PHASE_RANGES.echo[0], 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeCloseTo( + 0, + 6, + ); + expect(echoAmplitude(MOMENTS.pulse90, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBe(0); + }); + + it('shrinks with a smaller tip', () => { + const full = echoAmplitude(MOMENTS.echoPeak, 't2', 'fluid', 'field30', RIGHT_ANGLE); + const small = echoAmplitude(MOMENTS.echoPeak, 't2', 'fluid', 'field30', 30); + expect(small).toBeLessThan(full); + }); +}); diff --git a/explainers/mri-scanner/src/model/spins.ts b/explainers/mri-scanner/src/model/spins.ts new file mode 100644 index 00000000..938f2cbe --- /dev/null +++ b/explainers/mri-scanner/src/model/spins.ts @@ -0,0 +1,143 @@ +import { clamp, smoothstep, toRadians } from '@core/math'; +import type { FieldId, Point, TissueId, WeightingId } from '../ids'; +import { MOMENTS, PHASE_RANGES, realMs, WEIGHTINGS } from './sequence'; +import { RELAXATION } from './tissues'; + +interface SpinInput { + weighting: WeightingId; + tissue: TissueId; + field: FieldId; + tipDeg: number; +} + +interface SpinState { + along: number; + across: number; + spread: number; + flip: number; +} + +export const SPIN_COUNT = 64; +const SPIN_SEED = 20_260_312; +const JITTER = 0.18; +const FADE_START_UNITS = 800; +const UINT32_RANGE = 2 ** 32; +const MULBERRY = { increment: 0x6d2b79f5, shiftA: 15, shiftB: 7, shiftC: 14, mixB: 61 } as const; + +const RESTING: Point = [0, 0, -1]; + +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + MULBERRY.increment) >>> 0; + let value = Math.imul(state ^ (state >>> MULBERRY.shiftA), state | 1); + value ^= value + Math.imul(value ^ (value >>> MULBERRY.shiftB), value | MULBERRY.mixB); + return ((value ^ (value >>> MULBERRY.shiftC)) >>> 0) / UINT32_RANGE; + }; +} + +function randomUnit(random: () => number): Point { + const z = random() * 2 - 1; + const angle = random() * Math.PI * 2; + const ring = Math.sqrt(1 - z * z); + return [ring * Math.cos(angle), ring * Math.sin(angle), z]; +} + +const random = seededRandom(SPIN_SEED); +const OFFSETS = Array.from( + { length: SPIN_COUNT }, + (_, index) => ((index + 0.5) / SPIN_COUNT) * 2 - 1, +); +const JITTERS = OFFSETS.map(() => randomUnit(random)); + +function steadyAlong(t1: number, tr: number, tipCos: number): number { + const decay = Math.exp(-tr / t1); + return (1 - decay) / (1 - decay * tipCos); +} + +function dephasing(phase: number): number { + const [encodeStart, encodeEnd] = PHASE_RANGES.encode; + const [echoStart, echoEnd] = PHASE_RANGES.echo; + if (phase < encodeStart) return 0; + if (phase < encodeEnd) return (phase - encodeStart) / (encodeEnd - encodeStart); + if (phase < echoStart) return 1; + if (phase < MOMENTS.echoPeak) return (MOMENTS.echoPeak - phase) / (MOMENTS.echoPeak - echoStart); + return -clamp((phase - MOMENTS.echoPeak) / (echoEnd - MOMENTS.echoPeak), 0, 1); +} + +function spinState(phase: number, input: SpinInput): SpinState { + const { t1, t2 } = RELAXATION[input.field][input.tissue]; + const tip = toRadians(input.tipDeg); + const resting = steadyAlong(t1, WEIGHTINGS[input.weighting].tr, Math.cos(tip)); + const tipped = tip * smoothstep(phase, ...PHASE_RANGES.excite); + const elapsed = realMs(phase, input.weighting); + const fade = 1 - smoothstep(phase, FADE_START_UNITS, MOMENTS.repetitionEnd); + return { + along: 1 - (1 - resting * Math.cos(tipped)) * Math.exp(-elapsed / t1), + across: resting * Math.sin(tipped) * Math.exp(-elapsed / t2) * fade, + spread: Math.PI * dephasing(phase), + flip: Math.PI * smoothstep(phase, ...PHASE_RANGES.refocus), + }; +} + +function coherence(spread: number): number { + return spread === 0 ? 1 : Math.sin(spread) / spread; +} + +function transverse({ across, spread }: SpinState): number { + return across * coherence(Math.abs(spread)); +} + +export function magnetisation( + phase: number, + weighting: WeightingId, + tissue: TissueId, + field: FieldId, + tipDeg: number, +): Point { + const state = spinState(phase, { weighting, tissue, field, tipDeg }); + return [transverse(state), 0, -state.along]; +} + +function normalise([x, y, z]: Point): Point { + const length = Math.hypot(x, y, z); + return length === 0 ? RESTING : [x / length, y / length, z / length]; +} + +function spinArrow(state: SpinState, offset: number, jitter: Point): Point { + const angle = offset * state.spread; + const lift = Math.sin(angle) * state.across; + const base = normalise([ + Math.cos(angle) * state.across, + lift * Math.cos(state.flip), + lift * Math.sin(state.flip) - state.along, + ]); + return normalise([ + base[0] + jitter[0] * JITTER, + base[1] + jitter[1] * JITTER, + base[2] + jitter[2] * JITTER, + ]); +} + +export function spinArrows( + phase: number, + weighting: WeightingId, + tissue: TissueId, + field: FieldId, + tipDeg: number, +): readonly Point[] { + const state = spinState(phase, { weighting, tissue, field, tipDeg }); + return OFFSETS.map((offset, index) => spinArrow(state, offset, JITTERS[index])); +} + +export function echoAmplitude( + phase: number, + weighting: WeightingId, + tissue: TissueId, + field: FieldId, + tipDeg: number, +): number { + const [echoStart, echoEnd] = PHASE_RANGES.echo; + if (phase < echoStart || phase >= echoEnd) return 0; + return clamp(transverse(spinState(phase, { weighting, tissue, field, tipDeg })), 0, 1); +} diff --git a/package-lock.json b/package-lock.json index d5823913..77859efc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "fft.js": "^4.0.4", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "three": "^0.186.1", @@ -1348,6 +1349,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fft.js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz", + "integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", diff --git a/package.json b/package.json index 13fcf739..86f32295 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "fft.js": "^4.0.4", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "three": "^0.186.1", From 481bcad6f6768063d502d7b43a479cb32d059271 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:55:20 +0200 Subject: [PATCH 05/82] feat(mri-scanner): add the store, presets and derived assembly state --- .../mri-scanner/src/state/derived.test.ts | 146 +++++++++++++ explainers/mri-scanner/src/state/derived.ts | 169 +++++++++++++++ explainers/mri-scanner/src/state/index.ts | 3 + .../mri-scanner/src/state/presets.test.ts | 71 ++++++ explainers/mri-scanner/src/state/presets.ts | 99 +++++++++ .../mri-scanner/src/state/store.test.ts | 204 ++++++++++++++++++ explainers/mri-scanner/src/state/store.ts | 142 ++++++++++++ 7 files changed, 834 insertions(+) create mode 100644 explainers/mri-scanner/src/state/derived.test.ts create mode 100644 explainers/mri-scanner/src/state/derived.ts create mode 100644 explainers/mri-scanner/src/state/index.ts create mode 100644 explainers/mri-scanner/src/state/presets.test.ts create mode 100644 explainers/mri-scanner/src/state/presets.ts create mode 100644 explainers/mri-scanner/src/state/store.test.ts create mode 100644 explainers/mri-scanner/src/state/store.ts diff --git a/explainers/mri-scanner/src/state/derived.test.ts b/explainers/mri-scanner/src/state/derived.test.ts new file mode 100644 index 00000000..38260110 --- /dev/null +++ b/explainers/mri-scanner/src/state/derived.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_IDS, WEIGHTING_IDS } from '../ids'; +import { + CYCLE_UNITS, + FIELDS, + MOMENTS, + PHASE_RANGES, + SPIN_COUNT, + echoAmplitude, + gradientLevel, + magnetisation, + pictureFor, +} from '../model'; +import { + CHOSEN_AXIS_LEVEL, + PRECESSION_TURNS_PER_CYCLE, + axisLevel, + createAssemblyState, + followedAxis, + lineShare, + pictureVersionOf, + precessionAngle, + timeGauge, + writeAssemblyState, +} from './derived'; +import type { AssemblySource } from './derived'; +import { DEFAULT_VIEW } from './store'; + +const ENCODING = (PHASE_RANGES.encode[0] + PHASE_RANGES.encode[1]) / 2; +const RECOVERING = (PHASE_RANGES.recover[0] + PHASE_RANGES.recover[1]) / 2; + +function sourceAt(changes: Partial = {}): AssemblySource { + return { + phase: MOMENTS.echoPeak, + playing: true, + field: 'field15', + weighting: 't2', + tipAngle: 90, + tissue: 'whiteMatter', + gradientAxis: null, + linesFilled: 24, + view: { ...DEFAULT_VIEW }, + ...changes, + }; +} + +describe('readings', () => { + it('follows the sequence gradient unless an axis is chosen', () => { + expect(followedAxis(null, ENCODING)).toBe('y'); + expect(followedAxis(null, RECOVERING)).toBeNull(); + expect(followedAxis('x', RECOVERING)).toBe('x'); + }); + + it('ramps a followed gradient and holds a chosen one fully on', () => { + expect(axisLevel(null, ENCODING)).toBe(gradientLevel(ENCODING)); + expect(axisLevel('z', RECOVERING)).toBe(CHOSEN_AXIS_LEVEL); + }); + + it('turns the spins a whole number of times per loop so the wrap is seamless', () => { + expect(Number.isInteger(PRECESSION_TURNS_PER_CYCLE)).toBe(true); + expect(precessionAngle(0)).toBeCloseTo(0, 9); + expect(Math.cos(precessionAngle(CYCLE_UNITS))).toBeCloseTo(1, 9); + }); + + it('gives each scan and line count its own picture version', () => { + const versions = new Set(); + for (const field of FIELD_IDS) { + for (const weighting of WEIGHTING_IDS) { + for (let lines = 0; lines <= 64; lines += 1) { + versions.add(pictureVersionOf(field, weighting, lines)); + } + } + } + expect(versions.size).toBe(FIELD_IDS.length * WEIGHTING_IDS.length * 65); + }); + + it('reads the time gauge in real milliseconds of the repetition', () => { + expect(timeGauge(MOMENTS.echoPeak, 't2')).toMatchObject({ ms: 100, totalMs: 2500 }); + expect(timeGauge(MOMENTS.echoPeak, 't1')).toMatchObject({ ms: 15, totalMs: 500 }); + expect(timeGauge(CYCLE_UNITS / 2, 't1').share).toBeCloseTo(0.5, 9); + }); + + it('shares the lines out of the full picture', () => { + expect(lineShare(16)).toBe(0.25); + expect(lineShare(64)).toBe(1); + }); +}); + +describe('assembly state', () => { + it('builds the sequence and spin readings from the model', () => { + const state = createAssemblyState(sourceAt()); + expect(state.sequence).toMatchObject({ + phase: MOMENTS.echoPeak, + step: 'echo', + rf: null, + gradient: 'x', + echo: echoAmplitude(MOMENTS.echoPeak, 't2', 'whiteMatter', 'field15', 90), + }); + expect(state.spins.net).toEqual( + magnetisation(MOMENTS.echoPeak, 't2', 'whiteMatter', 'field15', 90), + ); + expect(state.spins.arrows).toHaveLength(SPIN_COUNT); + expect(state.fringe).toEqual(FIELDS.field15.fringe); + expect(state.fringe).not.toBe(FIELDS.field15.fringe); + expect(state.picture).toBe(pictureFor('field15', 't2', 24)); + }); + + it('writes into the same target objects frame after frame', () => { + const state = createAssemblyState(sourceAt()); + const { sequence, spins, fringe, view } = state; + const written = writeAssemblyState(state, sourceAt({ phase: ENCODING, field: 'field30' })); + expect(written).toBe(state); + expect(written.sequence).toBe(sequence); + expect(written.spins).toBe(spins); + expect(written.fringe).toBe(fringe); + expect(written.view).toBe(view); + expect(written.sequence.gradient).toBe('y'); + expect(written.fringe).toEqual(FIELDS.field30.fringe); + }); + + it('changes the picture version only when the scan or its lines change', () => { + const state = createAssemblyState(sourceAt()); + const first = state.pictureVersion; + writeAssemblyState(state, sourceAt({ phase: RECOVERING, tissue: 'fat', tipAngle: 30 })); + expect(state.pictureVersion).toBe(first); + writeAssemblyState(state, sourceAt({ linesFilled: 25 })); + expect(state.pictureVersion).not.toBe(first); + expect(state.picture).toBe(pictureFor('field15', 't2', 25)); + }); + + it('keeps two buffers consistent when they alternate', () => { + const front = createAssemblyState(sourceAt()); + const back = createAssemblyState(sourceAt()); + writeAssemblyState(back, sourceAt({ weighting: 't1' })); + writeAssemblyState(front, sourceAt({ weighting: 't1' })); + expect(front.pictureVersion).toBe(back.pictureVersion); + expect(front.picture).toBe(back.picture); + }); + + it('copies the view flags without sharing the store object', () => { + const view = { ...DEFAULT_VIEW, voxel: true }; + const state = createAssemblyState(sourceAt({ view })); + expect(state.view).toEqual(view); + expect(state.view).not.toBe(view); + }); +}); diff --git a/explainers/mri-scanner/src/state/derived.ts b/explainers/mri-scanner/src/state/derived.ts new file mode 100644 index 00000000..b2075767 --- /dev/null +++ b/explainers/mri-scanner/src/state/derived.ts @@ -0,0 +1,169 @@ +import { FULL_TURN, wrapAngle } from '@core/math'; +import { FIELD_IDS, WEIGHTING_IDS } from '../ids'; +import type { + AssemblyState, + FieldId, + GradientAxisId, + Point, + SequenceReading, + SpinReading, + WeightingId, +} from '../ids'; +import { + CYCLE_UNITS, + DISPLAY_TURNS_PER_S, + FIELDS, + SECONDS_PER_MINUTE, + WEIGHTINGS, + activeGradient, + echoAmplitude, + gradientLevel, + magnetisation, + phaseOf, + pictureFor, + realMs, + rfPulse, + spinArrows, +} from '../model'; +import { DEFAULT_SPEED } from '../timeline'; +import { LINES_RANGE } from './store'; +import type { MriScannerState } from './store'; + +export type AssemblySource = Pick< + MriScannerState, + | 'phase' + | 'playing' + | 'field' + | 'weighting' + | 'tipAngle' + | 'tissue' + | 'gradientAxis' + | 'linesFilled' + | 'view' +>; + +type SpinSource = Pick; + +export interface TimeGauge { + ms: number; + totalMs: number; + share: number; +} + +export const PRECESSION_TURNS_PER_CYCLE = + (DISPLAY_TURNS_PER_S * SECONDS_PER_MINUTE) / DEFAULT_SPEED; +export const CHOSEN_AXIS_LEVEL = 1; + +const PICTURE_STATES_PER_WEIGHTING = LINES_RANGE.max + 1; +const RESTING_NET: Point = [0, 0, 0]; + +const EMPTY_SEQUENCE: SequenceReading = { + phase: 0, + step: 'excite', + rf: null, + gradient: null, + gradientLevel: 0, + echo: 0, +}; + +const EMPTY_SPINS: SpinReading = { net: RESTING_NET, arrows: [], precession: 0 }; + +export function followedAxis( + gradientAxis: GradientAxisId | null, + phase: number, +): GradientAxisId | null { + return gradientAxis ?? activeGradient(phase); +} + +export function axisLevel(gradientAxis: GradientAxisId | null, phase: number): number { + return gradientAxis === null ? gradientLevel(phase) : CHOSEN_AXIS_LEVEL; +} + +export function precessionAngle(phase: number): number { + return wrapAngle((FULL_TURN * PRECESSION_TURNS_PER_CYCLE * phase) / CYCLE_UNITS); +} + +export function pictureVersionOf( + field: FieldId, + weighting: WeightingId, + linesFilled: number, +): number { + const scan = FIELD_IDS.indexOf(field) * WEIGHTING_IDS.length + WEIGHTING_IDS.indexOf(weighting); + return scan * PICTURE_STATES_PER_WEIGHTING + linesFilled; +} + +export function timeGauge(phase: number, weighting: WeightingId): TimeGauge { + return { + ms: realMs(phase, weighting), + totalMs: WEIGHTINGS[weighting].tr, + share: phase / CYCLE_UNITS, + }; +} + +export function lineShare(linesFilled: number): number { + return linesFilled / LINES_RANGE.max; +} + +function writeSequence( + target: SequenceReading, + source: SpinSource & Pick, +): void { + const { phase, weighting, tissue, field, tipAngle, gradientAxis } = source; + target.phase = phase; + target.step = phaseOf(phase); + target.rf = rfPulse(phase); + target.gradient = followedAxis(gradientAxis, phase); + target.gradientLevel = axisLevel(gradientAxis, phase); + target.echo = echoAmplitude(phase, weighting, tissue, field, tipAngle); +} + +function writeSpins(target: SpinReading, source: SpinSource): void { + const { phase, weighting, tissue, field, tipAngle } = source; + target.net = magnetisation(phase, weighting, tissue, field, tipAngle); + target.arrows = spinArrows(phase, weighting, tissue, field, tipAngle); + target.precession = precessionAngle(phase); +} + +function writeScan(target: AssemblyState, source: AssemblySource): void { + const { field, weighting, linesFilled } = source; + const version = pictureVersionOf(field, weighting, linesFilled); + if (target.pictureVersion !== version) { + target.picture = pictureFor(field, weighting, linesFilled); + target.pictureVersion = version; + } + target.fringe.along = FIELDS[field].fringe.along; + target.fringe.side = FIELDS[field].fringe.side; +} + +export function writeAssemblyState(target: AssemblyState, source: AssemblySource): AssemblyState { + target.phase = source.phase; + target.playing = source.playing; + target.field = source.field; + target.weighting = source.weighting; + target.tissue = source.tissue; + target.gradientAxis = source.gradientAxis; + writeSequence(target.sequence, source); + writeSpins(target.spins, source); + writeScan(target, source); + Object.assign(target.view, source.view); + return target; +} + +export function createAssemblyState(source: AssemblySource): AssemblyState { + const { phase, playing, field, weighting, tissue, gradientAxis, linesFilled, view } = source; + const target: AssemblyState = { + phase, + playing, + field, + weighting, + tissue, + gradientAxis, + sequence: { ...EMPTY_SEQUENCE }, + spins: { ...EMPTY_SPINS }, + picture: pictureFor(field, weighting, linesFilled), + pictureVersion: pictureVersionOf(field, weighting, linesFilled), + fringe: { ...FIELDS[field].fringe }, + view: { ...view }, + }; + return writeAssemblyState(target, source); +} diff --git a/explainers/mri-scanner/src/state/index.ts b/explainers/mri-scanner/src/state/index.ts new file mode 100644 index 00000000..029bc932 --- /dev/null +++ b/explainers/mri-scanner/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './derived'; +export * from './presets'; +export * from './store'; diff --git a/explainers/mri-scanner/src/state/presets.test.ts b/explainers/mri-scanner/src/state/presets.test.ts new file mode 100644 index 00000000..b0069eb2 --- /dev/null +++ b/explainers/mri-scanner/src/state/presets.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS, PRESET_IDS } from '../ids'; +import { PHASE_RANGES } from '../model'; +import { CAMERA_VIEWS } from '../scene/cameraViews'; +import { DEFAULT_SPEED, PICTURE_SPEED } from '../timeline'; +import { PRESETS } from './presets'; + +const SCENE_FLAGS = ['cutaway', 'fieldLines', 'voxel'] as const; + +describe('chapter presets', () => { + it('has the six chapters in reading order', () => { + expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]); + }); + + it('frames each chapter with a known camera view', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].camera)).toEqual([ + 'room', + 'cryostat', + 'voxel', + 'coil', + 'gradient', + 'console', + ]); + for (const id of PRESET_IDS) expect(CAMERA_VIEWS).toHaveProperty(PRESETS[id].camera); + }); + + it('labels and highlights only known parts', () => { + for (const id of PRESET_IDS) { + const { labels, highlight } = PRESETS[id]; + for (const part of [...labels, ...highlight]) expect(PART_IDS).toContain(part); + } + }); + + it('highlights the shims with the magnet layers', () => { + expect(PRESETS.magnet.highlight).toEqual([...PRESETS.magnet.labels, 'shims']); + }); + + it('plays every chapter at the default speed except the picture', () => { + for (const id of PRESET_IDS) { + expect(PRESETS[id].speed).toBe(id === 'picture' ? PICTURE_SPEED : DEFAULT_SPEED); + } + }); + + it('holds the spins at the loop start and seeks the others', () => { + expect(PRESETS.spins).toMatchObject({ pauseAt: 0 }); + expect(PRESETS.spins.startAt).toBeUndefined(); + expect(PRESETS.overview.startAt).toBe(0); + expect(PRESETS.magnet.startAt).toBe(0); + expect(PRESETS.resonance.startAt).toBe(0); + expect(PRESETS.gradients.startAt).toBe(PHASE_RANGES.encode[0]); + expect(PRESETS.picture.startAt).toBe(PHASE_RANGES.echo[0]); + }); + + it('sets the cutaway, the field lines and the voxel for each chapter, never the labels', () => { + for (const id of PRESET_IDS) { + expect(Object.keys(PRESETS[id].view).sort()).toEqual([...SCENE_FLAGS].sort()); + } + expect(PRESETS.magnet.view).toEqual({ cutaway: true, fieldLines: true, voxel: false }); + expect(PRESETS.spins.view.voxel).toBe(true); + expect(PRESETS.resonance.view.voxel).toBe(true); + }); + + it('starts the chapter controls the spec names', () => { + expect(PRESETS.overview.start).toEqual({ linesFilled: 24 }); + expect(PRESETS.magnet.start).toBeUndefined(); + expect(PRESETS.spins.start).toEqual({ tissue: 'whiteMatter' }); + expect(PRESETS.resonance.start).toEqual({ tipAngle: 90, tissue: 'whiteMatter' }); + expect(PRESETS.gradients.start).toEqual({ gradientAxis: null }); + expect(PRESETS.picture.start).toEqual({ linesFilled: 8 }); + }); +}); diff --git a/explainers/mri-scanner/src/state/presets.ts b/explainers/mri-scanner/src/state/presets.ts new file mode 100644 index 00000000..26040456 --- /dev/null +++ b/explainers/mri-scanner/src/state/presets.ts @@ -0,0 +1,99 @@ +import type { ScenePreset } from '@core/scene/presetBinder'; +import type { CameraView, GradientAxisId, PartId, PresetId, TissueId, ViewOptions } from '../ids'; +import { PHASE_RANGES } from '../model'; +import { DEFAULT_SPEED, PICTURE_SPEED } from '../timeline'; + +export interface ChapterControls { + tipAngle: number; + tissue: TissueId; + gradientAxis: GradientAxisId | null; + linesFilled: number; +} + +export type ChapterControl = keyof ChapterControls; + +export interface Preset extends ScenePreset { + view: Partial; + controls?: readonly ChapterControl[]; + start?: Partial; +} + +const LOOP_START = 0; +const OVERVIEW_LINES = 24; +const PICTURE_START_LINES = 8; +const RESTING_TIP_DEG = 90; + +const MAGNET_LABELS: readonly PartId[] = [ + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'coldHead', + 'quenchPipe', + 'fringeLine', +]; + +const SPIN_PARTS: readonly PartId[] = ['spinArrows', 'netMagnet', 'mainField', 'patient']; + +const GRADIENT_COILS: readonly PartId[] = ['gradientX', 'gradientY', 'gradientZ', 'sliceSlab']; + +export const PRESETS: Record = { + overview: { + camera: 'room', + speed: DEFAULT_SPEED, + view: { cutaway: false, fieldLines: false, voxel: false }, + startAt: LOOP_START, + start: { linesFilled: OVERVIEW_LINES }, + labels: ['cover', 'bore', 'table', 'patient', 'headCoil', 'coldHead', 'screen', 'room'], + highlight: [], + }, + magnet: { + camera: 'cryostat', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: true, voxel: false }, + startAt: LOOP_START, + labels: MAGNET_LABELS, + highlight: [...MAGNET_LABELS, 'shims'], + }, + spins: { + camera: 'voxel', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: false, voxel: true }, + pauseAt: LOOP_START, + controls: ['tissue'], + start: { tissue: 'whiteMatter' }, + labels: SPIN_PARTS, + highlight: [...SPIN_PARTS, 'headCoil'], + }, + resonance: { + camera: 'coil', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: false, voxel: true }, + startAt: LOOP_START, + controls: ['tipAngle', 'tissue'], + start: { tipAngle: RESTING_TIP_DEG, tissue: 'whiteMatter' }, + labels: ['bodyCoil', 'headCoil', 'rfWave', 'echoWave', 'netMagnet'], + highlight: ['bodyCoil', 'headCoil', 'rfWave', 'echoWave', 'spinArrows', 'netMagnet'], + }, + gradients: { + camera: 'gradient', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: false, voxel: false }, + startAt: PHASE_RANGES.encode[0], + controls: ['gradientAxis'], + start: { gradientAxis: null }, + labels: [...GRADIENT_COILS, 'bodyCoil'], + highlight: GRADIENT_COILS, + }, + picture: { + camera: 'console', + speed: PICTURE_SPEED, + view: { cutaway: false, fieldLines: false, voxel: false }, + startAt: PHASE_RANGES.echo[0], + controls: ['linesFilled'], + start: { linesFilled: PICTURE_START_LINES }, + labels: ['screen', 'headCoil', 'echoWave'], + highlight: ['screen', 'headCoil', 'patient', 'echoWave'], + }, +}; diff --git a/explainers/mri-scanner/src/state/store.test.ts b/explainers/mri-scanner/src/state/store.test.ts new file mode 100644 index 00000000..7ba4d74d --- /dev/null +++ b/explainers/mri-scanner/src/state/store.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import { LINE_DONE_UNITS, MOMENTS, rate } from '../model'; +import { PRESETS } from './presets'; +import { + CHAPTER_CONTROL_DEFAULTS, + DEFAULT_VIEW, + createMriScannerStore, + crossesLineDone, + freshScanLines, + nextLineCount, + snapToRange, + TIP_ANGLE_RANGE, +} from './store'; + +const PLAYBACK = true; +const SEEK = false; + +function storeOn(preset: Parameters[0] = 'magnet') { + const store = createMriScannerStore(); + store.getState().applyPreset(preset); + return store; +} + +function secondsFor(units: number, speed: number): number { + return units / rate(speed); +} + +describe('line crossing', () => { + it('counts a forward playback step that reaches the end of the echo', () => { + expect(crossesLineDone(600, 700, PLAYBACK)).toBe(true); + expect(crossesLineDone(600, LINE_DONE_UNITS, PLAYBACK)).toBe(true); + }); + + it('ignores a step that leaves from the line mark or stays short of it', () => { + expect(crossesLineDone(LINE_DONE_UNITS, 700, PLAYBACK)).toBe(false); + expect(crossesLineDone(100, 600, PLAYBACK)).toBe(false); + expect(crossesLineDone(700, 900, PLAYBACK)).toBe(false); + }); + + it('counts a step that spans the loop wrap and passes the mark on either side', () => { + expect(crossesLineDone(600, 100, PLAYBACK)).toBe(true); + expect(crossesLineDone(900, 700, PLAYBACK)).toBe(true); + expect(crossesLineDone(900, 100, PLAYBACK)).toBe(false); + }); + + it('never counts a seek or a still phase', () => { + expect(crossesLineDone(600, 700, SEEK)).toBe(false); + expect(crossesLineDone(600, 100, SEEK)).toBe(false); + expect(crossesLineDone(600, 600, PLAYBACK)).toBe(false); + }); + + it('wraps to a new scan after the last line', () => { + expect(nextLineCount(0)).toBe(1); + expect(nextLineCount(63)).toBe(64); + expect(nextLineCount(64)).toBe(0); + }); +}); + +describe('snapToRange', () => { + it('clamps and rounds to the step', () => { + expect(snapToRange(43, TIP_ANGLE_RANGE)).toBe(45); + expect(snapToRange(-20, TIP_ANGLE_RANGE)).toBe(0); + expect(snapToRange(400, TIP_ANGLE_RANGE)).toBe(180); + }); +}); + +describe('mri scanner store', () => { + it('starts on the overview with its picture part filled', () => { + expect(createMriScannerStore().getState()).toMatchObject({ + phase: 0, + speed: 6, + preset: 'overview', + field: 'field15', + weighting: 't2', + tipAngle: 90, + tissue: 'whiteMatter', + gradientAxis: null, + linesFilled: 24, + view: { cutaway: false, fieldLines: false, voxel: false, labels: true }, + }); + }); + + it('opens with the view the overview chapter asks for', () => { + expect(DEFAULT_VIEW).toMatchObject(PRESETS.overview.view); + }); + + it('adds one line each time playback carries the phase past the echo', () => { + const store = createMriScannerStore({ phase: 600, linesFilled: 3 }); + const { speed } = store.getState(); + store.getState().tick(secondsFor(100, speed)); + expect(store.getState().linesFilled).toBe(4); + store.getState().tick(secondsFor(100, speed)); + expect(store.getState().linesFilled).toBe(4); + }); + + it('adds a line when a playback step spans the loop wrap', () => { + const store = createMriScannerStore({ phase: 900, linesFilled: 3 }); + store.getState().tick(secondsFor(800, store.getState().speed)); + expect(store.getState().phase).toBeCloseTo(700, 6); + expect(store.getState().linesFilled).toBe(4); + }); + + it('starts a new scan after a full picture', () => { + const store = createMriScannerStore({ phase: 600, linesFilled: 64 }); + store.getState().tick(secondsFor(100, store.getState().speed)); + expect(store.getState().linesFilled).toBe(0); + }); + + it('never adds a line while paused, on a scrub, a step, a jump or a seek', () => { + const store = createMriScannerStore({ phase: 600, linesFilled: 3, playing: false }); + store.getState().tick(secondsFor(100, store.getState().speed)); + store.getState().setPhase(700); + store.getState().setPhase(600); + store.getState().step(100); + store.getState().jumpToPhase('recover'); + store.getState().setPhase(600); + store.getState().seekMoment('echoPeak'); + store.getState().seekMoment('pulse90'); + expect(store.getState().linesFilled).toBe(3); + }); + + it('seeks a moment and pauses there', () => { + const store = createMriScannerStore(); + store.getState().seekMoment('pulse180'); + expect(store.getState()).toMatchObject({ phase: MOMENTS.pulse180, playing: false }); + }); + + it('starts a new scan on a field or weighting change', () => { + const store = storeOn('magnet'); + store.getState().setLinesFilled(40); + store.getState().setField('field30'); + expect(store.getState()).toMatchObject({ field: 'field30', linesFilled: 0 }); + store.getState().setLinesFilled(40); + store.getState().setWeighting('t1'); + expect(store.getState()).toMatchObject({ weighting: 't1', linesFilled: 0 }); + }); + + it('restarts a scan at the chapter start lines when the chapter sets them', () => { + const store = storeOn('picture'); + store.getState().setLinesFilled(40); + store.getState().setWeighting('t1'); + expect(store.getState().linesFilled).toBe(PRESETS.picture.start?.linesFilled); + }); + + it('keeps the scan when the same field or weighting is chosen again', () => { + const store = storeOn('magnet'); + store.getState().setLinesFilled(40); + store.getState().setField('field15'); + store.getState().setWeighting('t2'); + expect(store.getState().linesFilled).toBe(40); + }); + + it('clamps the lines and the tip angle', () => { + const store = createMriScannerStore(); + store.getState().setLinesFilled(70.4); + expect(store.getState().linesFilled).toBe(64); + store.getState().setLinesFilled(12.6); + expect(store.getState().linesFilled).toBe(13); + store.getState().setTipAngle(-3); + expect(store.getState().tipAngle).toBe(0); + store.getState().setTipAngle(122); + expect(store.getState().tipAngle).toBe(120); + }); + + it('keeps the field and weighting across chapters', () => { + const store = storeOn('magnet'); + store.getState().setField('field30'); + store.getState().setWeighting('t1'); + store.getState().applyPreset('picture'); + expect(store.getState()).toMatchObject({ field: 'field30', weighting: 't1' }); + }); + + it('resets the chapter controls to the next chapter start or the defaults', () => { + const store = storeOn('resonance'); + store.getState().setTipAngle(30); + store.getState().setTissue('fat'); + store.getState().setGradientAxis('x'); + store.getState().applyPreset('magnet'); + expect(store.getState()).toMatchObject(CHAPTER_CONTROL_DEFAULTS); + store.getState().applyPreset('picture'); + expect(store.getState()).toMatchObject({ ...CHAPTER_CONTROL_DEFAULTS, linesFilled: 8 }); + }); + + it('keeps the chapter controls when the same chapter is applied again', () => { + const store = storeOn('resonance'); + store.getState().setTipAngle(30); + store.getState().applyPreset('resonance'); + expect(store.getState().tipAngle).toBe(30); + }); + + it('sets the tissue and the gradient axis', () => { + const store = createMriScannerStore(); + store.getState().setTissue('fluid'); + store.getState().setGradientAxis('z'); + expect(store.getState()).toMatchObject({ tissue: 'fluid', gradientAxis: 'z' }); + store.getState().setGradientAxis(null); + expect(store.getState().gradientAxis).toBeNull(); + }); + + it('starts a scan from zero on chapters without start lines', () => { + expect(freshScanLines('magnet')).toBe(0); + expect(freshScanLines('overview')).toBe(24); + }); +}); diff --git a/explainers/mri-scanner/src/state/store.ts b/explainers/mri-scanner/src/state/store.ts new file mode 100644 index 00000000..7b2f9217 --- /dev/null +++ b/explainers/mri-scanner/src/state/store.ts @@ -0,0 +1,142 @@ +import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; +import { clamp } from '@core/math'; +import { createExplainerStore } from '@core/store'; +import type { + FieldId, + GradientAxisId, + MomentId, + PresetId, + TissueId, + ViewOptions, + WeightingId, +} from '../ids'; +import { LINE_DONE_UNITS, MODEL_SIZE, MOMENTS } from '../model'; +import { MRI_SCANNER_TIMELINE, SPEED_RANGE } from '../timeline'; +import { PRESETS } from './presets'; +import type { ChapterControls, Preset } from './presets'; + +export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] }; + +export interface MriScannerFields extends ChapterControls { + field: FieldId; + weighting: WeightingId; + view: ViewState; + preset: PresetId; +} + +export interface MriScannerOwnActions { + setField(field: FieldId): void; + setWeighting(weighting: WeightingId): void; + setLinesFilled(lines: number): void; + addLine(): void; + setTipAngle(degrees: number): void; + setTissue(tissue: TissueId): void; + setGradientAxis(axis: GradientAxisId | null): void; + seekMoment(moment: MomentId): void; +} + +export type MriScannerState = PlaybackState & MriScannerFields; +export type MriScannerStoreState = Playback & MriScannerFields & MriScannerOwnActions; +export type MriScannerStore = ExplainerStore; + +export interface SteppedRange { + min: number; + max: number; + step: number; +} + +export const TIP_ANGLE_RANGE = { min: 0, max: 180, step: 5, default: 90 } as const; +export const LINES_RANGE = { min: 0, max: MODEL_SIZE, step: 1, default: 0 } as const; + +export const DEFAULT_VIEW: ViewState = { + cutaway: false, + fieldLines: false, + voxel: false, + labels: true, +}; +export const DEFAULT_FIELD: FieldId = 'field15'; +export const DEFAULT_WEIGHTING: WeightingId = 't2'; + +export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { + tipAngle: TIP_ANGLE_RANGE.default, + tissue: 'whiteMatter', + gradientAxis: null, + linesFilled: LINES_RANGE.default, +}; + +const START_PRESET: PresetId = 'overview'; +const START_SPEED = PRESETS[START_PRESET].speed ?? SPEED_RANGE.default; + +export function snapToRange(value: number, { min, max, step }: SteppedRange): number { + return clamp(min + Math.round((value - min) / step) * step, min, max); +} + +export function crossesLineDone(previous: number, next: number, byPlayback: boolean): boolean { + if (!byPlayback || previous === next) return false; + if (next > previous) return previous < LINE_DONE_UNITS && LINE_DONE_UNITS <= next; + return previous < LINE_DONE_UNITS || LINE_DONE_UNITS <= next; +} + +export function nextLineCount(lines: number): number { + return lines >= LINES_RANGE.max ? LINES_RANGE.min : lines + 1; +} + +export function freshScanLines(preset: PresetId): number { + return PRESETS[preset].start?.linesFilled ?? LINES_RANGE.default; +} + +function presetFields(preset: Preset, state: MriScannerFields): Partial { + if (PRESETS[state.preset] === preset) return {}; + return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start }; +} + +function countLinesOnPlayback(store: MriScannerStore): MriScannerStore { + const advance = store.getState().tick; + store.setState({ + tick: (deltaSeconds) => { + const previous = store.getState().phase; + advance(deltaSeconds); + if (crossesLineDone(previous, store.getState().phase, true)) store.getState().addLine(); + }, + }); + return store; +} + +export function createMriScannerStore( + overrides: Partial = {}, +): MriScannerStore { + const store = createExplainerStore( + { + timeline: MRI_SCANNER_TIMELINE, + presets: PRESETS, + defaults: { preset: START_PRESET, speed: START_SPEED, view: DEFAULT_VIEW }, + extend: (set, get) => { + const newScan = () => ({ linesFilled: freshScanLines(get().preset) }); + return { + ...CHAPTER_CONTROL_DEFAULTS, + ...PRESETS[START_PRESET].start, + field: DEFAULT_FIELD, + weighting: DEFAULT_WEIGHTING, + setField: (field) => { + if (field !== get().field) set({ field, ...newScan() }); + }, + setWeighting: (weighting) => { + if (weighting !== get().weighting) set({ weighting, ...newScan() }); + }, + setLinesFilled: (lines) => set({ linesFilled: snapToRange(lines, LINES_RANGE) }), + addLine: () => set({ linesFilled: nextLineCount(get().linesFilled) }), + setTipAngle: (degrees) => set({ tipAngle: snapToRange(degrees, TIP_ANGLE_RANGE) }), + setTissue: (tissue) => set({ tissue }), + setGradientAxis: (gradientAxis) => set({ gradientAxis }), + seekMoment: (moment) => { + get().pause(); + get().setPhase(MOMENTS[moment]); + }, + }; + }, + presetState: presetFields, + }, + overrides, + ); + return countLinesOnPlayback(store); +} From 251afdf396288abed5bebf47daf7f25e9df073af Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:03:36 +0200 Subject: [PATCH 06/82] feat(mri-scanner): add the dock, readouts, chapter actions and k-space widget --- explainers/mri-scanner/src/style.css | 140 +++++++++++++ explainers/mri-scanner/src/ui/actions.test.ts | 61 ++++++ explainers/mri-scanner/src/ui/actions.ts | 77 +++++++ explainers/mri-scanner/src/ui/dock.ts | 53 +++++ .../mri-scanner/src/ui/format.dom.test.ts | 82 ++++++++ explainers/mri-scanner/src/ui/format.ts | 190 ++++++++++++++++++ explainers/mri-scanner/src/ui/index.ts | 24 +++ .../mri-scanner/src/ui/kspaceView.dom.test.ts | 81 ++++++++ explainers/mri-scanner/src/ui/kspaceView.ts | 84 ++++++++ .../src/ui/mountGradientsReadouts.ts | 20 ++ .../mri-scanner/src/ui/mountMagnetReadouts.ts | 12 ++ .../src/ui/mountOverviewReadouts.ts | 11 + .../src/ui/mountPictureReadouts.ts | 32 +++ .../src/ui/mountResonanceControl.ts | 31 +++ .../mri-scanner/src/ui/mountSpinsReadouts.ts | 13 ++ .../mri-scanner/src/ui/mountUi.dom.test.ts | 116 +++++++++++ .../mri-scanner/src/ui/readouts.test.ts | 27 +++ explainers/mri-scanner/src/ui/readouts.ts | 28 +++ explainers/mri-scanner/src/ui/testing.ts | 9 + 19 files changed, 1091 insertions(+) create mode 100644 explainers/mri-scanner/src/style.css create mode 100644 explainers/mri-scanner/src/ui/actions.test.ts create mode 100644 explainers/mri-scanner/src/ui/actions.ts create mode 100644 explainers/mri-scanner/src/ui/dock.ts create mode 100644 explainers/mri-scanner/src/ui/format.dom.test.ts create mode 100644 explainers/mri-scanner/src/ui/format.ts create mode 100644 explainers/mri-scanner/src/ui/index.ts create mode 100644 explainers/mri-scanner/src/ui/kspaceView.dom.test.ts create mode 100644 explainers/mri-scanner/src/ui/kspaceView.ts create mode 100644 explainers/mri-scanner/src/ui/mountGradientsReadouts.ts create mode 100644 explainers/mri-scanner/src/ui/mountMagnetReadouts.ts create mode 100644 explainers/mri-scanner/src/ui/mountOverviewReadouts.ts create mode 100644 explainers/mri-scanner/src/ui/mountPictureReadouts.ts create mode 100644 explainers/mri-scanner/src/ui/mountResonanceControl.ts create mode 100644 explainers/mri-scanner/src/ui/mountSpinsReadouts.ts create mode 100644 explainers/mri-scanner/src/ui/mountUi.dom.test.ts create mode 100644 explainers/mri-scanner/src/ui/readouts.test.ts create mode 100644 explainers/mri-scanner/src/ui/readouts.ts create mode 100644 explainers/mri-scanner/src/ui/testing.ts diff --git a/explainers/mri-scanner/src/style.css b/explainers/mri-scanner/src/style.css new file mode 100644 index 00000000..40c32718 --- /dev/null +++ b/explainers/mri-scanner/src/style.css @@ -0,0 +1,140 @@ +:root { + --rf: #ffb347; + --grad-x: #ff7a59; + --grad-y: #6fd38a; + --grad-z: #b48cff; + --echo: #4fd1c5; + --main-field: #5fa8ff; + --fringe: #ffd24c; + --fat: #ffd27a; + --white-matter: #f0ece4; + --grey-matter: #a7a2b4; + --fluid: #5fb8ff; + --screen: #1b1d20; +} + +.chapter .readouts { + --readout-min-width: 8rem; + margin-block: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; +} + +.chapter .readouts > div, +.chapter .readouts :is(dt, dd) { + min-width: 0; +} + +.chapter .readouts :is(dt, dd) { + margin: 0; + overflow-wrap: break-word; + hyphens: auto; +} + +.chapter .readouts dt { + color: var(--muted); + font-size: 0.8125rem; +} + +.chapter .readouts > div:last-child:nth-child(odd) { + grid-column: 1 / -1; +} + +.range-widget__header { + flex-wrap: wrap; +} + +.range-widget__header label { + min-width: 0; + overflow-wrap: break-word; +} + +.range-widget .readouts { + margin-block: 12px 0; +} + +.kspace-widget { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + margin: 4px 0 16px; +} + +.kspace-widget canvas { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1; + border-radius: var(--radius-small); + background: var(--screen); +} + +.kspace-widget figcaption { + display: contents; +} + +.kspace-widget figcaption span { + color: var(--muted); + font-size: 0.8125rem; + text-align: center; +} + +.chip[data-action='moment'] { + --tone: var(--rf); +} + +.chip[data-action='moment'][data-value='echoPeak'] { + --tone: var(--echo); +} + +.chip[data-action='field'] { + --tone: var(--main-field); +} + +.chip[data-action='gradientAxis'][data-value='x'] { + --tone: var(--grad-x); +} + +.chip[data-action='gradientAxis'][data-value='y'] { + --tone: var(--grad-y); +} + +.chip[data-action='gradientAxis'][data-value='z'] { + --tone: var(--grad-z); +} + +.chip[data-action='tissue'][data-value='fat'] { + --tone: var(--fat); +} + +.chip[data-action='tissue'][data-value='whiteMatter'] { + --tone: var(--white-matter); +} + +.chip[data-action='tissue'][data-value='greyMatter'] { + --tone: var(--grey-matter); +} + +.chip[data-action='tissue'][data-value='fluid'] { + --tone: var(--fluid); +} + +.chip:is([data-action='weighting'], [data-action='lines']) { + --tone: var(--echo); +} + +@media (pointer: coarse) { + .chip[data-action] { + height: 44px; + } +} + +@media (max-width: 480px) { + .chapter .readouts { + grid-template-columns: minmax(0, 1fr); + } + + .chapter .readouts > div:last-child:nth-child(odd) { + grid-column: auto; + } +} diff --git a/explainers/mri-scanner/src/ui/actions.test.ts b/explainers/mri-scanner/src/ui/actions.test.ts new file mode 100644 index 00000000..58458407 --- /dev/null +++ b/explainers/mri-scanner/src/ui/actions.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { FOLLOW_SEQUENCE, MOMENT_IDS } from '../ids'; +import { MOMENTS } from '../model'; +import { createMriScannerStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; + +function run(action: string, value: string) { + const store = createMriScannerStore({ playing: true }); + CHAPTER_ACTIONS[action]?.run(store.getState(), value); + return store.getState(); +} + +function current(action: string, state: ReturnType): string | undefined { + return CHAPTER_ACTIONS[action]?.current?.(state); +} + +describe('chapter actions', () => { + it('switches the field everywhere and the weighting in the picture chapter', () => { + expect(current('field', run('field', 'field30'))).toBe('field30'); + const picked = run('weighting', 't1'); + expect(picked.preset).toBe('picture'); + expect(current('weighting', picked)).toBe('t1'); + }); + + it('picks a tissue in the resonance chapter', () => { + const state = run('tissue', 'fluid'); + expect(state.preset).toBe('resonance'); + expect(current('tissue', state)).toBe('fluid'); + }); + + it('holds one gradient coil or follows the sequence', () => { + const held = run('gradientAxis', 'y'); + expect(held.gradientAxis).toBe('y'); + expect(current('gradientAxis', held)).toBe('y'); + const following = run('gradientAxis', FOLLOW_SEQUENCE); + expect(following.gradientAxis).toBeNull(); + expect(current('gradientAxis', following)).toBe(FOLLOW_SEQUENCE); + }); + + it('pauses at each moment and marks it until playback moves on', () => { + MOMENT_IDS.forEach((moment) => { + const state = run('moment', moment); + expect(state.playing).toBe(false); + expect(state.phase).toBe(MOMENTS[moment]); + expect(current('moment', state)).toBe(moment); + }); + expect(current('moment', createMriScannerStore({ phase: 1 }).getState())).toBe(''); + }); + + it('fills a chosen number of lines and marks only matching counts', () => { + const state = run('lines', '32'); + expect(state.linesFilled).toBe(32); + expect(current('lines', state)).toBe('32'); + expect(current('lines', createMriScannerStore({ linesFilled: 5 }).getState())).toBe(''); + }); + + it('rejects values it does not know', () => { + expect(() => run('lines', '7')).toThrow(); + expect(() => run('gradientAxis', 'w')).toThrow(); + }); +}); diff --git a/explainers/mri-scanner/src/ui/actions.ts b/explainers/mri-scanner/src/ui/actions.ts new file mode 100644 index 00000000..84a37a02 --- /dev/null +++ b/explainers/mri-scanner/src/ui/actions.ts @@ -0,0 +1,77 @@ +import type { ChapterAction } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { + AXIS_CHOICE_IDS, + FIELD_IDS, + FOLLOW_SEQUENCE, + LINE_CHOICE_IDS, + MOMENT_IDS, + TISSUE_IDS, + WEIGHTING_IDS, +} from '../ids'; +import type { GradientAxisId, PresetId } from '../ids'; +import type { MriScannerStoreState } from '../state'; +import { momentAt } from '../timeline'; + +const NOTHING_CURRENT = ''; + +type ChapterChange = (state: MriScannerStoreState, ...args: A) => void; + +export function inChapter( + preset: PresetId, + change: ChapterChange, +): ChapterChange { + return (state, ...args) => { + if (state.preset !== preset) state.applyPreset(preset); + change(state, ...args); + }; +} + +function axisOf(value: string): GradientAxisId | null { + const choice = parseOption(value, AXIS_CHOICE_IDS); + return choice === FOLLOW_SEQUENCE ? null : choice; +} + +function pausedMoment(state: MriScannerStoreState): string { + if (state.playing) return NOTHING_CURRENT; + return momentAt(state.phase) ?? NOTHING_CURRENT; +} + +function lineChoiceOf(lines: number): string { + return LINE_CHOICE_IDS.find((choice) => Number(choice) === lines) ?? NOTHING_CURRENT; +} + +export const CHAPTER_ACTIONS: Record> = { + field: { + run: (state, value) => state.setField(parseOption(value, FIELD_IDS)), + current: (state) => state.field, + }, + weighting: { + run: inChapter('picture', (state, value: string) => + state.setWeighting(parseOption(value, WEIGHTING_IDS)), + ), + current: (state) => state.weighting, + }, + tissue: { + run: inChapter('resonance', (state, value: string) => + state.setTissue(parseOption(value, TISSUE_IDS)), + ), + current: (state) => state.tissue, + }, + gradientAxis: { + run: inChapter('gradients', (state, value: string) => state.setGradientAxis(axisOf(value))), + current: (state) => state.gradientAxis ?? FOLLOW_SEQUENCE, + }, + moment: { + run: inChapter('resonance', (state, value: string) => + state.seekMoment(parseOption(value, MOMENT_IDS)), + ), + current: pausedMoment, + }, + lines: { + run: inChapter('picture', (state, value: string) => + state.setLinesFilled(Number(parseOption(value, LINE_CHOICE_IDS))), + ), + current: (state) => lineChoiceOf(state.linesFilled), + }, +}; diff --git a/explainers/mri-scanner/src/ui/dock.ts b/explainers/mri-scanner/src/ui/dock.ts new file mode 100644 index 00000000..6b5fb12a --- /dev/null +++ b/explainers/mri-scanner/src/ui/dock.ts @@ -0,0 +1,53 @@ +import type { Choice, ViewToggle } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { FIELD_IDS, WEIGHTING_IDS } from '../ids'; +import type { MriScannerStoreState } from '../state'; + +const CUTAWAY_ICON = + ''; +const FIELD_LINES_ICON = + ''; +const LABELS_ICON = + ''; + +export const MRI_SCANNER_CHOICES: readonly Choice[] = [ + { + id: 'field', + labelKey: 'controls.field', + shortcut: 'B', + options: FIELD_IDS.map((field) => ({ + value: field, + labelKey: `controls.fieldOptions.${field}`, + })), + select: (state) => state.field, + apply: (state, value) => state.setField(parseOption(value, FIELD_IDS)), + }, + { + id: 'weighting', + labelKey: 'controls.weighting', + shortcut: 'W', + options: WEIGHTING_IDS.map((weighting) => ({ + value: weighting, + labelKey: `controls.weightingOptions.${weighting}`, + })), + select: (state) => state.weighting, + apply: (state, value) => state.setWeighting(parseOption(value, WEIGHTING_IDS)), + }, +]; + +function viewToggle(view: string, shortcut: string, icon: string): ViewToggle { + return { + view, + shortcut, + nameKey: `controls.views.${view}.name`, + shortKey: `controls.views.${view}.short`, + hintKey: `controls.views.${view}.hint`, + icon, + }; +} + +export const VIEW_TOGGLES: readonly ViewToggle[] = [ + viewToggle('cutaway', 'C', CUTAWAY_ICON), + viewToggle('fieldLines', 'F', FIELD_LINES_ICON), + viewToggle('labels', 'L', LABELS_ICON), +]; diff --git a/explainers/mri-scanner/src/ui/format.dom.test.ts b/explainers/mri-scanner/src/ui/format.dom.test.ts new file mode 100644 index 00000000..b9be04e1 --- /dev/null +++ b/explainers/mri-scanner/src/ui/format.dom.test.ts @@ -0,0 +1,82 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import en from '../../locales/en.json'; +import { + formatAcross, + formatAlong, + formatAxisRole, + formatBoreWidth, + formatBrightness, + formatEarthMultiple, + formatEdgeShift, + formatFieldNow, + formatFieldShare, + formatFringe, + formatHelium, + formatLarmor, + formatLarmorBand, + formatLines, + formatRise, + formatScanTime, + formatSlowdown, + formatSpread, + formatSurplus, + formatSurplusCount, + formatT1, + formatT2, + formatTimeGauge, + formatTurbo, + toSuperscript, +} from './format'; + +describe('readout formats', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('formats the gauge', () => { + expect(formatLarmor('field15')).toBe('63.9 MHz'); + expect(formatLarmor('field30')).toBe('127.7 MHz'); + expect(formatTimeGauge({ ms: 1250.4, totalMs: 2500, share: 0.5 })).toBe('1,250 of 2,500 ms'); + expect(formatLines(24)).toBe('24 of 64'); + }); + + it('formats the overview and the magnet', () => { + expect(formatFieldNow('field15')).toBe('1.5 T, always on'); + expect(formatBoreWidth()).toBe('70 cm across'); + expect(formatEarthMultiple('field15')).toBe('23,000 to 60,000 times stronger'); + expect(formatEarthMultiple('field30')).toBe('46,000 to 120,000 times stronger'); + expect(formatHelium()).toBe('4.2 K, -269 °C'); + expect(formatFringe('field30')).toBe('5.2 m along the tunnel, 2.8 m to the side'); + }); + + it('formats the spins', () => { + expect(formatLarmorBand('field15')).toBe('63.9 MHz, just below FM radio'); + expect(formatLarmorBand('field30')).toBe('127.7 MHz, just above FM radio'); + expect(formatSurplus('field15')).toBe('about 5 in a million'); + expect(formatSurplusCount('field15')).toBe('about 3.3 × 10¹⁴'); + expect(formatSlowdown('field15')).toBe('about 128 million times'); + expect(toSuperscript(-19)).toBe('⁻¹⁹'); + }); + + it('formats the tip, the relaxation times and the gradients', () => { + expect([formatAcross(90), formatAlong(90)]).toEqual(['100 %', '0 %']); + expect(formatAlong(180)).toBe('-100 %'); + expect([formatT1('field15', 'whiteMatter'), formatT2('field15', 'whiteMatter')]).toEqual([ + '884 ms', + '72 ms', + ]); + expect(formatEdgeShift()).toBe('5.4 mT'); + expect(formatFieldShare('field15')).toBe('0.36 %'); + expect(formatSpread()).toBe('230 kHz'); + expect(formatRise()).toBe('0.225 ms'); + expect(formatAxisRole(null)).toBe(en.chapters.gradients.roleValue.sequence); + expect(formatAxisRole('x')).toBe(en.chapters.gradients.roleValue.x); + }); + + it('formats the picture', () => { + expect(formatScanTime('t2')).toBe('10.7 min for 256 lines'); + expect(formatScanTime('t1')).toBe('2.1 min for 256 lines'); + expect(formatTurbo('t2')).toBe('about 40 s'); + expect(formatTurbo('t1')).toBe('about 8 s'); + expect(formatBrightness('field15', 't2', 'fluid')).toBe('100 %'); + }); +}); diff --git a/explainers/mri-scanner/src/ui/format.ts b/explainers/mri-scanner/src/ui/format.ts new file mode 100644 index 00000000..da721393 --- /dev/null +++ b/explainers/mri-scanner/src/ui/format.ts @@ -0,0 +1,190 @@ +import { formatCompact, formatFixed, formatNumber, formatSignificant } from '@core/format'; +import { t } from '@core/i18n'; +import { FOLLOW_SEQUENCE } from '../ids'; +import type { FieldId, GradientAxisId, TissueId, WeightingId } from '../ids'; +import { + BORE, + FIELDS, + HELIUM_K, + KELVIN_AT_ZERO_CELSIUS, + MODEL_SIZE, + REAL_LINES, + RELAXATION, + SECONDS_PER_MINUTE, + earthMultiple, + edgeShiftMT, + frequencySpreadKHz, + larmorMHz, + metresToCentimetres, + relativeBrightness, + riseTimeMs, + scanSeconds, + shareOfField, + slowdown, + spinSurplus, + surplusPerMm3, + tipComponents, +} from '../model'; +import type { TimeGauge } from '../state'; + +const WHOLE = 0; +const TENTHS = 1; +const HUNDREDTHS = 2; +const PERCENT = 100; +const PER_MILLION = 1e6; +const EARTH_DIGITS = 2; +const RISE_DIGITS = 3; +const SLOWDOWN_DIGITS = 3; +const FM_BAND_LOW_MHZ = 87.5; +const DECIMAL = 10; +const RADII_PER_DIAMETER = 2; +export const TURBO_LINES_PER_REPETITION = 16; +const SUPERSCRIPT_DIGITS = '⁰¹²³⁴⁵⁶⁷⁸⁹'; +const SUPERSCRIPT_MINUS = '⁻'; + +function unit(key: string, value: string): string { + return t(`units.${key}`, { value }); +} + +export function formatPercent(fraction: number): string { + return unit('percent', formatNumber(Math.round(fraction * PERCENT))); +} + +export function formatDegrees(degrees: number): string { + return unit('degrees', formatNumber(degrees)); +} + +export function formatMs(ms: number): string { + return unit('ms', formatNumber(ms)); +} + +export function formatLarmor(field: FieldId): string { + return unit('mhz', formatFixed(larmorMHz(field), TENTHS)); +} + +export function formatTimeGauge(gauge: TimeGauge): string { + return t('units.msOf', { + value: formatNumber(Math.round(gauge.ms)), + total: formatNumber(gauge.totalMs), + }); +} + +export function formatLines(lines: number): string { + return t('units.linesOf', { value: formatNumber(lines), total: formatNumber(MODEL_SIZE) }); +} + +export function formatFieldNow(field: FieldId): string { + return t('chapters.overview.field', { tesla: unit('tesla', formatNumber(FIELDS[field].tesla)) }); +} + +export function formatBoreWidth(): string { + const cm = metresToCentimetres(RADII_PER_DIAMETER * BORE.radius); + return t('chapters.overview.bore', { cm: unit('cm', formatNumber(Math.round(cm))) }); +} + +export function formatEarthMultiple(field: FieldId): string { + const { min, max } = earthMultiple(field); + return t('chapters.magnet.earthValue', { + min: formatSignificant(min, EARTH_DIGITS), + max: formatSignificant(max, EARTH_DIGITS), + }); +} + +export function formatHelium(): string { + return t('units.kelvin', { + kelvin: formatFixed(HELIUM_K, TENTHS), + celsius: formatNumber(Math.round(HELIUM_K - KELVIN_AT_ZERO_CELSIUS)), + }); +} + +export function formatFringe(field: FieldId): string { + const { along, side } = FIELDS[field].fringe; + return t('chapters.magnet.fringeValue', { along: formatNumber(along), side: formatNumber(side) }); +} + +export function formatLarmorBand(field: FieldId): string { + const band = larmorMHz(field) < FM_BAND_LOW_MHZ ? 'belowFm' : 'aboveFm'; + return t('chapters.spins.larmorBand', { + value: formatLarmor(field), + band: t(`chapters.spins.band.${band}`), + }); +} + +export function formatSurplus(field: FieldId): string { + return t('chapters.spins.surplusValue', { + perMillion: formatNumber(Math.round(spinSurplus(field) * PER_MILLION)), + }); +} + +export function toSuperscript(exponent: number): string { + const digits = [...String(Math.abs(exponent))].map((digit) => SUPERSCRIPT_DIGITS[Number(digit)]); + return (exponent < 0 ? SUPERSCRIPT_MINUS : '') + digits.join(''); +} + +export function formatSurplusCount(field: FieldId): string { + const count = surplusPerMm3(field); + const exponent = Math.floor(Math.log10(count)); + return t('chapters.spins.countValue', { + mantissa: formatFixed(count / DECIMAL ** exponent, TENTHS), + exponent: toSuperscript(exponent), + }); +} + +export function formatSlowdown(field: FieldId): string { + return t('chapters.spins.slowdownValue', { + factor: formatCompact(slowdown(field), SLOWDOWN_DIGITS), + }); +} + +export function formatAcross(tipAngle: number): string { + return formatPercent(tipComponents(tipAngle).across); +} + +export function formatAlong(tipAngle: number): string { + return formatPercent(tipComponents(tipAngle).along); +} + +export function formatT1(field: FieldId, tissue: TissueId): string { + return formatMs(RELAXATION[field][tissue].t1); +} + +export function formatT2(field: FieldId, tissue: TissueId): string { + return formatMs(RELAXATION[field][tissue].t2); +} + +export function formatEdgeShift(): string { + return unit('mT', formatFixed(edgeShiftMT(), TENTHS)); +} + +export function formatFieldShare(field: FieldId): string { + return unit('percent', formatFixed(shareOfField(field), HUNDREDTHS)); +} + +export function formatSpread(): string { + return unit('kHz', formatFixed(frequencySpreadKHz(), WHOLE)); +} + +export function formatRise(): string { + return unit('ms', formatSignificant(riseTimeMs(), RISE_DIGITS)); +} + +export function formatAxisRole(axis: GradientAxisId | null): string { + return t(`chapters.gradients.roleValue.${axis ?? FOLLOW_SEQUENCE}`); +} + +export function formatBrightness(field: FieldId, weighting: WeightingId, tissue: TissueId): string { + return formatPercent(relativeBrightness(field, weighting)[tissue]); +} + +export function formatScanTime(weighting: WeightingId): string { + return t('chapters.picture.scanTimeValue', { + minutes: formatFixed(scanSeconds(weighting) / SECONDS_PER_MINUTE, TENTHS), + lines: formatNumber(REAL_LINES), + }); +} + +export function formatTurbo(weighting: WeightingId): string { + return t('chapters.picture.turboValue', { + seconds: formatNumber(scanSeconds(weighting) / TURBO_LINES_PER_REPETITION), + }); +} diff --git a/explainers/mri-scanner/src/ui/index.ts b/explainers/mri-scanner/src/ui/index.ts new file mode 100644 index 00000000..0740cfee --- /dev/null +++ b/explainers/mri-scanner/src/ui/index.ts @@ -0,0 +1,24 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { MriScannerStore } from '../state'; +import { mountGradientsReadouts } from './mountGradientsReadouts'; +import { mountMagnetReadouts } from './mountMagnetReadouts'; +import { mountOverviewReadouts } from './mountOverviewReadouts'; +import { mountPictureReadouts } from './mountPictureReadouts'; +import { mountResonanceControl } from './mountResonanceControl'; +import { mountSpinsReadouts } from './mountSpinsReadouts'; + +export { CHAPTER_ACTIONS } from './actions'; +export { MRI_SCANNER_CHOICES, VIEW_TOGGLES } from './dock'; +export { MRI_SCANNER_READOUTS } from './readouts'; + +export function mountMriScannerUi(root: Document, store: MriScannerStore): Disposer { + return disposeAll([ + mountOverviewReadouts(root, store), + mountMagnetReadouts(root, store), + mountSpinsReadouts(root, store), + mountResonanceControl(root, store), + mountGradientsReadouts(root, store), + mountPictureReadouts(root, store), + ]); +} diff --git a/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts b/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts new file mode 100644 index 00000000..3c6d6d3f --- /dev/null +++ b/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { CanvasSurface } from '@core/ui/canvasSurface'; +import type { CanvasFrame, Painter } from '@core/ui/canvasSurface'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { createMriScannerStore } from '../state'; +import type { MriScannerStore } from '../state'; +import { mountKspaceView } from './kspaceView'; + +const FRAME: CanvasFrame = { width: 256, height: 256, ratio: 1, fontFamily: 'sans-serif' }; + +function fakeContext() { + return { fillRect: vi.fn(), fillText: vi.fn() }; +} + +function paintWith(painter: Painter | undefined) { + const context = fakeContext(); + painter?.(context as unknown as CanvasRenderingContext2D, FRAME); + return context; +} + +describe('k-space and image canvases', () => { + let store: MriScannerStore; + let dispose: () => void; + let paint: MockInstance; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = chapters; + paint = vi.spyOn(CanvasSurface.prototype, 'paint'); + store = createMriScannerStore({ playing: false, linesFilled: 0 }); + dispose = mountKspaceView(document, store); + }); + + afterEach(() => { + dispose(); + paint.mockRestore(); + }); + + function lastPainters(): Painter[] { + return paint.mock.calls.slice(-2).map(([painter]) => painter); + } + + it('makes both canvases square', () => { + document.querySelectorAll('canvas').forEach((canvas) => { + expect(canvas.width).toBe(canvas.height); + }); + }); + + it('shows the empty hint on the image before the first line', () => { + expect(paint).toHaveBeenCalledTimes(2); + const [, image] = lastPainters(); + expect(paintWith(image).fillText).toHaveBeenCalledWith( + en.chapters.picture.empty, + expect.any(Number), + expect.any(Number), + expect.any(Number), + ); + }); + + it('draws k-space and the image once lines arrive', () => { + store.getState().setLinesFilled(16); + expect(paint).toHaveBeenCalledTimes(4); + const [kspace, image] = lastPainters(); + expect(paintWith(kspace).fillRect.mock.calls.length).toBeGreaterThan(1); + const drawn = paintWith(image); + expect(drawn.fillText).not.toHaveBeenCalled(); + expect(drawn.fillRect.mock.calls.length).toBeGreaterThan(1); + }); + + it('redraws only when the field, the weighting or the lines change', () => { + store.getState().setTipAngle(45); + store.getState().setTissue('fat'); + expect(paint).toHaveBeenCalledTimes(2); + store.getState().setField('field30'); + expect(paint).toHaveBeenCalledTimes(4); + }); +}); diff --git a/explainers/mri-scanner/src/ui/kspaceView.ts b/explainers/mri-scanner/src/ui/kspaceView.ts new file mode 100644 index 00000000..581ac2b9 --- /dev/null +++ b/explainers/mri-scanner/src/ui/kspaceView.ts @@ -0,0 +1,84 @@ +import { t } from '@core/i18n'; +import { CanvasSurface, canvasFont } from '@core/ui/canvasSurface'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { watchShallowLocalized } from '@core/ui/subscribe'; +import type { FieldId, WeightingId } from '../ids'; +import { MODEL_SIZE, kspaceDisplay, pictureFor } from '../model'; +import type { MriScannerStore, MriScannerStoreState } from '../state'; +import { THEME } from '../theme'; + +export type PictureInputs = readonly [field: FieldId, weighting: WeightingId, linesFilled: number]; + +const PIXELS_PER_CELL = 4; +const CANVAS_SIZE = MODEL_SIZE * PIXELS_PER_CELL; +const FULL_LEVEL = 255; +const EMPTY_FONT_PX = 13; +const EMPTY_PADDING_PX = 12; +const HALF = 0.5; +const BACKGROUND = THEME.screenFrame; + +function selectInputs(state: MriScannerStoreState): PictureInputs { + return [state.field, state.weighting, state.linesFilled]; +} + +function grey(level: number): string { + const channel = Math.round(level * FULL_LEVEL); + return `rgb(${channel} ${channel} ${channel})`; +} + +export function paintGrid( + context: CanvasRenderingContext2D, + frame: CanvasFrame, + values: Float32Array, +): void { + const cell = frame.width / MODEL_SIZE; + context.fillStyle = BACKGROUND; + context.fillRect(0, 0, frame.width, frame.height); + values.forEach((level, index) => { + if (level <= 0) return; + context.fillStyle = grey(level); + const row = Math.floor(index / MODEL_SIZE); + const column = index % MODEL_SIZE; + context.fillRect(column * cell, row * cell, Math.ceil(cell), Math.ceil(cell)); + }); +} + +export function paintEmpty(context: CanvasRenderingContext2D, frame: CanvasFrame): void { + context.fillStyle = BACKGROUND; + context.fillRect(0, 0, frame.width, frame.height); + context.fillStyle = THEME.rest; + context.font = canvasFont(frame, EMPTY_FONT_PX); + context.textAlign = 'center'; + context.textBaseline = 'middle'; + const maxWidth = frame.width - 2 * EMPTY_PADDING_PX; + context.fillText(t('chapters.picture.empty'), frame.width * HALF, frame.height * HALF, maxWidth); +} + +function squareSurface(root: ParentNode, name: string): CanvasSurface { + const canvas = requireElement(root, `[data-canvas="${name}"]`); + canvas.width = CANVAS_SIZE; + canvas.height = CANVAS_SIZE; + return new CanvasSurface(canvas); +} + +export function mountKspaceView(root: ParentNode, store: MriScannerStore): Disposer { + const kspace = squareSurface(root, 'kspace'); + const image = squareSurface(root, 'image'); + const stopWatching = watchShallowLocalized(store, selectInputs, ([field, weighting, lines]) => { + kspace.paint((context, frame) => + paintGrid(context, frame, kspaceDisplay(field, weighting, lines)), + ); + image.paint((context, frame) => + lines > 0 + ? paintGrid(context, frame, pictureFor(field, weighting, lines)) + : paintEmpty(context, frame), + ); + }); + return () => { + stopWatching(); + kspace.dispose(); + image.dispose(); + }; +} diff --git a/explainers/mri-scanner/src/ui/mountGradientsReadouts.ts b/explainers/mri-scanner/src/ui/mountGradientsReadouts.ts new file mode 100644 index 00000000..8dfc78ff --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountGradientsReadouts.ts @@ -0,0 +1,20 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { + formatAxisRole, + formatEdgeShift, + formatFieldShare, + formatRise, + formatSpread, +} from './format'; + +export function mountGradientsReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + edgeShift: () => formatEdgeShift(), + fieldShare: (state) => formatFieldShare(state.field), + spread: () => formatSpread(), + rise: () => formatRise(), + axisRole: (state) => formatAxisRole(state.gradientAxis), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountMagnetReadouts.ts b/explainers/mri-scanner/src/ui/mountMagnetReadouts.ts new file mode 100644 index 00000000..e43ff172 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountMagnetReadouts.ts @@ -0,0 +1,12 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { formatEarthMultiple, formatFringe, formatHelium } from './format'; + +export function mountMagnetReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + earthMultiple: (state) => formatEarthMultiple(state.field), + heliumTemp: () => formatHelium(), + fringe: (state) => formatFringe(state.field), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountOverviewReadouts.ts b/explainers/mri-scanner/src/ui/mountOverviewReadouts.ts new file mode 100644 index 00000000..c28134d3 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountOverviewReadouts.ts @@ -0,0 +1,11 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { formatBoreWidth, formatFieldNow } from './format'; + +export function mountOverviewReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + fieldNow: (state) => formatFieldNow(state.field), + boreWidth: () => formatBoreWidth(), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountPictureReadouts.ts b/explainers/mri-scanner/src/ui/mountPictureReadouts.ts new file mode 100644 index 00000000..0a5ddc92 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountPictureReadouts.ts @@ -0,0 +1,32 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { TissueId } from '../ids'; +import type { MriScannerStore, MriScannerStoreState } from '../state'; +import { formatBrightness, formatScanTime, formatTurbo } from './format'; +import { mountKspaceView } from './kspaceView'; + +const BRIGHTNESS_READOUTS: Readonly> = { + brightFat: 'fat', + brightWhite: 'whiteMatter', + brightGrey: 'greyMatter', + brightFluid: 'fluid', +}; + +function brightnessOf(tissue: TissueId) { + return (state: MriScannerStoreState) => formatBrightness(state.field, state.weighting, tissue); +} + +export function mountPictureReadouts(root: Document, store: MriScannerStore): Disposer { + const brightness = Object.fromEntries( + Object.entries(BRIGHTNESS_READOUTS).map(([id, tissue]) => [id, brightnessOf(tissue)]), + ); + return disposeAll([ + mountLiveReadouts(root, store, { + ...brightness, + scanTime: (state) => formatScanTime(state.weighting), + turbo: (state) => formatTurbo(state.weighting), + }), + mountKspaceView(root, store), + ]); +} diff --git a/explainers/mri-scanner/src/ui/mountResonanceControl.ts b/explainers/mri-scanner/src/ui/mountResonanceControl.ts new file mode 100644 index 00000000..adbd37cf --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountResonanceControl.ts @@ -0,0 +1,31 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { TIP_ANGLE_RANGE } from '../state'; +import type { MriScannerStore } from '../state'; +import { formatAcross, formatAlong, formatDegrees, formatT1, formatT2 } from './format'; +import { inChapter } from './actions'; + +type SelectedTip = readonly [tipAngle: number]; + +export function mountResonanceControl(root: Document, store: MriScannerStore): Disposer { + return disposeAll([ + mountRangeWidget(root, store, { + control: 'tip-angle', + range: TIP_ANGLE_RANGE, + select: (state): SelectedTip => [state.tipAngle], + value: ([tipAngle]) => tipAngle, + format: ([tipAngle]) => formatDegrees(tipAngle), + set: inChapter('resonance', (state, degrees: number) => state.setTipAngle(degrees)), + readouts: { + across: ([tipAngle]) => formatAcross(tipAngle), + along: ([tipAngle]) => formatAlong(tipAngle), + }, + }), + mountLiveReadouts(root, store, { + t1: (state) => formatT1(state.field, state.tissue), + t2: (state) => formatT2(state.field, state.tissue), + }), + ]); +} diff --git a/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts new file mode 100644 index 00000000..f87a0c4a --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts @@ -0,0 +1,13 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { formatLarmorBand, formatSlowdown, formatSurplus, formatSurplusCount } from './format'; + +export function mountSpinsReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + larmor: (state) => formatLarmorBand(state.field), + surplus: (state) => formatSurplus(state.field), + surplusCount: (state) => formatSurplusCount(state.field), + slowdown: (state) => formatSlowdown(state.field), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountUi.dom.test.ts b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts new file mode 100644 index 00000000..1383507f --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { mountActions } from '@core/ui/actions'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { AXIS_CHOICE_IDS, FIELD_IDS, LINE_CHOICE_IDS, TISSUE_IDS, WEIGHTING_IDS } from '../ids'; +import { MOMENTS } from '../model'; +import { createMriScannerStore } from '../state'; +import type { MriScannerStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; +import { fill, isFilled } from './testing'; +import { mountMriScannerUi } from '.'; + +const { units, chapters: copy } = en; + +function readout(id: string): string | null | undefined { + return document.querySelector(`[data-readout="${id}"]`)?.textContent; +} + +function click(action: string, value: string): void { + document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click(); +} + +function pressed(action: string, value: string): string | null | undefined { + return document + .querySelector(`[data-action="${action}"][data-value="${value}"]`) + ?.getAttribute('aria-pressed'); +} + +function tipInput(): HTMLInputElement { + const found = document.querySelector('[data-control="tip-angle"]'); + if (!found) throw new Error('No tip-angle control'); + return found; +} + +function chipValues(action: string): (string | null)[] { + return [...document.querySelectorAll(`.chip[data-action="${action}"]`)].map((node) => + node.getAttribute('data-value'), + ); +} + +describe('chapter widgets', () => { + let store: MriScannerStore; + let dispose: () => void; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = chapters; + store = createMriScannerStore({ playing: false, phase: 0 }); + dispose = mountMriScannerUi(document, store); + mountActions(document, store, CHAPTER_ACTIONS); + }); + + afterEach(() => dispose()); + + it('offers a chip for every option the chapters switch', () => { + expect(new Set(chipValues('field'))).toEqual(new Set(FIELD_IDS)); + expect(chipValues('weighting')).toEqual([...WEIGHTING_IDS]); + expect(chipValues('tissue')).toEqual([...TISSUE_IDS]); + expect(chipValues('gradientAxis')).toEqual([...AXIS_CHOICE_IDS]); + expect(chipValues('lines')).toEqual([...LINE_CHOICE_IDS]); + }); + + it('fills every readout with a finished text', () => { + const texts = [...document.querySelectorAll('[data-readout]')].map((node) => node.textContent); + expect(texts.length).toBeGreaterThan(0); + texts.forEach((text) => expect(isFilled(text), text ?? '').toBe(true)); + }); + + it('follows the field across the overview, the magnet and the spins', () => { + expect(readout('fieldNow')).toBe(fill(copy.overview.field, { tesla: '1.5 T' })); + click('field', 'field30'); + expect(pressed('field', 'field30')).toBe('true'); + expect(readout('fieldNow')).toBe(fill(copy.overview.field, { tesla: '3 T' })); + expect(readout('slowdown')).toBe(fill(copy.spins.slowdownValue, { factor: '255 million' })); + expect(readout('fieldShare')).toBe(fill(units.percent, { value: '0.18' })); + }); + + it('turns the tip angle into the across and along shares', () => { + expect(tipInput().max).toBe('180'); + tipInput().value = '180'; + tipInput().dispatchEvent(new Event('input')); + expect(store.getState().tipAngle).toBe(180); + expect(document.querySelector('output[for="tip-angle"]')?.textContent).toBe( + fill(units.degrees, { value: '180' }), + ); + expect(readout('along')).toBe(fill(units.percent, { value: '-100' })); + expect(readout('across')).toBe(fill(units.percent, { value: '0' })); + }); + + it('shows the relaxation times of the chosen tissue and pauses at a moment', () => { + click('tissue', 'fluid'); + expect(readout('t1')).toBe(fill(units.ms, { value: '4,300' })); + expect(readout('t2')).toBe(fill(units.ms, { value: '2,000' })); + click('moment', 'echoPeak'); + expect(store.getState().phase).toBe(MOMENTS.echoPeak); + expect(pressed('moment', 'echoPeak')).toBe('true'); + }); + + it('names the role of the held gradient coil', () => { + click('gradientAxis', 'z'); + expect(readout('axisRole')).toBe(copy.gradients.roleValue.z); + click('gradientAxis', 'sequence'); + expect(readout('axisRole')).toBe(copy.gradients.roleValue.sequence); + }); + + it('updates the picture readouts with the weighting and the lines', () => { + click('weighting', 't1'); + expect(readout('turbo')).toBe(fill(copy.picture.turboValue, { seconds: '8' })); + expect(readout('brightFat')).toBe(fill(units.percent, { value: '100' })); + click('lines', '16'); + expect(store.getState().linesFilled).toBe(16); + expect(pressed('lines', '16')).toBe('true'); + }); +}); diff --git a/explainers/mri-scanner/src/ui/readouts.test.ts b/explainers/mri-scanner/src/ui/readouts.test.ts new file mode 100644 index 00000000..7f683c72 --- /dev/null +++ b/explainers/mri-scanner/src/ui/readouts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { createMriScannerStore } from '../state'; +import { MRI_SCANNER_READOUTS } from './readouts'; + +const HALF_CYCLE = 500; +const HALF_LINES = 32; + +function meterOf(id: string) { + const meter = MRI_SCANNER_READOUTS.find((row) => row.id === id)?.meter; + if (!meter) throw new Error(`No meter on ${id}`); + return meter; +} + +describe('gauge readouts', () => { + it('shows the spin frequency, the time in the repetition and the lines of k-space', () => { + expect(MRI_SCANNER_READOUTS.map((row) => row.id)).toEqual(['larmor', 'time', 'lines']); + MRI_SCANNER_READOUTS.forEach((row) => expect(row.labelKey).toBe(`readouts.${row.id}`)); + MRI_SCANNER_READOUTS.forEach((row) => expect(row.numeric).toBe(true)); + }); + + it('meters the share of the repetition and the share of the lines', () => { + const state = createMriScannerStore({ phase: HALF_CYCLE, linesFilled: HALF_LINES }).getState(); + expect(meterOf('time').share(state)).toBeCloseTo(0.5); + expect(meterOf('lines').share(state)).toBeCloseTo(0.5); + expect(MRI_SCANNER_READOUTS.find((row) => row.id === 'larmor')?.meter).toBeUndefined(); + }); +}); diff --git a/explainers/mri-scanner/src/ui/readouts.ts b/explainers/mri-scanner/src/ui/readouts.ts new file mode 100644 index 00000000..22d5539b --- /dev/null +++ b/explainers/mri-scanner/src/ui/readouts.ts @@ -0,0 +1,28 @@ +import type { Readout } from '@core/explainer'; +import { lineShare, timeGauge } from '../state'; +import type { MriScannerStoreState } from '../state'; +import { THEME } from '../theme'; +import { formatLarmor, formatLines, formatTimeGauge } from './format'; + +export const MRI_SCANNER_READOUTS: readonly Readout[] = [ + { + id: 'larmor', + labelKey: 'readouts.larmor', + numeric: true, + value: (state) => formatLarmor(state.field), + }, + { + id: 'time', + labelKey: 'readouts.time', + numeric: true, + value: (state) => formatTimeGauge(timeGauge(state.phase, state.weighting)), + meter: { share: (state) => timeGauge(state.phase, state.weighting).share, fill: THEME.rf }, + }, + { + id: 'lines', + labelKey: 'readouts.lines', + numeric: true, + value: (state) => formatLines(state.linesFilled), + meter: { share: (state) => lineShare(state.linesFilled), fill: THEME.echo }, + }, +]; diff --git a/explainers/mri-scanner/src/ui/testing.ts b/explainers/mri-scanner/src/ui/testing.ts new file mode 100644 index 00000000..b8e1cc66 --- /dev/null +++ b/explainers/mri-scanner/src/ui/testing.ts @@ -0,0 +1,9 @@ +export function fill(template: string, values: Readonly>): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => values[name] ?? ''); +} + +const PLACEHOLDER = /\{\{|\}\}/; + +export function isFilled(text: string | null | undefined): boolean { + return Boolean(text) && !PLACEHOLDER.test(text ?? ''); +} From 094b738e8f58b7276add9e5ddc9866205649e454 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:03:36 +0200 Subject: [PATCH 07/82] feat(mri-scanner): add the scene controller, bindings and page entry --- explainers/mri-scanner/src/index.ts | 28 ++++ .../mri-scanner/src/scene/bindings.test.ts | 95 +++++++++++++ explainers/mri-scanner/src/scene/bindings.ts | 51 +++++++ .../src/scene/controller.dom.test.ts | 126 ++++++++++++++++++ .../mri-scanner/src/scene/controller.ts | 80 +++++++++++ explainers/mri-scanner/src/scene/index.ts | 71 ++++++++++ 6 files changed, 451 insertions(+) create mode 100644 explainers/mri-scanner/src/index.ts create mode 100644 explainers/mri-scanner/src/scene/bindings.test.ts create mode 100644 explainers/mri-scanner/src/scene/bindings.ts create mode 100644 explainers/mri-scanner/src/scene/controller.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/controller.ts create mode 100644 explainers/mri-scanner/src/scene/index.ts diff --git a/explainers/mri-scanner/src/index.ts b/explainers/mri-scanner/src/index.ts new file mode 100644 index 00000000..006f86c5 --- /dev/null +++ b/explainers/mri-scanner/src/index.ts @@ -0,0 +1,28 @@ +import './style.css'; +import { defineExplainer } from '@core/explainer'; +import { SCENE_OPTIONS, mountMriScannerScene } from './scene'; +import { PART_INFO } from './scene/partInfo'; +import { PRESETS, createMriScannerStore } from './state'; +import type { MriScannerStoreState } from './state'; +import { MRI_SCANNER_TIMELINE } from './timeline'; +import { + CHAPTER_ACTIONS, + MRI_SCANNER_CHOICES, + MRI_SCANNER_READOUTS, + VIEW_TOGGLES, + mountMriScannerUi, +} from './ui'; + +export default defineExplainer({ + id: 'mri-scanner', + timeline: MRI_SCANNER_TIMELINE, + presets: PRESETS, + parts: PART_INFO, + createStore: () => createMriScannerStore(), + dock: { choices: MRI_SCANNER_CHOICES, toggles: VIEW_TOGGLES }, + readouts: MRI_SCANNER_READOUTS, + actions: CHAPTER_ACTIONS, + scene: SCENE_OPTIONS, + mountScene: mountMriScannerScene, + mountUi: mountMriScannerUi, +}); diff --git a/explainers/mri-scanner/src/scene/bindings.test.ts b/explainers/mri-scanner/src/scene/bindings.test.ts new file mode 100644 index 00000000..0b760799 --- /dev/null +++ b/explainers/mri-scanner/src/scene/bindings.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { FIELDS } from '../model'; +import { createMriScannerStore, pictureVersionOf } from '../state'; +import { bindStore } from './bindings'; +import type { SceneTargets } from './bindings'; + +const LATER_PHASE = 0.4; +const FILLED_LINES = 32; + +function fakeTargets() { + const received: AssemblyState[] = []; + const snapshots: AssemblyState[] = []; + const record = (state: AssemblyState) => { + received.push(state); + snapshots.push(structuredClone(state)); + }; + const frame = vi.fn(); + const targets: SceneTargets = { + mriScanner: { build: record, setState: record, views: { frame } }, + labelVisibility: { setWanted: vi.fn() }, + highlighter: { setHighlight: vi.fn() }, + labels: { show: vi.fn() }, + }; + return { received, snapshots, frame, targets }; +} + +describe('mri scanner scene bindings', () => { + it('builds the scene from the store and frames the first chapter', () => { + const { received, frame, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + const { phase, field, weighting, linesFilled, view } = store.getState(); + expect(received[0]).toMatchObject({ + phase, + field, + weighting, + view, + fringe: FIELDS[field].fringe, + pictureVersion: pictureVersionOf(field, weighting, linesFilled), + }); + expect(frame).toHaveBeenCalledWith('room', false, undefined); + }); + + it('hands the next chapter its view flags before framing its camera', () => { + const { received, frame, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + store.getState().applyPreset('magnet'); + expect(frame).toHaveBeenLastCalledWith('cryostat', true, undefined); + expect(received.at(-1)?.view).toMatchObject({ cutaway: true, fieldLines: true }); + }); + + it('alternates two buffers so the held state is never rewritten', () => { + const { received, snapshots, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + store.getState().pause(); + store.getState().setPhase(LATER_PHASE); + store.getState().setTissue('fluid'); + const [first, second, third] = received.slice(-3); + expect(first).not.toBe(second); + expect(third).toBe(first); + expect(received.at(-2)).toEqual(snapshots.at(-2)); + expect(third?.tissue).toBe('fluid'); + }); + + it('bumps the picture version only when the picture changes', () => { + const { received, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + const built = { picture: received[0]?.picture, version: received[0]?.pictureVersion }; + store.getState().setPhase(LATER_PHASE); + store.getState().setTissue('fat'); + const unchanged = received.at(-1); + expect(unchanged?.pictureVersion).toBe(built.version); + expect(unchanged?.picture).toBe(built.picture); + store.getState().setLinesFilled(FILLED_LINES); + const { field, weighting } = store.getState(); + const changed = received.at(-1); + expect(changed?.pictureVersion).toBe(pictureVersionOf(field, weighting, FILLED_LINES)); + expect(changed?.pictureVersion).not.toBe(built.version); + expect(changed?.picture).not.toBe(built.picture); + }); + + it('stops handing over states once unbound', () => { + const { received, targets } = fakeTargets(); + const store = createMriScannerStore(); + const unbind = bindStore(store, targets); + unbind(); + const count = received.length; + store.getState().setPhase(LATER_PHASE); + expect(received).toHaveLength(count); + }); +}); diff --git a/explainers/mri-scanner/src/scene/bindings.ts b/explainers/mri-scanner/src/scene/bindings.ts new file mode 100644 index 00000000..f7cdf092 --- /dev/null +++ b/explainers/mri-scanner/src/scene/bindings.ts @@ -0,0 +1,51 @@ +import { bindPresets } from '@core/scene/presetBinder'; +import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder'; +import type { AssemblyState } from '../ids'; +import { PRESETS, createAssemblyState, writeAssemblyState } from '../state'; +import type { AssemblySource, MriScannerStore, MriScannerStoreState, Preset } from '../state'; +import type { MriScannerController } from './controller'; + +export interface SceneTargets extends PresetTargets { + mriScanner: Pick; + labelVisibility: LabelPolicy; +} + +class DoubleBufferedAssemblyState { + private heldByAssembly: AssemblyState; + private free: AssemblyState; + + constructor(source: AssemblySource) { + this.heldByAssembly = createAssemblyState(source); + this.free = createAssemblyState(source); + } + + handOver(source: AssemblySource): AssemblyState { + const next = writeAssemblyState(this.carryPicture(this.free), source); + this.free = this.heldByAssembly; + this.heldByAssembly = next; + return next; + } + + private carryPicture(target: AssemblyState): AssemblyState { + target.picture = this.heldByAssembly.picture; + target.pictureVersion = this.heldByAssembly.pictureVersion; + return target; + } +} + +export function bindStore(store: MriScannerStore, targets: SceneTargets): () => void { + const { mriScanner, labelVisibility } = targets; + const assemblyState = new DoubleBufferedAssemblyState(store.getState()); + const push = (state: AssemblySource) => mriScanner.setState(assemblyState.handOver(state)); + mriScanner.build(assemblyState.handOver(store.getState())); + const unsubscribers = [ + store.subscribe(push), + bindPresets(targets, store, { + presets: PRESETS, + views: mriScanner.views, + labels: labelVisibility, + prepare: push, + }), + ]; + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); +} diff --git a/explainers/mri-scanner/src/scene/controller.dom.test.ts b/explainers/mri-scanner/src/scene/controller.dom.test.ts new file mode 100644 index 00000000..2191db36 --- /dev/null +++ b/explainers/mri-scanner/src/scene/controller.dom.test.ts @@ -0,0 +1,126 @@ +import { Box3, Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import type { CameraPose } from '@core/scene/frameBox'; +import { MaterialLibrary } from '@core/scene/materials'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState, CameraView } from '../ids'; +import { createAssemblyState, createMriScannerStore } from '../state'; +import { createAssembly } from './assembly'; +import { CAMERA_VIEWS, STAGE_VARIANTS } from './cameraViews'; +import type { StageVariant } from './cameraViews'; +import { MriScannerController } from './controller'; +import type { MriScannerControllerDependencies } from './controller'; + +const FRAMING = { vertical: 0.2, horizontal: 0.27 }; +const STAGE_WIDTH_PX: Readonly> = { phone: 390, desktop: 835 }; +const DIRECTION_DIGITS = 6; +const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; + +function assemblyState(changes: Partial = {}): AssemblyState { + return { ...createAssemblyState(createMriScannerStore().getState()), ...changes }; +} + +function regionBox(view: CameraView): Box3 { + const resources = { materials: new MaterialLibrary(), textures: createSceneTextures() }; + return createAssembly(resources, assemblyState()).region(CAMERA_VIEWS[view].region); +} + +function fakeShell(stageWidth: number = STAGE_WIDTH_PX.desktop) { + const rig = { + framing: () => FRAMING, + follow: vi.fn(), + setDistanceLimits: vi.fn(), + jumpTo: vi.fn<(pose: CameraPose) => void>(), + tweenTo: vi.fn<(pose: CameraPose) => void>(), + setBounds: vi.fn<(bounds: Box3, floor: number) => void>(), + camera: { position: new Vector3(0, 1, 6) }, + controls: { target: new Vector3(0, 1, 0) }, + }; + const scene = { add: vi.fn<(object: Object3D) => void>() }; + const shell = { + scene, + materials: new MaterialLibrary(), + textures: createSceneTextures(), + labels: { attach: vi.fn(), show: vi.fn() }, + rig, + viewport: { + renderer: { compileAsync: vi.fn(() => Promise.resolve()) }, + element: { clientWidth: stageWidth }, + }, + }; + return { rig, scene, shell: shell as unknown as MriScannerControllerDependencies }; +} + +function viewDirection(pose: CameraPose): number[] { + return roundAll(pose.position.clone().sub(pose.target).normalize().toArray()); +} + +function roundAll(values: readonly number[]): number[] { + return values.map((value) => Number(value.toFixed(DIRECTION_DIGITS))); +} + +describe('mri scanner controller', () => { + it('adds the assembly, attaches its labels and bounds the camera by the room', () => { + const { rig, scene, shell } = fakeShell(); + new MriScannerController(shell).build(assemblyState()); + expect(scene.add).toHaveBeenCalledTimes(1); + expect(shell.labels.attach).toHaveBeenCalledTimes(1); + const [bounds, floor] = rig.setBounds.mock.lastCall ?? []; + expect(bounds).toBeInstanceOf(Box3); + expect(floor).toBe(bounds?.min.y); + }); + + it('frames nothing before the assembly exists', () => { + const { rig, shell } = fakeShell(); + new MriScannerController(shell).views.frame('room', false); + expect(rig.jumpTo).not.toHaveBeenCalled(); + }); + + it.each(STAGE_VARIANTS)('frames every fixed view on its region for a %s stage', (variant) => { + const { rig, shell } = fakeShell(STAGE_WIDTH_PX[variant]); + const controller = new MriScannerController(shell); + controller.build(assemblyState()); + for (const view of VIEWS) { + controller.views.frame(view, false); + const pose = rig.jumpTo.mock.lastCall?.[0]; + expect(pose?.target).toEqual(regionBox(view).getCenter(new Vector3())); + const direction = new Vector3(...CAMERA_VIEWS[view].direction[variant]).normalize(); + expect(pose && viewDirection(pose)).toEqual(roundAll(direction.toArray())); + expect(rig.follow).toHaveBeenLastCalledWith(null, undefined); + } + }); + + it('tweens when animated and honours an explicit variant', () => { + const { rig, shell } = fakeShell(STAGE_WIDTH_PX.phone); + const controller = new MriScannerController(shell); + controller.build(assemblyState()); + controller.views.frame('console', true, 'desktop'); + const pose = rig.tweenTo.mock.lastCall?.[0]; + const desktop = new Vector3(...CAMERA_VIEWS.console.direction.desktop).normalize(); + expect(pose && viewDirection(pose)).toEqual(roundAll(desktop.toArray())); + expect(rig.jumpTo).not.toHaveBeenCalled(); + }); + + it('reports no motion without an assembly and removes it on dispose', () => { + const { scene, shell } = fakeShell(); + const controller = new MriScannerController(shell); + expect(controller.update(1)).toBe(false); + controller.build(assemblyState()); + const removed = vi.spyOn(scene.add.mock.lastCall?.[0] as Object3D, 'removeFromParent'); + controller.dispose(); + expect(removed).toHaveBeenCalledTimes(1); + expect(controller.update(1)).toBe(false); + }); + + it('rebuilds without leaving the previous assembly behind', () => { + const { scene, shell } = fakeShell(); + const controller = new MriScannerController(shell); + controller.build(assemblyState()); + const first = scene.add.mock.lastCall?.[0]; + const removed = vi.spyOn(first as Object3D, 'removeFromParent'); + controller.build(assemblyState()); + expect(removed).toHaveBeenCalledTimes(1); + expect(scene.add).toHaveBeenCalledTimes(2); + }); +}); diff --git a/explainers/mri-scanner/src/scene/controller.ts b/explainers/mri-scanner/src/scene/controller.ts new file mode 100644 index 00000000..f73a5109 --- /dev/null +++ b/explainers/mri-scanner/src/scene/controller.ts @@ -0,0 +1,80 @@ +import type { Object3D } from 'three'; +import { CameraViews } from '@core/scene/cameraViews'; +import type { SceneShell } from '@core/scene/shell'; +import { whenIdle } from '@core/scene/warmUp'; +import type { AssemblyState, CameraView, RegionId } from '../ids'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { CAMERA_VIEWS, stageVariant } from './cameraViews'; +import type { StageVariant } from './cameraViews'; + +export type MriScannerControllerDependencies = Pick< + SceneShell, + 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport' +>; + +export type ViewFramer = Pick, 'frame'>; + +const BOUNDS_REGION: RegionId = 'room'; + +export class MriScannerController { + readonly views: ViewFramer; + private readonly dependencies: MriScannerControllerDependencies; + private readonly cameraViews: CameraViews; + private assembly: Assembly | null = null; + private cancelWarmUp: () => void = () => {}; + + constructor(dependencies: MriScannerControllerDependencies) { + this.dependencies = dependencies; + this.cameraViews = new CameraViews(dependencies.rig, { + views: CAMERA_VIEWS, + region: (id) => this.assembly?.region(id) ?? null, + }); + this.views = { + frame: (view, animate, variant) => + this.cameraViews.frame(view, animate, variant ?? this.stageVariant()), + }; + } + + build(state: AssemblyState): void { + const { scene, materials, textures, labels, rig } = this.dependencies; + this.dispose(); + const assembly = createAssembly({ materials, textures }, state); + this.assembly = assembly; + scene.add(assembly.root); + labels.attach(assembly.labelAnchors()); + const bounds = assembly.region(BOUNDS_REGION); + rig.setBounds(bounds, bounds.min.y); + this.cancelWarmUp = whenIdle(() => assembly.warmUp?.((object) => this.compileHidden(object))); + } + + setState(state: AssemblyState): void { + this.assembly?.setState(state); + } + + update(deltaSeconds: number): boolean { + const { camera, controls } = this.dependencies.rig; + return ( + this.assembly?.update(deltaSeconds, camera.position.distanceTo(controls.target)) ?? false + ); + } + + dispose(): void { + this.cancelWarmUp(); + this.assembly?.root.removeFromParent(); + this.assembly?.dispose(); + this.assembly = null; + } + + private stageVariant(): StageVariant { + return stageVariant(this.dependencies.viewport.element.clientWidth); + } + + private compileHidden(object: Object3D): void { + const { viewport, rig, scene } = this.dependencies; + const shown = object.visible; + object.visible = true; + void viewport.renderer.compileAsync(object, rig.camera, scene); + object.visible = shown; + } +} diff --git a/explainers/mri-scanner/src/scene/index.ts b/explainers/mri-scanner/src/scene/index.ts new file mode 100644 index 00000000..8b5b9a16 --- /dev/null +++ b/explainers/mri-scanner/src/scene/index.ts @@ -0,0 +1,71 @@ +import type { DirectionalLight } from 'three'; +import type { Lighting } from '@core/scene/lighting'; +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneOptions, SceneShell } from '@core/scene/shell'; +import type { MriScannerStore } from '../state'; +import { THEME } from '../theme'; +import { bindStore } from './bindings'; +import { MriScannerController } from './controller'; +import { LABEL_PRIORITY } from './partInfo'; + +export { bindStore } from './bindings'; +export type { SceneTargets } from './bindings'; +export { MriScannerController } from './controller'; +export type { MriScannerControllerDependencies, ViewFramer } from './controller'; + +type LightName = 'key' | 'fill' | 'rim'; + +interface LightSetting { + readonly color: string; + readonly intensity: number; + readonly position: readonly [x: number, y: number, z: number]; +} + +const MRI_SCANNER_LIGHT: Readonly> = { + key: { color: '#fff4e6', intensity: 2.2, position: [0, 80, 45] }, + fill: { color: '#b8d0f0', intensity: 0.6, position: [-70, 30, 25] }, + rim: { color: '#cfe0ff', intensity: 1.2, position: [80, 25, 25] }, +}; + +export const SCENE_OPTIONS: SceneOptions = { + background: THEME.background, + stage: false, + camera: { near: 0.05, far: 60, distance: { min: 0.3, max: 12 } }, + highlight: { undimmed: ['room', 'fieldLinesGroup'] }, +}; + +function applyLight(light: DirectionalLight, setting: LightSetting): () => void { + const color = light.color.clone(); + const intensity = light.intensity; + const position = light.position.clone(); + light.color.set(setting.color); + light.intensity = setting.intensity; + light.position.fromArray(setting.position); + return () => { + light.color.copy(color); + light.intensity = intensity; + light.position.copy(position); + }; +} + +function mriScannerLight(lighting: Pick): () => void { + const restorers = (Object.keys(MRI_SCANNER_LIGHT) as LightName[]).map((name) => + applyLight(lighting[name], MRI_SCANNER_LIGHT[name]), + ); + return () => restorers.forEach((restore) => restore()); +} + +export function mountMriScannerScene(shell: SceneShell, store: MriScannerStore): () => void { + const restoreLight = mriScannerLight(shell.lighting); + const mriScanner = new MriScannerController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => mriScanner.update(deltaSeconds)); + const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); + const unbind = bindStore(store, { mriScanner, labelVisibility, ...shell }); + return () => { + removeFrame(); + labelVisibility.dispose(); + unbind(); + mriScanner.dispose(); + restoreLight(); + }; +} From 352ba1a30e82bff4851fcd31eaaaf3cbd9ef9d89 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:19:09 +0200 Subject: [PATCH 08/82] fix(mri-scanner): keep the spins Larmor readout out of the gauge --- explainers/mri-scanner/src/ui/mountSpinsReadouts.ts | 5 ++++- explainers/mri-scanner/src/ui/mountUi.dom.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts index f87a0c4a..10f57f90 100644 --- a/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts +++ b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts @@ -1,10 +1,13 @@ import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; import { mountLiveReadouts } from '@core/ui/liveReadouts'; import type { MriScannerStore } from '../state'; import { formatLarmorBand, formatSlowdown, formatSurplus, formatSurplusCount } from './format'; +const SPINS_CHAPTER = 'section.chapter[data-preset="spins"]'; + export function mountSpinsReadouts(root: Document, store: MriScannerStore): Disposer { - return mountLiveReadouts(root, store, { + return mountLiveReadouts(requireElement(root, SPINS_CHAPTER), store, { larmor: (state) => formatLarmorBand(state.field), surplus: (state) => formatSurplus(state.field), surplusCount: (state) => formatSurplusCount(state.field), diff --git a/explainers/mri-scanner/src/ui/mountUi.dom.test.ts b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts index 1383507f..f68e18c4 100644 --- a/explainers/mri-scanner/src/ui/mountUi.dom.test.ts +++ b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts @@ -77,6 +77,16 @@ describe('chapter widgets', () => { expect(readout('fieldShare')).toBe(fill(units.percent, { value: '0.18' })); }); + it('keeps the spins larmor readout apart from the gauge readout of the same id', () => { + dispose(); + document.body.innerHTML = `
${chapters}`; + dispose = mountMriScannerUi(document, store); + store.getState().setField('field30'); + const spinsLarmor = document.querySelector('[data-preset="spins"] [data-readout="larmor"]'); + expect(spinsLarmor?.textContent).toContain('127.7'); + expect(document.querySelector('.gauge [data-readout="larmor"]')?.textContent).toBe(''); + }); + it('turns the tip angle into the across and along shares', () => { expect(tipInput().max).toBe('180'); tipInput().value = '180'; From ff71919c131ff0f554384d5185165c481b76385d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:19:09 +0200 Subject: [PATCH 09/82] fix(mri-scanner): frame the cryostat, coil and console views on a phone --- explainers/mri-scanner/src/scene/cameraViews.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/explainers/mri-scanner/src/scene/cameraViews.ts b/explainers/mri-scanner/src/scene/cameraViews.ts index 40200989..cf239db8 100644 --- a/explainers/mri-scanner/src/scene/cameraViews.ts +++ b/explainers/mri-scanner/src/scene/cameraViews.ts @@ -24,7 +24,7 @@ export const CAMERA_VIEWS: Readonly> = { cryostat: { region: 'layers', direction: { phone: [0.3, 0.25, 1], desktop: [0.5, 0.35, 1] }, - margin: 1.2, + margin: 1.5, }, voxel: { region: 'voxel', @@ -33,7 +33,7 @@ export const CAMERA_VIEWS: Readonly> = { }, coil: { region: 'bore', - direction: { phone: [0.7, 0.6, 1], desktop: [1, 0.55, 0.75] }, + direction: { phone: [0.4, 0.35, 1], desktop: [1, 0.55, 0.75] }, margin: 1.15, }, gradient: { @@ -43,7 +43,7 @@ export const CAMERA_VIEWS: Readonly> = { }, console: { region: 'console', - direction: { phone: [-1, 0.35, 0.65], desktop: [-1, 0.3, 0.45] }, - margin: 2.8, + direction: { phone: [-1, 0.15, 0.3], desktop: [-1, 0.3, 0.45] }, + margin: 2, }, }; From 53d42c8ed7d2a7f8b5b1911288fec1e439828b54 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:23:05 +0200 Subject: [PATCH 10/82] build(vite): keep single-explainer packages out of the shared chunk --- vite.config.ts | 12 +++++-- vite/chunks.test.ts | 80 +++++++++++++++++++++++++++++++++++++++++++-- vite/chunks.ts | 41 ++++++++++++++++++++++- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 13925dfa..8f7cf234 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -import { dependsOn } from './vite/chunks.ts'; +import { dependsOn, isExplainerOnlyPackage } from './vite/chunks.ts'; import type { ModuleGraph } from './vite/chunks.ts'; import { explainerPages } from './vite/explainerPages.ts'; import { siteCheck } from './vite/siteCheckPlugin.ts'; @@ -31,12 +31,18 @@ export default defineConfig({ groups: [ { name: 'three', test: THREE_MODULES }, { - name: (id, context) => (needsScene(id, context) ? null : 'shared'), + name: (id, context) => + isExplainerOnlyPackage(id, context) || needsScene(id, context) ? null : 'shared', debugName: 'shared', test: SHARED_MODULES, minShareCount: 2, }, - { name: 'scene', test: SHARED_MODULES, minShareCount: 2 }, + { + name: (id, context) => (isExplainerOnlyPackage(id, context) ? null : 'scene'), + debugName: 'scene', + test: SHARED_MODULES, + minShareCount: 2, + }, ], }, }, diff --git a/vite/chunks.test.ts b/vite/chunks.test.ts index af2e7c30..ade27a84 100644 --- a/vite/chunks.test.ts +++ b/vite/chunks.test.ts @@ -1,12 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { dependsOn } from './chunks.ts'; +import { dependsOn, isExplainerOnlyPackage } from './chunks.ts'; import type { ModuleGraph } from './chunks.ts'; const THREE = /node_modules\/three\//; -function graph(imports: Record): ModuleGraph { +type Imports = Record; + +function importersOf(id: string, imports: Imports): string[] { + return Object.keys(imports).filter((importer) => imports[importer].includes(id)); +} + +function graph(imports: Imports, dynamicImports: Imports = {}): ModuleGraph { return { - getModuleInfo: (id) => (id in imports ? { importedIds: imports[id] } : null), + getModuleInfo: (id) => + id in imports + ? { + importedIds: imports[id], + importers: importersOf(id, imports), + dynamicImporters: importersOf(id, dynamicImports), + } + : null, }; } @@ -34,3 +47,64 @@ describe('dependsOn', () => { expect(dependsOn('/src/site/main.ts', THREE, modules)).toBe(false); }); }); + +const packages = graph( + { + '/src/core/mount.ts': ['/node_modules/i18next/index.js'], + '/explainers/mri-scanner/src/model/kspace.ts': ['/node_modules/fft.js/lib/fft.js'], + '/explainers/mri-scanner/src/model/image.ts': [ + '/node_modules/fft.js/lib/fft.js', + '/node_modules/wrapper/index.js', + ], + '/explainers/mri-scanner/src/scene/coil.ts': ['/explainers/mri-scanner/src/model/image.ts'], + '/explainers/naval-drone/src/model/hull.ts': ['/node_modules/both/index.js'], + '/explainers/sundial/src/model/sun.ts': [ + '/node_modules/both/index.js', + '/node_modules/i18next/index.js', + ], + '/explainers/sundial/src/scene/sky.ts': [], + '/node_modules/wrapper/index.js': ['/node_modules/inner/index.js'], + '/node_modules/inner/index.js': ['/node_modules/wrapper/index.js'], + '/node_modules/fft.js/lib/fft.js': [], + '/node_modules/both/index.js': [], + '/node_modules/i18next/index.js': [], + '/node_modules/lazy/index.js': [], + '/node_modules/lazy-core/index.js': [], + '/node_modules/orphan/index.js': [], + }, + { + '/explainers/sundial/src/scene/sky.ts': [ + '/node_modules/lazy/index.js', + '/node_modules/lazy-core/index.js', + ], + '/src/core/mount.ts': ['/node_modules/lazy-core/index.js'], + }, +); + +describe('isExplainerOnlyPackage', () => { + it('is true for a package that only one explainer imports, from any of its files', () => { + expect(isExplainerOnlyPackage('/node_modules/fft.js/lib/fft.js', packages)).toBe(true); + }); + + it('follows imports through other packages, cycles included', () => { + expect(isExplainerOnlyPackage('/node_modules/inner/index.js', packages)).toBe(true); + }); + + it('counts dynamic imports as reaching the package', () => { + expect(isExplainerOnlyPackage('/node_modules/lazy/index.js', packages)).toBe(true); + expect(isExplainerOnlyPackage('/node_modules/lazy-core/index.js', packages)).toBe(false); + }); + + it('is false for a package that two explainers or the core import', () => { + expect(isExplainerOnlyPackage('/node_modules/both/index.js', packages)).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/i18next/index.js', packages)).toBe(false); + }); + + it('is false for project code and for a package nothing imports', () => { + expect(isExplainerOnlyPackage('/explainers/mri-scanner/src/model/image.ts', packages)).toBe( + false, + ); + expect(isExplainerOnlyPackage('/node_modules/orphan/index.js', packages)).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/unknown/index.js', packages)).toBe(false); + }); +}); diff --git a/vite/chunks.ts b/vite/chunks.ts index d88be957..dd0973de 100644 --- a/vite/chunks.ts +++ b/vite/chunks.ts @@ -1,7 +1,16 @@ +export interface ModuleInfo { + importedIds: readonly string[]; + importers: readonly string[]; + dynamicImporters: readonly string[]; +} + export interface ModuleGraph { - getModuleInfo(id: string): { importedIds: readonly string[] } | null; + getModuleInfo(id: string): ModuleInfo | null; } +const PACKAGE_MODULE = /[\\/]node_modules[\\/]/; +const EXPLAINER_MODULE = /[\\/]explainers[\\/]([^\\/]+)[\\/]/; + export function dependsOn(id: string, target: RegExp, graph: ModuleGraph): boolean { const visited = new Set([id]); const pending = [id]; @@ -15,3 +24,33 @@ export function dependsOn(id: string, target: RegExp, graph: ModuleGraph): boole } return false; } + +function importersOf(id: string, graph: ModuleGraph): readonly string[] { + const info = graph.getModuleInfo(id); + return info === null ? [] : [...info.importers, ...info.dynamicImporters]; +} + +function projectImporters(id: string, graph: ModuleGraph): Set { + const importers = new Set(); + const visited = new Set([id]); + const pending = [id]; + for (let current = pending.pop(); current !== undefined; current = pending.pop()) { + for (const importer of importersOf(current, graph)) { + if (visited.has(importer)) continue; + visited.add(importer); + if (PACKAGE_MODULE.test(importer)) pending.push(importer); + else importers.add(importer); + } + } + return importers; +} + +function explainerSlug(id: string): string | undefined { + return EXPLAINER_MODULE.exec(id)?.[1]; +} + +export function isExplainerOnlyPackage(id: string, graph: ModuleGraph): boolean { + if (!PACKAGE_MODULE.test(id)) return false; + const owners = new Set([...projectImporters(id, graph)].map(explainerSlug)); + return owners.size === 1 && !owners.has(undefined); +} From 8c9ccdadfed633ff16fa43c6e78d2e23df771bbd Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:39:21 +0200 Subject: [PATCH 11/82] feat(mri-scanner): add the English copy --- explainers/mri-scanner/chapters.html | 514 +++++++++++++++++++++++++ explainers/mri-scanner/locales/en.json | 250 ++++++++++++ 2 files changed, 764 insertions(+) create mode 100644 explainers/mri-scanner/chapters.html create mode 100644 explainers/mri-scanner/locales/en.json diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html new file mode 100644 index 00000000..3eb63d37 --- /dev/null +++ b/explainers/mri-scanner/chapters.html @@ -0,0 +1,514 @@ +
+

01

+

+ A tunnel that listens to your body +

+

+ This is an MRI scanner with a 1.5 tesla magnet. The + tesla measures how strong a magnetic field is. You lie on the + table, and it slides you into the bore, a tunnel about 70 cm + wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes. +

+

+ For a brain scan, a head coil sits around your head like a cage. It is an + antenna with 20 to 64 small receivers that listen for a faint radio echo from your body. The + room is lined with copper sheet to keep outside radio out, so it cannot drown that echo. There + are no X-rays here. A radio wave at this pitch carries about 50 million times too little energy + to knock an electron out of an atom. +

+

+ Under the white cover, layers sit one inside the other like rings: the magnet + in its cold jacket, three gradient coils and a radio antenna called the + body coil. Switch on the cutaway (C) to see them. The next chapters take them + one at a time. +

+

+ The model follows one repetition, the short cycle a scan runs again and again + to build one picture. In this model one repetition lasts 0.5 to 2.5 s of real time, but the + radio pulses inside it take only milliseconds. So the timeline is stretched: the pulses fill + most of the loop and the long rest is squeezed. The timeline names the step, and the live + readings show the real time. A whole head exam, with many pictures, takes about 30 to 45 + minutes. +

+
+
+
Main field
+
1.5 T, always on
+
+
+
Tunnel width
+
70 cm across
+
+
+
+ +
+

02

+

A magnet that never switches off

+

+ The biggest part is the main magnet: about 30 to 40 km of niobium-titanium wire + wound into coils. Cooled far enough, this wire becomes superconducting, which + means current flows through it with no resistance at all. Several hundred amperes go round and + round in a closed loop. Once the loop is closed, holding the field takes no power. It fades by + less than a tenth of a percent in a year. +

+

+ To stay superconducting, the coils sit in liquid helium at 4.2 K, about −269 + °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres + of it, while newer sealed magnets need just 7. A heat shield and a vacuum around the helium + vessel work like a thermos flask. A small fridge on top, the cold head, turns + boiled helium back into liquid; it runs day and night and draws about 6 to 7 kW. +

+

+ The field is tens of thousands of times stronger than Earth's, which changes from place to + place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside + push current the other way and pull the stray field in close. Even so, the 0.5 mT line, beyond + which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field + lines to see it. +

+

+ Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it + can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check + everyone and everything at the door. If the wire warms up somewhere, it stops superconducting + and the current turns into heat: a quench. The field drops to zero in 2 to 5 + seconds, and the boiling helium roars out of the building through the + quench pipe. +

+
+ + +
+
+
+
Against Earth's field
+
23,000 to 60,000 times stronger
+
+
+
Liquid helium
+
4.2 K, −269 °C
+
+
+
0.5 mT safety line
+
4 m along the tunnel, 2.5 m to the side
+
+
+
+ +
+

03

+

Hydrogen leans and wobbles

+

+ About six in ten atoms in your body are hydrogen, mostly in water and fat. The core of a + hydrogen atom, its nucleus, is a single proton. It behaves like a tiny magnet. + MRI listens to hydrogen because it is everywhere and gives the strongest signal. +

+

+ In the field, each tiny magnet does not simply point along it. It precesses: it + wobbles around the field direction like a tilted spinning top. At 1.5 T it wobbles about 64 + million times a second, 63.9 MHz. This rate is the Larmor frequency, and it + grows with the field, to 127.7 MHz at 3 T. Both are close to FM radio, one just below the band + and one just above it. +

+

+ The nuclei do not all line up. Body heat jostles them, so they point almost every which way. + Only about 5 in a million more lean with the field than against it. That small surplus still + adds up to about 3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny + net magnet, the arrow the scanner works with. +

+

+ The arrows in the inset are a drawing, enlarged many times, not a real spot in the head. They + also show the lean much stronger than it is, so you can see it. The drawn wobble turns half a + turn per second, about 128 million times slower than the real one at 1.5 T. Switch to 3 T and + watch the frequency and the surplus double. +

+
+ + +
+
+
+
Wobble frequency
+
63.9 MHz, just below FM radio
+
+
+
Extra nuclei leaning with the field
+
about 5 in a million
+
+
+
Surplus in 1 mm³ of water
+
about 3.3 × 10¹⁴
+
+
+
Drawing slowed down
+
about 128 million times
+
+
+
+ +
+

04

+

A radio pulse and its echo

+

+ To make the net magnet speak, the scanner sends a short radio pulse at exactly the Larmor + frequency. Matching the wobble like this is called resonance, like pushing a + child on a swing at just the right moment. The pulse comes from the body coil, + a cage of copper rungs just inside the tunnel. It lasts a few milliseconds at most. +

+

+ A 90° pulse tips the net magnet over until it lies sideways. There it keeps + wobbling, and as it sweeps past the head coil it makes a tiny voltage of a few microvolts. That + is the signal. Move the tip angle slider: only the sideways part makes signal, the part along + the field makes none. +

+

+ The spins soon drift out of step, because some wobble a little faster than others, and the + signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast + ones end up behind and catch up. They come back into step and give an echo at + the echo time. Use the chips to stop at each moment. +

+

+ Two clocks decide how bright each tissue looks. T1 is the time the net magnet + takes to grow about 63 % of the way back along the field. T2 is the time the + sideways signal takes to fade to about 37 % as the spins fall out of step. Fat recovers fast and + brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the scanner + keeps that heating within safe limits. +

+
+
+ 90° +
+ +
+
+
Sideways part (signal)
+
100 %
+
+
+
Part along the field
+
0 %
+
+
+
+
+ + + +
+
+ + + + +
+
+
+
T1, regrowth
+
884 ms
+
+
+
T2, fading
+
72 ms
+
+
+
+ +
+

05

+

Coils that tell places apart

+

+ So far every spot in the head answers at the same pitch, so the echo cannot tell where it came + from. Three more coils inside the magnet fix that: the gradient coils x, y and + z. A gradient is a field that changes steadily from one side to the other. Each + coil adds a little to the main field on one side of the centre and takes a little away on the + other. +

+

+ The change is small. At up to 45 mT per metre, the field at the edge of the head shifts by about + 5.4 mT, a third of a percent of the main field. That is enough to give every position its own + frequency. The coils switch on in about 0.2 ms. +

+

+ Each coil has its own job. The z coil works with the radio pulse to wake just one thin slice: + slice selection. The y coil gives each repetition a different twist from top to + bottom: phase encoding. The x coil runs while the echo is heard, so its pitch + tells left from right: frequency encoding. Pick a coil to see its field, or + follow the scan. +

+

+ Switching big currents inside a strong magnet makes the coils jolt, like a loudspeaker. That is + the loud knocking you hear during a scan. It can pass 100 decibels, about as loud as a + jackhammer or louder. That is why you always get earplugs or headphones. +

+
+ + + + +
+
+
+
Change at the head's edge
+
5.4 mT
+
+
+
Share of the main field
+
0.36 %
+
+
+
Pitch spread across the head
+
230 kHz
+
+
+
Switch-on time
+
0.225 ms
+
+
+
Job in this scan
+
Follows the sequence
+
+
+
+ +
+

06

+

From echoes to a picture

+

+ Each echo is stored as one line of k-space, a grid that sorts the echoes by + pitch and twist instead of by place. One repetition fills one line; the next one changes the + twist and fills another. In this model the picture is 64 × 64, so it takes 64 repetitions; real + head scans often use 256 lines. Here the lines fill from the middle outwards, so a rough picture + shows up early. +

+

+ A maths step called the Fourier transform turns that grid into the picture. It + finds which mix of stripes, broad and fine, adds up to the image. The middle of k-space holds + the broad stripes, the overall light and shade. The edges hold the fine detail. Try 8, 16, 32 or + 64 lines and watch the picture sharpen. +

+

+ The timing decides which tissues look bright. A T1-weighted scan repeats every + 500 ms and listens early, at 15 ms. The repetition ends before slow tissues have grown back, so + fat is bright and brain fluid is dark. A T2-weighted scan waits 2,500 ms and + listens at 100 ms: slow-fading fluid now shines, and grey matter beats white. In this model + every tissue holds the same amount of hydrogen, and bone and air stay black. +

+

+ With one line per repetition, 256 lines take about 2.1 minutes for the T1 scan and 10.7 for the + T2 scan. Real scanners use turbo spin echo, which reads 4 to 32 lines in each + repetition; at 16 lines, the T2 scan takes about 40 seconds. The idea is young. Paul Lauterbur + published the first MR image in 1973, and he and Peter Mansfield shared the 2003 Nobel Prize for + it. +

+
+ + +
+
+ + + + +
+
+ +
+ k-space, the stored echoes + The picture +
+
+
+
+
Fat
+
0 %
+
+
+
White matter
+
0 %
+
+
+
Grey matter
+
0 %
+
+
+
Brain fluid
+
0 %
+
+
+
One line per repetition, 256 lines
+
2.1 min for 256 lines
+
+
+
Turbo spin echo, 16 lines per repetition
+
about 8 s
+
+
+
diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json new file mode 100644 index 00000000..56af3fcb --- /dev/null +++ b/explainers/mri-scanner/locales/en.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "How an MRI scanner works", + "eyebrow": "Medical imaging", + "tagline": "A giant magnet, a radio pulse and the hydrogen in your body: how a scanner hears a faint echo and turns it into a picture.", + "description": "An MRI scanner in 3D: the magnet that never switches off, how hydrogen answers a radio pulse, the knocking coils, and how echoes become a picture.", + "summary": "The superconducting magnet, leaning hydrogen, the radio pulse and its echo, the gradient coils, and k-space.", + "socialAlt": "A cutaway MRI scanner with its nested magnet layers around the tunnel, beside a slice picture of a head and the title How an MRI scanner works" + }, + "stage": { + "label": "Interactive MRI scanner model", + "gauge": "Live scan readings" + }, + "timeline": { + "label": "Time in one repetition", + "phases": "Jump to a step", + "speed": "Playback speed", + "value": "{{phase}}", + "during": "Step {{step}} of {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} repetitions per minute", + "phase": { + "excite": "Radio pulse", + "encode": "Encode", + "refocus": "Refocus", + "echo": "Echo", + "recover": "Recover" + }, + "jump": { + "excite": "Jump to the radio pulse", + "encode": "Jump to the encoding", + "refocus": "Jump to the refocusing pulse", + "echo": "Jump to the echo", + "recover": "Jump to the recovery" + } + }, + "controls": { + "label": "Model controls", + "field": "Field strength", + "weighting": "Picture type", + "fieldOptions": { + "field15": "1.5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "T1-weighted", + "t2": "T2-weighted" + }, + "tissueOptions": { + "fat": "Fat", + "whiteMatter": "White matter", + "greyMatter": "Grey matter", + "fluid": "Brain fluid" + }, + "axisOptions": { + "x": "x coil", + "y": "y coil", + "z": "z coil", + "sequence": "Follow the scan" + }, + "momentOptions": { + "pulse90": "90° pulse", + "pulse180": "180° pulse", + "echoPeak": "Echo peak", + "repetitionEnd": "End of the repetition" + }, + "linesOptions": { + "8": "8 lines", + "16": "16 lines", + "32": "32 lines", + "64": "64 lines" + }, + "groups": { + "field": "Field strength", + "weighting": "Picture type", + "tissue": "Tissue", + "axis": "Gradient coil", + "moment": "Moment in the repetition", + "lines": "Lines of k-space" + }, + "views": { + "cutaway": { + "name": "Cutaway", + "short": "Cutaway", + "hint": "Cutaway (C)" + }, + "fieldLines": { + "name": "Field lines", + "short": "Field", + "hint": "Field lines (F)" + }, + "labels": { + "name": "Labels", + "short": "Labels", + "hint": "Labels (L)" + } + } + }, + "readouts": { + "larmor": "Spin frequency", + "time": "Time in the repetition", + "lines": "Lines of k-space" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} of {{total}} ms", + "linesOf": "{{value}} of {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Main field", + "field": "{{tesla}}, always on", + "boreLabel": "Tunnel width", + "bore": "{{cm}} across" + }, + "magnet": { + "earth": "Against Earth's field", + "earthValue": "{{min}} to {{max}} times stronger", + "helium": "Liquid helium", + "fringe": "0.5 mT safety line", + "fringeValue": "{{along}} m along the tunnel, {{side}} m to the side" + }, + "spins": { + "larmor": "Wobble frequency", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "just below FM radio", + "aboveFm": "just above FM radio" + }, + "surplus": "Extra nuclei leaning with the field", + "surplusValue": "about {{perMillion}} in a million", + "count": "Surplus in 1 mm³ of water", + "countValue": "about {{mantissa}} × 10{{exponent}}", + "slowdown": "Drawing slowed down", + "slowdownValue": "about {{millions}} million times" + }, + "resonance": { + "tip": "Tip angle", + "across": "Sideways part (signal)", + "along": "Part along the field", + "t1": "T1, regrowth", + "t2": "T2, fading" + }, + "gradients": { + "edge": "Change at the head's edge", + "share": "Share of the main field", + "spread": "Pitch spread across the head", + "rise": "Switch-on time", + "role": "Job in this scan", + "roleValue": { + "x": "Frequency: left to right", + "y": "Phase: one twist per line", + "z": "Slice: picks one thin slab", + "sequence": "Follows the sequence" + } + }, + "picture": { + "kspace": "k-space, the stored echoes", + "image": "The picture", + "scanTime": "One line per repetition, 256 lines", + "scanTimeValue": "{{minutes}} min for {{lines}} lines", + "turbo": "Turbo spin echo, 16 lines per repetition", + "turboValue": "about {{seconds}} s", + "empty": "Press play to fill the first line" + } + }, + "parts": { + "room": "Copper-lined room", + "cover": "Cover", + "vacuumVessel": "Vacuum vessel", + "radiationShield": "Heat shield", + "heliumVessel": "Helium vessel", + "mainCoils": "Main magnet coils", + "shieldCoils": "Shield coils", + "shims": "Shims", + "coldHead": "Cold head", + "quenchPipe": "Quench pipe", + "gradientX": "Gradient coil x", + "gradientY": "Gradient coil y", + "gradientZ": "Gradient coil z", + "bodyCoil": "Body coil", + "bore": "Bore", + "table": "Table", + "patient": "Patient", + "headCoil": "Head coil", + "sliceSlab": "Slice", + "spinArrows": "Hydrogen nuclei", + "netMagnet": "Net magnet", + "mainField": "Main field", + "rfWave": "Radio pulse", + "echoWave": "Echo", + "fieldLinesGroup": "Field lines", + "fringeLine": "0.5 mT line", + "screen": "Picture" + }, + "sections": { + "overview": { + "title": "A tunnel that listens to your body", + "p1": "This is an MRI scanner with a 1.5 tesla magnet. The tesla measures how strong a magnetic field is. You lie on the table, and it slides you into the bore, a tunnel about 70 cm wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes.", + "p2": "For a brain scan, a head coil sits around your head like a cage. It is an antenna with 20 to 64 small receivers that listen for a faint radio echo from your body. The room is lined with copper sheet to keep outside radio out, so it cannot drown that echo. There are no X-rays here. A radio wave at this pitch carries about 50 million times too little energy to knock an electron out of an atom.", + "p3": "Under the white cover, layers sit one inside the other like rings: the magnet in its cold jacket, three gradient coils and a radio antenna called the body coil. Switch on the cutaway (C) to see them. The next chapters take them one at a time.", + "p4": "The model follows one repetition, the short cycle a scan runs again and again to build one picture. In this model one repetition lasts 0.5 to 2.5 s of real time, but the radio pulses inside it take only milliseconds. So the timeline is stretched: the pulses fill most of the loop and the long rest is squeezed. The timeline names the step, and the live readings show the real time. A whole head exam, with many pictures, takes about 30 to 45 minutes." + }, + "magnet": { + "title": "A magnet that never switches off", + "p1": "The biggest part is the main magnet: about 30 to 40 km of niobium-titanium wire wound into coils. Cooled far enough, this wire becomes superconducting, which means current flows through it with no resistance at all. Several hundred amperes go round and round in a closed loop. Once the loop is closed, holding the field takes no power. It fades by less than a tenth of a percent in a year.", + "p2": "To stay superconducting, the coils sit in liquid helium at 4.2 K, about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres of it, while newer sealed magnets need just 7. A heat shield and a vacuum around the helium vessel work like a thermos flask. A small fridge on top, the cold head, turns boiled helium back into liquid; it runs day and night and draws about 6 to 7 kW.", + "p3": "The field is tens of thousands of times stronger than Earth's, which changes from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside push current the other way and pull the stray field in close. Even so, the 0.5 mT line, beyond which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field lines to see it.", + "p4": "Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting and the current turns into heat: a quench. The field drops to zero in 2 to 5 seconds, and the boiling helium roars out of the building through the quench pipe." + }, + "spins": { + "title": "Hydrogen leans and wobbles", + "p1": "About six in ten atoms in your body are hydrogen, mostly in water and fat. The core of a hydrogen atom, its nucleus, is a single proton. It behaves like a tiny magnet. MRI listens to hydrogen because it is everywhere and gives the strongest signal.", + "p2": "In the field, each tiny magnet does not simply point along it. It precesses: it wobbles around the field direction like a tilted spinning top. At 1.5 T it wobbles about 64 million times a second, 63.9 MHz. This rate is the Larmor frequency, and it grows with the field, to 127.7 MHz at 3 T. Both are close to FM radio, one just below the band and one just above it.", + "p3": "The nuclei do not all line up. Body heat jostles them, so they point almost every which way. Only about 5 in a million more lean with the field than against it. That small surplus still adds up to about 3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny net magnet, the arrow the scanner works with.", + "p4": "The arrows in the inset are a drawing, enlarged many times, not a real spot in the head. They also show the lean much stronger than it is, so you can see it. The drawn wobble turns half a turn per second, about 128 million times slower than the real one at 1.5 T. Switch to 3 T and watch the frequency and the surplus double." + }, + "resonance": { + "title": "A radio pulse and its echo", + "p1": "To make the net magnet speak, the scanner sends a short radio pulse at exactly the Larmor frequency. Matching the wobble like this is called resonance, like pushing a child on a swing at just the right moment. The pulse comes from the body coil, a cage of copper rungs just inside the tunnel. It lasts a few milliseconds at most.", + "p2": "A 90° pulse tips the net magnet over until it lies sideways. There it keeps wobbling, and as it sweeps past the head coil it makes a tiny voltage of a few microvolts. That is the signal. Move the tip angle slider: only the sideways part makes signal, the part along the field makes none.", + "p3": "The spins soon drift out of step, because some wobble a little faster than others, and the signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast ones end up behind and catch up. They come back into step and give an echo at the echo time. Use the chips to stop at each moment.", + "p4": "Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the sideways signal takes to fade to about 37 % as the spins fall out of step. Fat recovers fast and brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the scanner keeps that heating within safe limits." + }, + "gradients": { + "title": "Coils that tell places apart", + "p1": "So far every spot in the head answers at the same pitch, so the echo cannot tell where it came from. Three more coils inside the magnet fix that: the gradient coils x, y and z. A gradient is a field that changes steadily from one side to the other. Each coil adds a little to the main field on one side of the centre and takes a little away on the other.", + "p2": "The change is small. At up to 45 mT per metre, the field at the edge of the head shifts by about 5.4 mT, a third of a percent of the main field. That is enough to give every position its own frequency. The coils switch on in about 0.2 ms.", + "p3": "Each coil has its own job. The z coil works with the radio pulse to wake just one thin slice: slice selection. The y coil gives each repetition a different twist from top to bottom: phase encoding. The x coil runs while the echo is heard, so its pitch tells left from right: frequency encoding. Pick a coil to see its field, or follow the scan.", + "p4": "Switching big currents inside a strong magnet makes the coils jolt, like a loudspeaker. That is the loud knocking you hear during a scan. It can pass 100 decibels, about as loud as a jackhammer or louder. That is why you always get earplugs or headphones." + }, + "picture": { + "title": "From echoes to a picture", + "p1": "Each echo is stored as one line of k-space, a grid that sorts the echoes by pitch and twist instead of by place. One repetition fills one line; the next one changes the twist and fills another. In this model the picture is 64 × 64, so it takes 64 repetitions; real head scans often use 256 lines. Here the lines fill from the middle outwards, so a rough picture shows up early.", + "p2": "A maths step called the Fourier transform turns that grid into the picture. It finds which mix of stripes, broad and fine, adds up to the image. The middle of k-space holds the broad stripes, the overall light and shade. The edges hold the fine detail. Try 8, 16, 32 or 64 lines and watch the picture sharpen.", + "p3": "The timing decides which tissues look bright. A T1-weighted scan repeats every 500 ms and listens early, at 15 ms. The repetition ends before slow tissues have grown back, so fat is bright and brain fluid is dark. A T2-weighted scan waits 2,500 ms and listens at 100 ms: slow-fading fluid now shines, and grey matter beats white. In this model every tissue holds the same amount of hydrogen, and bone and air stay black.", + "p4": "With one line per repetition, 256 lines take about 2.1 minutes for the T1 scan and 10.7 for the T2 scan. Real scanners use turbo spin echo, which reads 4 to 32 lines in each repetition; at 16 lines, the T2 scan takes about 40 seconds. The idea is young. Paul Lauterbur published the first MR image in 1973, and he and Peter Mansfield shared the 2003 Nobel Prize for it." + } + } +} From 44b7b6b1ea25edc11217a1837b2f7e5d336983b6 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:43:49 +0200 Subject: [PATCH 12/82] fix(mri-scanner): place the pacemaker risk inside the 0.5 mT line --- explainers/mri-scanner/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index 56af3fcb..eddc32c9 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -215,7 +215,7 @@ "title": "A magnet that never switches off", "p1": "The biggest part is the main magnet: about 30 to 40 km of niobium-titanium wire wound into coils. Cooled far enough, this wire becomes superconducting, which means current flows through it with no resistance at all. Several hundred amperes go round and round in a closed loop. Once the loop is closed, holding the field takes no power. It fades by less than a tenth of a percent in a year.", "p2": "To stay superconducting, the coils sit in liquid helium at 4.2 K, about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres of it, while newer sealed magnets need just 7. A heat shield and a vacuum around the helium vessel work like a thermos flask. A small fridge on top, the cold head, turns boiled helium back into liquid; it runs day and night and draws about 6 to 7 kW.", - "p3": "The field is tens of thousands of times stronger than Earth's, which changes from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside push current the other way and pull the stray field in close. Even so, the 0.5 mT line, beyond which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field lines to see it.", + "p3": "The field is tens of thousands of times stronger than Earth's, which changes from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside push current the other way and pull the stray field in close. Even so, the 0.5 mT line, inside which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field lines to see it.", "p4": "Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting and the current turns into heat: a quench. The field drops to zero in 2 to 5 seconds, and the boiling helium roars out of the building through the quench pipe." }, "spins": { From fa30e6f0ec92262679ee4d12c0df1a1d207501eb Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:43:50 +0200 Subject: [PATCH 13/82] feat(mri-scanner): add the Chinese copy --- explainers/mri-scanner/locales/zh.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/zh.json diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json new file mode 100644 index 00000000..6b43464c --- /dev/null +++ b/explainers/mri-scanner/locales/zh.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "MRI扫描仪如何工作", + "eyebrow": "医学影像", + "tagline": "一块巨大的磁铁,一道无线电脉冲,再加上你体内的氢:扫描仪怎样听见微弱的回波,又怎样把它变成图像。", + "description": "用3D模型看MRI扫描仪:从不关闭的磁铁,氢怎样回应无线电脉冲,咚咚作响的线圈,还有回波怎样变成图像。", + "summary": "超导磁体、倾斜的氢原子核、无线电脉冲和它的回波、梯度线圈,还有k空间。", + "socialAlt": "一台剖开的MRI扫描仪,隧道外面是一层套一层的磁体结构,旁边是一张头部断层图像和标题:MRI扫描仪如何工作。" + }, + "stage": { + "label": "MRI扫描仪互动模型", + "gauge": "实时扫描读数" + }, + "timeline": { + "label": "一个重复周期内的时间", + "phases": "跳到某一步", + "speed": "播放速度", + "value": "{{phase}}", + "during": "第{{step}}步,共{{total}}步:{{phase}}", + "speedValue": "{{value}}次/分钟", + "speedFormat": "每分钟重复{{value}}次", + "phase": { + "excite": "无线电脉冲", + "encode": "编码", + "refocus": "重聚焦", + "echo": "回波", + "recover": "恢复" + }, + "jump": { + "excite": "跳到无线电脉冲", + "encode": "跳到编码", + "refocus": "跳到重聚焦脉冲", + "echo": "跳到回波", + "recover": "跳到恢复阶段" + } + }, + "controls": { + "label": "模型控制", + "field": "磁场强度", + "weighting": "图像类型", + "fieldOptions": { + "field15": "1.5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "T1加权", + "t2": "T2加权" + }, + "tissueOptions": { + "fat": "脂肪", + "whiteMatter": "白质", + "greyMatter": "灰质", + "fluid": "脑脊液" + }, + "axisOptions": { + "x": "x线圈", + "y": "y线圈", + "z": "z线圈", + "sequence": "跟随扫描" + }, + "momentOptions": { + "pulse90": "90°脉冲", + "pulse180": "180°脉冲", + "echoPeak": "回波峰值", + "repetitionEnd": "重复周期结束" + }, + "linesOptions": { + "8": "8行", + "16": "16行", + "32": "32行", + "64": "64行" + }, + "groups": { + "field": "磁场强度", + "weighting": "图像类型", + "tissue": "组织", + "axis": "梯度线圈", + "moment": "重复周期中的时刻", + "lines": "k空间行数" + }, + "views": { + "cutaway": { + "name": "剖面", + "short": "剖面", + "hint": "剖面(C)" + }, + "fieldLines": { + "name": "磁力线", + "short": "磁场", + "hint": "磁力线(F)" + }, + "labels": { + "name": "标注", + "short": "标注", + "hint": "标注(L)" + } + } + }, + "readouts": { + "larmor": "自旋频率", + "time": "重复周期内的时间", + "lines": "k空间行数" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}}/{{total}}毫秒", + "linesOf": "{{value}}/{{total}}", + "degrees": "{{value}}°", + "percent": "{{value}}%", + "ms": "{{value}}毫秒", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K,{{celsius}} °C", + "minutes": "{{value}}分钟", + "seconds": "{{value}}秒", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}}厘米" + }, + "chapters": { + "overview": { + "fieldLabel": "主磁场", + "field": "{{tesla}},从不关闭", + "boreLabel": "隧道宽度", + "bore": "直径{{cm}}" + }, + "magnet": { + "earth": "与地球磁场相比", + "earthValue": "强{{min}}到{{max}}倍", + "helium": "液氦", + "fringe": "0.5 mT安全线", + "fringeValue": "沿隧道方向{{along}}米,侧面{{side}}米" + }, + "spins": { + "larmor": "摆动频率", + "larmorBand": "{{value}},{{band}}", + "band": { + "belowFm": "略低于调频广播", + "aboveFm": "略高于调频广播" + }, + "surplus": "顺着磁场多出的核", + "surplusValue": "约百万分之{{perMillion}}", + "count": "1 mm³水中多出的核", + "countValue": "约{{mantissa}} × 10{{exponent}}个", + "slowdown": "画面放慢了", + "slowdownValue": "约{{millions}}00万倍" + }, + "resonance": { + "tip": "翻转角", + "across": "横向分量(信号)", + "along": "沿磁场的分量", + "t1": "T1,回升", + "t2": "T2,衰减" + }, + "gradients": { + "edge": "头部边缘的变化", + "share": "占主磁场的比例", + "spread": "头部两侧的频率差", + "rise": "开启时间", + "role": "在这次扫描中的任务", + "roleValue": { + "x": "频率:分出左右", + "y": "相位:每行扭一次", + "z": "层面:选出薄薄一层", + "sequence": "跟随扫描序列" + } + }, + "picture": { + "kspace": "k空间,存下的回波", + "image": "图像", + "scanTime": "每个重复周期一行,共256行", + "scanTimeValue": "{{lines}}行需{{minutes}}分钟", + "turbo": "快速自旋回波,每个重复周期16行", + "turboValue": "约{{seconds}}秒", + "empty": "按播放键,填入第一行" + } + }, + "parts": { + "room": "铜板屏蔽室", + "cover": "外壳", + "vacuumVessel": "真空容器", + "radiationShield": "隔热屏", + "heliumVessel": "氦容器", + "mainCoils": "主磁体线圈", + "shieldCoils": "屏蔽线圈", + "shims": "匀场片", + "coldHead": "冷头", + "quenchPipe": "失超管", + "gradientX": "梯度线圈x", + "gradientY": "梯度线圈y", + "gradientZ": "梯度线圈z", + "bodyCoil": "体线圈", + "bore": "孔腔", + "table": "检查床", + "patient": "患者", + "headCoil": "头线圈", + "sliceSlab": "层面", + "spinArrows": "氢原子核", + "netMagnet": "净磁体", + "mainField": "主磁场", + "rfWave": "无线电脉冲", + "echoWave": "回波", + "fieldLinesGroup": "磁力线", + "fringeLine": "0.5 mT线", + "screen": "图像" + }, + "sections": { + "overview": { + "title": "一条倾听你身体的隧道", + "p1": "这是一台MRI扫描仪,磁铁强度为1.5特斯拉。特斯拉是衡量磁场强弱的单位。你躺在检查床上,床把你送进孔腔,也就是一条宽约70厘米的隧道。围着隧道的机器长约1.6到1.9米,重3到6吨。", + "p2": "做脑部扫描时,头线圈像笼子一样罩住你的头。它是一副天线,带有20到64个小接收器,专门听你身体发出的微弱无线电回波。房间四壁衬着铜板,把外面的无线电挡在门外,免得它盖过这个回波。这里没有X射线。这种频率的无线电波能量太小:要把一个电子从原子里打出来,它还差约5000万倍。", + "p3": "白色外壳下面,几层结构像套环一样一层套一层:裹在低温外套里的磁铁、三组梯度线圈,还有一副叫体线圈的无线电天线。打开剖面(C)就能看到它们。接下来几章会逐一介绍。", + "p4": "模型演示一个重复周期,也就是扫描为了拼出一张图像而一遍遍重复的短循环。在这个模型里,一个重复周期实际持续0.5到2.5秒,但其中的无线电脉冲只有几毫秒。所以时间轴被拉伸了:脉冲占了循环的大部分,漫长的休息被压缩。时间轴标出当前步骤,实时读数显示真实时间。一次完整的头部检查要拍很多张图像,大约需要30到45分钟。" + }, + "magnet": { + "title": "一块从不关闭的磁铁", + "p1": "最大的部件是主磁体:约30到40千米长的铌钛导线绕成的线圈。冷却到足够低的温度,这种导线就会超导,也就是电流在里面流动时完全没有电阻。几百安培的电流在闭合回路里一圈圈地流。回路一旦闭合,维持磁场就不再需要电力。一年下来,磁场减弱不到千分之一。", + "p2": "为了保持超导,线圈泡在4.2 K的液氦里,约合−269 °C。这只比宇宙深空高约1.5 K。传统磁体要装约1500升液氦,较新的密封磁体只需7升。氦容器外面有隔热屏和真空层,原理就像保温瓶。顶上有一台小冰箱,叫冷头,它把沸腾汽化的氦重新变回液体;它日夜运转,耗电约6到7千瓦。", + "p3": "这个磁场比地球磁场强几万倍,而地球磁场的强弱因地而异。选3 T,看看更强的磁铁是什么样。外侧还有额外的屏蔽线圈,电流方向相反,把外泄的磁场收拢在近处。即便如此,0.5 mT线沿隧道方向仍延伸约4米;在这条线以内,心脏起搏器可能就不再安全。打开磁力线就能看到它。", + "p4": "磁铁从不关闭,所以门口有一块亮着的警示牌提醒这一点。任何含铁的东西都可能被吸进隧道,氧气瓶就曾这样夺去人命。所以工作人员会在门口检查每个人、每样东西。如果导线某处变热,它就不再超导,电流变成热量:这叫失超。磁场在2到5秒内降到零,沸腾的氦经由失超管呼啸着冲出大楼。" + }, + "spins": { + "title": "氢核会倾斜,也会摆动", + "p1": "你体内约十分之六的原子是氢,大多在水和脂肪里。氢原子的核心叫原子核,只有一个质子。它就像一块小磁铁。MRI专听氢,因为氢到处都有,信号也最强。", + "p2": "在磁场里,每块小磁铁并不是简单地顺着磁场指向。它会进动:像一个倾斜旋转的陀螺,绕着磁场方向摆动。在1.5 T下,它每秒摆动约6400万次,也就是63.9 MHz。这个频率叫拉莫尔频率,磁场越强它越高,在3 T下达到127.7 MHz。两者都接近调频广播,一个略低于这个频段,一个略高于它。", + "p3": "这些原子核并没有全都排整齐。体温让它们不停晃动,所以它们几乎指向四面八方。顺着磁场倾斜的核,只比逆着磁场的多出约百万分之五。差别看似很小,可一立方毫米的水里,多出的核仍有约3 × 10¹⁴个。它们合在一起,形成一块微小的净磁体,也就是扫描仪要用的那个箭头。", + "p4": "插图里的箭头只是示意,放大了很多倍,并不是头部里真实的某个位置。为了让你看清,倾斜也画得比实际夸张得多。画出来的摆动每秒转半圈,比1.5 T下真实的摆动慢约1.28亿倍。切换到3 T,看频率和多出的核都翻一倍。" + }, + "resonance": { + "title": "无线电脉冲和它的回波", + "p1": "要让净磁体开口说话,扫描仪会发出一道短促的无线电脉冲,频率正好等于拉莫尔频率。像这样合上摆动的节拍,就叫共振,就像在恰好的时刻推一把荡秋千的孩子。脉冲来自体线圈,这是一个由铜条围成的笼子,紧贴在隧道内侧。它最多持续几毫秒。", + "p2": "一道90°脉冲把净磁体放倒,让它横躺下来。它在那里继续摆动,扫过头线圈时会产生几微伏的微小电压。这就是信号。拖动翻转角滑块:只有横向分量产生信号,沿磁场的分量不产生。", + "p3": "自旋很快就会乱了步调,因为有些摆得稍快,有些稍慢,信号随之减弱。走到回波的半路时,一道180°脉冲把它们翻过来,快的反而落在后面,于是又追了上来。它们重新同步,发出一个回波,这个时刻叫回波时间。用这些按钮可以停在每个时刻。", + "p4": "两个时钟决定每种组织看上去有多亮。T1是净磁体沿磁场方向恢复约63%所需的时间。T2是自旋失去同步、横向信号衰减到约37%所需的时间。脂肪恢复得快,脑脊液恢复得慢;选一种组织比较一下。脉冲还会让你身体微微发热,所以扫描仪会把升温控制在安全范围内。" + }, + "gradients": { + "title": "分辨位置的线圈", + "p1": "到目前为止,头部每个位置都以同样的频率回应,所以回波分不出自己来自哪里。磁体里面另外三组线圈解决了这个问题:梯度线圈x、y和z。梯度是指从一侧到另一侧均匀变化的磁场。每组线圈在中心的一侧给主磁场加一点,在另一侧减一点。", + "p2": "这个变化很小。梯度最高可达每米45 mT,这时头部边缘的磁场只偏移约5.4 mT,相当于主磁场的三百分之一。这已足够让每个位置都有自己的频率。线圈约0.2毫秒就能开启。", + "p3": "每组线圈各有分工。z线圈和无线电脉冲配合,只唤醒薄薄的一层:这叫层面选择。y线圈让每个重复周期从上到下带上不同的扭转:这叫相位编码。x线圈在接收回波时工作,于是频率高低就能分出左右:这叫频率编码。选一组线圈看它的磁场,或者跟随扫描。", + "p4": "在强磁场里切换大电流,会让线圈像扬声器一样猛地震动。这就是扫描时你听到的响亮敲击声。它可能超过100分贝,和风镐差不多响,甚至更响。所以你总会拿到耳塞或耳机。" + }, + "picture": { + "title": "从回波到图像", + "p1": "每个回波存成k空间里的一行。k空间是一张网格,按频率和扭转来排列回波,而不是按位置。一个重复周期填一行;下一个周期换一种扭转,再填一行。这个模型的图像是64 × 64,所以要64个重复周期;真实的头部扫描常用256行。这里的行从中间往外填,所以粗略的图像很早就会出现。", + "p2": "一个叫傅里叶变换的数学步骤,把这张网格变成图像。它找出哪些粗细不同的条纹叠在一起,正好拼成这张图。k空间的中间存着粗条纹,也就是整体的明暗。边缘存着细节。试试8、16、32或64行,看图像怎样变清晰。", + "p3": "时间安排决定哪些组织显得亮。T1加权扫描每500毫秒重复一次,早早地在15毫秒时就听回波。重复周期在慢组织恢复之前就结束了,所以脂肪亮,脑脊液暗。T2加权扫描要等2500毫秒,在100毫秒时听:衰减慢的液体这时发亮,灰质也比白质亮。在这个模型里,每种组织含的氢一样多,骨头和空气始终是黑的。", + "p4": "如果每个重复周期只读一行,256行的T1扫描约需2.1分钟,T2扫描约需10.7分钟。真实的扫描仪用快速自旋回波,每个重复周期读4到32行;读16行时,T2扫描只需约40秒。这个想法还很年轻。保罗·劳特伯在1973年发表了第一张磁共振图像,他和彼得·曼斯菲尔德因此共同获得2003年诺贝尔奖。" + } + } +} From 188485af2604b10d8268e1fdf5af9e54f9440c88 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:43:50 +0200 Subject: [PATCH 14/82] feat(mri-scanner): add the Portuguese copy --- explainers/mri-scanner/locales/pt.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/pt.json diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json new file mode 100644 index 00000000..46552236 --- /dev/null +++ b/explainers/mri-scanner/locales/pt.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "Como funciona um aparelho de ressonância magnética", + "eyebrow": "Diagnóstico por imagem", + "tagline": "Um ímã gigante, um pulso de rádio e o hidrogênio do seu corpo: como o aparelho escuta um eco fraquinho e o transforma em imagem.", + "description": "Ressonância magnética em 3D: o ímã que nunca desliga, o hidrogênio que responde a um pulso de rádio, as bobinas que batem e os ecos que viram imagem.", + "summary": "O ímã supercondutor, o hidrogênio que se inclina, o pulso de rádio e seu eco, as bobinas de gradiente e o espaço k.", + "socialAlt": "Um aparelho de ressonância magnética em corte, com as camadas do ímã umas dentro das outras em volta do túnel. Ao lado, a imagem de um corte da cabeça e o título “Como funciona um aparelho de ressonância magnética”" + }, + "stage": { + "label": "Modelo interativo de um aparelho de ressonância magnética", + "gauge": "Leituras do exame em tempo real" + }, + "timeline": { + "label": "Tempo dentro de uma repetição", + "phases": "Ir para uma etapa", + "speed": "Velocidade de reprodução", + "value": "{{phase}}", + "during": "Etapa {{step}} de {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} repetições por minuto", + "phase": { + "excite": "Pulso de rádio", + "encode": "Codificação", + "refocus": "Refocalização", + "echo": "Eco", + "recover": "Recuperação" + }, + "jump": { + "excite": "Ir para o pulso de rádio", + "encode": "Ir para a codificação", + "refocus": "Ir para o pulso de refocalização", + "echo": "Ir para o eco", + "recover": "Ir para a recuperação" + } + }, + "controls": { + "label": "Controles do modelo", + "field": "Intensidade do campo", + "weighting": "Tipo de imagem", + "fieldOptions": { + "field15": "1,5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "Ponderada em T1", + "t2": "Ponderada em T2" + }, + "tissueOptions": { + "fat": "Gordura", + "whiteMatter": "Substância branca", + "greyMatter": "Substância cinzenta", + "fluid": "Líquido cerebral" + }, + "axisOptions": { + "x": "Bobina x", + "y": "Bobina y", + "z": "Bobina z", + "sequence": "Seguir o exame" + }, + "momentOptions": { + "pulse90": "Pulso de 90°", + "pulse180": "Pulso de 180°", + "echoPeak": "Pico do eco", + "repetitionEnd": "Fim da repetição" + }, + "linesOptions": { + "8": "8 linhas", + "16": "16 linhas", + "32": "32 linhas", + "64": "64 linhas" + }, + "groups": { + "field": "Intensidade do campo", + "weighting": "Tipo de imagem", + "tissue": "Tecido", + "axis": "Bobina de gradiente", + "moment": "Momento da repetição", + "lines": "Linhas do espaço k" + }, + "views": { + "cutaway": { + "name": "Vista em corte", + "short": "Corte", + "hint": "Vista em corte (C)" + }, + "fieldLines": { + "name": "Linhas de campo", + "short": "Campo", + "hint": "Linhas de campo (F)" + }, + "labels": { + "name": "Rótulos", + "short": "Rótulos", + "hint": "Rótulos (L)" + } + } + }, + "readouts": { + "larmor": "Frequência de precessão", + "time": "Tempo na repetição", + "lines": "Linhas do espaço k" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} de {{total}} ms", + "linesOf": "{{value}} de {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Campo principal", + "field": "{{tesla}}, sempre ligado", + "boreLabel": "Largura do túnel", + "bore": "{{cm}} de diâmetro" + }, + "magnet": { + "earth": "Comparado ao campo da Terra", + "earthValue": "de {{min}} a {{max}} vezes mais forte", + "helium": "Hélio líquido", + "fringe": "Linha de segurança de 0,5 mT", + "fringeValue": "{{along}} m ao longo do túnel, {{side}} m para o lado" + }, + "spins": { + "larmor": "Frequência da oscilação", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "logo abaixo do rádio FM", + "aboveFm": "logo acima do rádio FM" + }, + "surplus": "Núcleos a mais inclinados a favor do campo", + "surplusValue": "cerca de {{perMillion}} em um milhão", + "count": "Excedente em 1 mm³ de água", + "countValue": "cerca de {{mantissa}} × 10{{exponent}}", + "slowdown": "Desenho desacelerado", + "slowdownValue": "cerca de {{millions}} milhões de vezes" + }, + "resonance": { + "tip": "Ângulo de inclinação", + "across": "Parte lateral (sinal)", + "along": "Parte ao longo do campo", + "t1": "T1, recuperação", + "t2": "T2, decaimento" + }, + "gradients": { + "edge": "Variação na borda da cabeça", + "share": "Fração do campo principal", + "spread": "Faixa de frequências na cabeça", + "rise": "Tempo para ligar", + "role": "Função neste exame", + "roleValue": { + "x": "Frequência: da esquerda para a direita", + "y": "Fase: uma torção por linha", + "z": "Fatia: escolhe uma camada fina", + "sequence": "Segue a sequência" + } + }, + "picture": { + "kspace": "Espaço k, os ecos guardados", + "image": "A imagem", + "scanTime": "Uma linha por repetição, 256 linhas", + "scanTimeValue": "{{minutes}} min para {{lines}} linhas", + "turbo": "Turbo spin eco, 16 linhas por repetição", + "turboValue": "cerca de {{seconds}} s", + "empty": "Aperte o play para preencher a primeira linha" + } + }, + "parts": { + "room": "Sala revestida de cobre", + "cover": "Cobertura", + "vacuumVessel": "Câmara de vácuo", + "radiationShield": "Escudo térmico", + "heliumVessel": "Reservatório de hélio", + "mainCoils": "Bobinas do ímã principal", + "shieldCoils": "Bobinas de blindagem", + "shims": "Shims", + "coldHead": "Cabeçote de refrigeração", + "quenchPipe": "Tubo de quench", + "gradientX": "Bobina de gradiente x", + "gradientY": "Bobina de gradiente y", + "gradientZ": "Bobina de gradiente z", + "bodyCoil": "Bobina de corpo", + "bore": "Túnel", + "table": "Mesa", + "patient": "Paciente", + "headCoil": "Bobina de cabeça", + "sliceSlab": "Fatia", + "spinArrows": "Núcleos de hidrogênio", + "netMagnet": "Ímã resultante", + "mainField": "Campo principal", + "rfWave": "Pulso de rádio", + "echoWave": "Eco", + "fieldLinesGroup": "Linhas de campo", + "fringeLine": "Linha de 0,5 mT", + "screen": "Imagem" + }, + "sections": { + "overview": { + "title": "Um túnel que escuta o seu corpo", + "p1": "Este é um aparelho de ressonância magnética com um ímã de 1,5 tesla. O tesla é a unidade que mede a força de um campo magnético. Você se deita na mesa, e ela desliza com você para dentro do túnel, que tem cerca de 70 cm de largura. A máquina em volta mede cerca de 1,6 a 1,9 m de comprimento e pesa de 3 a 6 toneladas.", + "p2": "Num exame do cérebro, uma bobina de cabeça envolve sua cabeça como uma gaiola. Ela é uma antena com 20 a 64 pequenos receptores, que escutam um eco de rádio fraquinho vindo do seu corpo. A sala é revestida com chapas de cobre para barrar as ondas de rádio de fora, que poderia abafar esse eco. Aqui não há raios X. Uma onda de rádio nessa frequência carrega cerca de 50 milhões de vezes menos energia do que seria preciso para arrancar um elétron de um átomo.", + "p3": "Sob a cobertura branca, as camadas ficam umas dentro das outras, como anéis: o ímã na sua capa fria, três bobinas de gradiente e uma antena de rádio chamada bobina de corpo. Ligue a vista em corte (C) para vê-las. Os próximos capítulos mostram uma de cada vez.", + "p4": "O modelo acompanha uma repetição, o ciclo curto que o exame repete muitas vezes para montar uma imagem. Neste modelo, uma repetição dura de 0,5 a 2,5 s de tempo real, mas os pulsos de rádio dentro dela levam só milissegundos. Por isso a linha do tempo é esticada: os pulsos ocupam a maior parte do ciclo, e o longo descanso fica espremido. A linha do tempo mostra o nome da etapa, e as leituras ao vivo mostram o tempo real. Um exame completo da cabeça, com muitas imagens, leva cerca de 30 a 45 minutos." + }, + "magnet": { + "title": "Um ímã que nunca desliga", + "p1": "A maior peça é o ímã principal: cerca de 30 a 40 km de fio de nióbio-titânio enrolado em bobinas. Quando é resfriado o bastante, esse fio fica supercondutor, ou seja, a corrente passa por ele sem nenhuma resistência. Várias centenas de ampères circulam sem parar num circuito fechado. Depois que o circuito é fechado, manter o campo não gasta energia. Em um ano, ele perde menos de um décimo de um por cento da força.", + "p2": "Para continuar supercondutoras, as bobinas ficam mergulhadas em hélio líquido a 4,2 K, cerca de −269 °C. Isso é só cerca de 1,5 K mais quente que o espaço profundo. Um ímã clássico guarda cerca de 1.500 litros de hélio, enquanto os ímãs selados mais novos precisam de apenas 7. Um escudo térmico e um vácuo em volta do reservatório de hélio funcionam como uma garrafa térmica. Uma pequena geladeira no topo, o cabeçote de refrigeração, transforma o hélio que ferveu de volta em líquido. Ela funciona dia e noite e consome cerca de 6 a 7 kW.", + "p3": "O campo é dezenas de milhares de vezes mais forte que o da Terra, que varia de um lugar para outro. Escolha 3 T para comparar com um ímã mais forte. Bobinas de blindagem extras, do lado de fora, fazem a corrente circular no sentido contrário e puxam para perto o campo que escapa. Mesmo assim, a linha de 0,5 mT, dentro da qual um marca-passo pode deixar de ser seguro, chega a cerca de 4 m ao longo do túnel. Ligue as linhas de campo para vê-la.", + "p4": "Como o ímã nunca desliga, um aviso luminoso na porta lembra disso. Qualquer coisa com ferro pode voar para dentro do túnel, e cilindros de oxigênio já mataram pessoas desse jeito. É por isso que a equipe confere todo mundo e tudo o que entra pela porta. Se o fio esquenta em algum ponto, ele deixa de ser supercondutor e a corrente vira calor: é o quench. O campo cai a zero em 2 a 5 segundos, e o hélio fervendo sai rugindo do prédio pelo tubo de quench." + }, + "spins": { + "title": "O hidrogênio se inclina e oscila", + "p1": "Cerca de seis em cada dez átomos do seu corpo são de hidrogênio, a maioria na água e na gordura. O centro de um átomo de hidrogênio, o seu núcleo, é um único próton. Ele se comporta como um ímã minúsculo. A ressonância magnética escuta o hidrogênio porque ele está em toda parte e dá o sinal mais forte.", + "p2": "Dentro do campo, cada um desses ímãs minúsculos não fica simplesmente apontado na direção dele. Ele faz precessão: oscila em volta dessa direção, como um pião inclinado que gira. A 1,5 T, ele oscila cerca de 64 milhões de vezes por segundo, ou 63,9 MHz. Esse ritmo é a frequência de Larmor, e ela cresce com o campo, chegando a 127,7 MHz a 3 T. As duas ficam perto do rádio FM, uma logo abaixo da faixa e a outra logo acima dela.", + "p3": "Os núcleos não se alinham todos. O calor do corpo os sacode, e eles apontam para quase todos os lados. Os que se inclinam a favor do campo superam os que se inclinam contra ele por só cerca de 5 em um milhão. Mesmo assim, esse pequeno excedente soma cerca de 3 × 10¹⁴ núcleos em um milímetro cúbico de água. Juntos, eles formam um minúsculo ímã resultante, a seta com que o aparelho trabalha.", + "p4": "As setas no quadro em destaque são um desenho, ampliado muitas vezes, e não um ponto real da cabeça. Elas também mostram a inclinação bem mais forte do que ela é, para que você consiga vê-la. A oscilação desenhada dá meia volta por segundo, cerca de 128 milhões de vezes mais devagar que a real a 1,5 T. Mude para 3 T e veja a frequência e o excedente dobrarem." + }, + "resonance": { + "title": "Um pulso de rádio e seu eco", + "p1": "Para fazer o ímã resultante falar, o aparelho envia um pulso de rádio curto exatamente na frequência de Larmor. Acompanhar a oscilação desse jeito se chama ressonância, como empurrar uma criança no balanço bem na hora certa. O pulso sai da bobina de corpo, uma gaiola de barras de cobre logo por dentro do túnel. Ele dura no máximo alguns milissegundos.", + "p2": "Um pulso de 90° tomba o ímã resultante até ele ficar deitado de lado. Ali ele continua oscilando e, ao passar pela bobina de cabeça, gera uma tensão minúscula, de alguns microvolts. Esse é o sinal. Mexa no controle do ângulo de inclinação: só a parte lateral gera sinal, e a parte ao longo do campo não gera nenhum.", + "p3": "Os núcleos logo perdem o compasso, porque alguns oscilam um pouco mais rápido que outros, e o sinal se apaga. Na metade do caminho até o eco, um pulso de 180° inverte todos eles. Assim, os mais rápidos ficam para trás e alcançam os outros. Eles voltam ao mesmo compasso e geram um eco no tempo de eco. Use os botões para parar em cada momento.", + "p4": "Dois relógios decidem o brilho de cada tecido. T1 é o tempo que o ímã resultante leva para voltar cerca de 63 % do caminho ao longo do campo. T2 é o tempo que o sinal lateral leva para cair a cerca de 37 %, à medida que os núcleos perdem o compasso. A gordura se recupera rápido, e o líquido cerebral, devagar: escolha um tecido para comparar. Os pulsos também aquecem você um pouco, então o aparelho mantém esse aquecimento dentro de limites seguros." + }, + "gradients": { + "title": "Bobinas que distinguem os lugares", + "p1": "Até aqui, todo ponto da cabeça responde no mesmo tom, então o eco não tem como dizer de onde veio. Mais três bobinas dentro do ímã resolvem isso: as bobinas de gradiente x, y e z. Um gradiente é um campo que muda aos poucos, por igual, de um lado para o outro. Cada bobina soma um pouco ao campo principal de um lado do centro e tira um pouco do outro lado.", + "p2": "A mudança é pequena. Com até 45 mT por metro, o campo na borda da cabeça muda cerca de 5,4 mT, um terço de um por cento do campo principal. Isso basta para dar a cada posição a sua própria frequência. As bobinas ligam em cerca de 0,2 ms.", + "p3": "Cada bobina tem a sua função. A bobina z trabalha junto com o pulso de rádio para despertar só uma fatia fina: é a seleção de fatia. A bobina y dá a cada repetição uma torção diferente, de cima para baixo: é a codificação de fase. A bobina x funciona enquanto o eco é escutado, e assim o tom do eco separa a esquerda da direita: é a codificação de frequência. Escolha uma bobina para ver o campo dela ou siga o exame.", + "p4": "Ligar e desligar correntes fortes dentro de um ímã potente faz as bobinas darem trancos, como um alto-falante. São essas as batidas altas que você ouve durante o exame. O barulho pode passar de 100 decibéis, mais ou menos como uma britadeira, ou até mais. Por isso você sempre recebe protetores de ouvido ou fones." + }, + "picture": { + "title": "Dos ecos à imagem", + "p1": "Cada eco é guardado como uma linha do espaço k, uma grade que organiza os ecos por tom e por torção, e não por lugar. Uma repetição preenche uma linha; a seguinte muda a torção e preenche outra. Neste modelo, a imagem tem 64 × 64, então são necessárias 64 repetições. Exames reais da cabeça costumam usar 256 linhas. Aqui as linhas são preenchidas do meio para fora, por isso uma imagem borrada já aparece cedo.", + "p2": "Uma etapa de matemática chamada transformada de Fourier converte essa grade na imagem. Ela descobre quais listras, largas e finas, formam a imagem quando somadas. O meio do espaço k guarda as listras largas: o claro e o escuro gerais da imagem. As bordas guardam os detalhes finos. Experimente 8, 16, 32 ou 64 linhas e veja a imagem ficar mais nítida.", + "p3": "Os tempos escolhidos decidem quais tecidos aparecem claros. Um exame ponderado em T1 se repete a cada 500 ms e escuta cedo, aos 15 ms. A repetição termina antes que os tecidos lentos tenham voltado a crescer, então a gordura fica clara e o líquido cerebral, escuro. Um exame ponderado em T2 espera 2.500 ms e escuta aos 100 ms: agora o líquido, que demora a se apagar, brilha, e a substância cinzenta fica mais clara que a branca. Neste modelo, todo tecido tem a mesma quantidade de hidrogênio, e osso e ar ficam pretos.", + "p4": "Com uma linha por repetição, 256 linhas levam cerca de 2,1 minutos no exame T1 e 10,7 no exame T2. Aparelhos reais usam turbo spin eco, que lê de 4 a 32 linhas em cada repetição. Com 16 linhas, o exame T2 leva cerca de 40 segundos. A ideia é recente. Paul Lauterbur publicou a primeira imagem por ressonância magnética em 1973, e ele e Peter Mansfield dividiram o Prêmio Nobel de 2003 por isso." + } + } +} From 54d94d43daeab39a51a344805e237b7e73da3a96 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:43:50 +0200 Subject: [PATCH 15/82] feat(mri-scanner): add the Japanese copy --- explainers/mri-scanner/locales/ja.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/ja.json diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json new file mode 100644 index 00000000..10aa5d55 --- /dev/null +++ b/explainers/mri-scanner/locales/ja.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "MRI装置のしくみ", + "eyebrow": "医療画像", + "tagline": "巨大な磁石、電波のパルス、そしてあなたの体の水素。装置がかすかなエコーを聞き取り、画像に変えるしくみです。", + "description": "MRI装置を3Dで。ずっとオンのままの磁石、電波に応える水素、ガンガン鳴るコイル、エコーが画像になるまでを見ていきます。", + "summary": "超伝導磁石、傾く水素、電波パルスとそのエコー、傾斜磁場コイル、k空間。", + "socialAlt": "入れ子になった磁石の層がトンネルを囲む、MRI装置の断面図。横には頭のスライス画像と、「MRI装置のしくみ」というタイトルがあります。" + }, + "stage": { + "label": "MRI装置のインタラクティブモデル", + "gauge": "撮影中のリアルタイム計測" + }, + "timeline": { + "label": "1回の繰り返しの中の時間", + "phases": "ステップへ移動", + "speed": "再生速度", + "value": "{{phase}}", + "during": "ステップ{{step}}/{{total}}:{{phase}}", + "speedValue": "{{value}}回/分", + "speedFormat": "1分あたり{{value}}回の繰り返し", + "phase": { + "excite": "電波パルス", + "encode": "エンコード", + "refocus": "再収束", + "echo": "エコー", + "recover": "回復" + }, + "jump": { + "excite": "電波パルスへ移動", + "encode": "エンコードへ移動", + "refocus": "再収束パルスへ移動", + "echo": "エコーへ移動", + "recover": "回復へ移動" + } + }, + "controls": { + "label": "モデルの操作", + "field": "磁場の強さ", + "weighting": "画像の種類", + "fieldOptions": { + "field15": "1.5T", + "field30": "3T" + }, + "weightingOptions": { + "t1": "T1強調", + "t2": "T2強調" + }, + "tissueOptions": { + "fat": "脂肪", + "whiteMatter": "白質", + "greyMatter": "灰白質", + "fluid": "脳脊髄液" + }, + "axisOptions": { + "x": "xコイル", + "y": "yコイル", + "z": "zコイル", + "sequence": "撮影の流れを追う" + }, + "momentOptions": { + "pulse90": "90°パルス", + "pulse180": "180°パルス", + "echoPeak": "エコーのピーク", + "repetitionEnd": "繰り返しの終わり" + }, + "linesOptions": { + "8": "8ライン", + "16": "16ライン", + "32": "32ライン", + "64": "64ライン" + }, + "groups": { + "field": "磁場の強さ", + "weighting": "画像の種類", + "tissue": "組織", + "axis": "傾斜磁場コイル", + "moment": "繰り返しの中の瞬間", + "lines": "k空間のライン数" + }, + "views": { + "cutaway": { + "name": "断面表示", + "short": "断面", + "hint": "断面表示(C)" + }, + "fieldLines": { + "name": "磁力線", + "short": "磁場", + "hint": "磁力線(F)" + }, + "labels": { + "name": "ラベル", + "short": "ラベル", + "hint": "ラベル(L)" + } + } + }, + "readouts": { + "larmor": "スピンの周波数", + "time": "繰り返しの中の時間", + "lines": "k空間のライン" + }, + "units": { + "mhz": "{{value}}MHz", + "msOf": "{{value}}/{{total}}ミリ秒", + "linesOf": "{{value}}/{{total}}", + "degrees": "{{value}}°", + "percent": "{{value}}%", + "ms": "{{value}}ミリ秒", + "tesla": "{{value}}T", + "kelvin": "{{kelvin}}K、{{celsius}}°C", + "minutes": "{{value}}分", + "seconds": "{{value}}秒", + "kHz": "{{value}}kHz", + "mT": "{{value}}mT", + "cm": "{{value}}cm" + }, + "chapters": { + "overview": { + "fieldLabel": "主磁場", + "field": "{{tesla}}、いつもオン", + "boreLabel": "トンネルの幅", + "bore": "直径{{cm}}" + }, + "magnet": { + "earth": "地球の磁場と比べると", + "earthValue": "{{min}}倍から{{max}}倍の強さ", + "helium": "液体ヘリウム", + "fringe": "0.5mTの安全ライン", + "fringeValue": "トンネルの方向に{{along}}m、横に{{side}}m" + }, + "spins": { + "larmor": "首振りの周波数", + "larmorBand": "{{value}}、{{band}}", + "band": { + "belowFm": "FMラジオのすぐ下", + "aboveFm": "FMラジオのすぐ上" + }, + "surplus": "磁場の向きに傾く、余分な原子核", + "surplusValue": "100万個あたり約{{perMillion}}個", + "count": "水1mm³の中の余分な原子核", + "countValue": "約{{mantissa}} × 10{{exponent}}個", + "slowdown": "絵をゆっくりにした倍率", + "slowdownValue": "約{{millions}}百万倍" + }, + "resonance": { + "tip": "傾き角度", + "across": "横向きの部分(信号)", + "along": "磁場に沿った部分", + "t1": "T1、戻る時間", + "t2": "T2、消える時間" + }, + "gradients": { + "edge": "頭の端での変化", + "share": "主磁場に対する割合", + "spread": "頭全体での周波数の幅", + "rise": "オンになるまでの時間", + "role": "この撮影での役目", + "roleValue": { + "x": "周波数:左右を見分ける", + "y": "位相:1ラインごとにねじる", + "z": "スライス:薄い1枚を選ぶ", + "sequence": "撮影の流れに合わせる" + } + }, + "picture": { + "kspace": "k空間、記録したエコー", + "image": "画像", + "scanTime": "1回の繰り返しで1ライン、256ライン", + "scanTimeValue": "{{lines}}ラインで{{minutes}}分", + "turbo": "ターボスピンエコー、1回の繰り返しで16ライン", + "turboValue": "約{{seconds}}秒", + "empty": "再生を押すと、最初のラインが埋まります" + } + }, + "parts": { + "room": "銅張りの部屋", + "cover": "カバー", + "vacuumVessel": "真空容器", + "radiationShield": "熱シールド", + "heliumVessel": "ヘリウム容器", + "mainCoils": "主磁石のコイル", + "shieldCoils": "シールドコイル", + "shims": "シム", + "coldHead": "コールドヘッド", + "quenchPipe": "クエンチ管", + "gradientX": "傾斜磁場コイルx", + "gradientY": "傾斜磁場コイルy", + "gradientZ": "傾斜磁場コイルz", + "bodyCoil": "ボディコイル", + "bore": "ボア", + "table": "寝台", + "patient": "患者", + "headCoil": "頭部コイル", + "sliceSlab": "スライス", + "spinArrows": "水素の原子核", + "netMagnet": "正味の磁石", + "mainField": "主磁場", + "rfWave": "電波パルス", + "echoWave": "エコー", + "fieldLinesGroup": "磁力線", + "fringeLine": "0.5mTのライン", + "screen": "画像" + }, + "sections": { + "overview": { + "title": "体の声に耳をすますトンネル", + "p1": "これは1.5テスラの磁石をもつMRI装置です。テスラは、磁場の強さを表す単位です。あなたが寝台に横になると、寝台はそのままボアへすべり込みます。ボアは幅約70cmのトンネルです。それを囲む装置は長さ約1.6mから1.9m、重さは3トンから6トンあります。", + "p2": "脳を撮るときは、頭部コイルがかごのように頭を囲みます。これはアンテナで、20個から64個の小さな受信器が、体から返ってくるかすかな電波のエコーを聞き取ります。部屋の壁には銅板が張ってあり、外の電波を締め出します。外の電波が入ると、エコーがかき消されてしまうからです。ここではX線は使いません。この周波数の電波がもつエネルギーは、原子から電子をはじき出すのに必要な量の約5000万分の1しかありません。", + "p3": "白いカバーの下には、いくつもの層が輪のように入れ子になっています。冷たい容器に包まれた磁石、3つの傾斜磁場コイル、そしてボディコイルと呼ばれる電波のアンテナです。断面表示(C)をオンにすると見えます。次の章から、ひとつずつ見ていきましょう。", + "p4": "モデルでは1回の繰り返しを追います。繰り返しとは、1枚の画像をつくるために、撮影中に何度も回す短いサイクルです。モデルの1回の繰り返しは、実際の時間で0.5秒から2.5秒です。ところが、その中の電波パルスはほんの数ミリ秒です。そこでタイムラインは時間を引き伸ばしています。パルスがループの大部分を占め、長い休みは縮めてあります。タイムラインにはステップの名前が、リアルタイムの計測値には本当の時間が出ます。何枚も画像を撮る頭の検査全体では、約30分から45分かかります。" + }, + "magnet": { + "title": "ずっとオンのままの磁石", + "p1": "いちばん大きな部品は主磁石です。ニオブチタンの線を約30kmから40km、コイルに巻いたものです。十分に冷やすと、この線は超伝導になります。電流がまったく抵抗なしに流れる状態です。数百アンペアの電流が、閉じたループの中をぐるぐる回り続けます。いったんループを閉じれば、磁場を保つのに電力はいりません。磁場の弱まりは、1年で0.1%にも届きません。", + "p2": "超伝導を保つため、コイルは4.2K、約−269°Cの液体ヘリウムに浸かっています。深宇宙より約1.5Kあたたかいだけです。昔ながらの磁石はこれを約1500リットル抱えていますが、新しい密閉型の磁石ならわずか7リットルですみます。ヘリウム容器を囲む熱シールドと真空が、魔法瓶のように熱をさえぎります。上に載った小さな冷凍機、コールドヘッドは、沸騰したヘリウムを液体に戻します。昼も夜も動き続け、約6kWから7kWの電力を使います。", + "p3": "磁場の強さは、地球の磁場の数万倍です。地球の磁場は、場所によって違います。3Tを選ぶと、もっと強い磁石と比べられます。外側にあるシールドコイルは逆向きに電流を流し、外へもれる磁場を近くに引き戻します。それでも、0.5mTのラインはトンネルの方向に約4m先まで届きます。このラインを越えて磁石に近づくと、ペースメーカーが安全とは限りません。磁力線をオンにすると見えます。", + "p4": "磁石は決してオフにならないので、入口にはそれを知らせる明かりのついた表示があります。鉄を含むものはトンネルへ飛び込むことがあり、酸素ボンベがこうして人の命を奪った事故もあります。だからスタッフは、入口で人も物もすべて確かめます。線のどこかが温まると、そこで超伝導が止まり、電流が熱に変わります。これがクエンチです。磁場は2秒から5秒でゼロになり、沸騰したヘリウムがクエンチ管を通って、ごうごうと建物の外へ噴き出します。" + }, + "spins": { + "title": "水素は傾いて、首を振る", + "p1": "体の原子のおよそ10個に6個は水素で、ほとんどは水と脂肪の中にあります。水素原子の中心、原子核は陽子1個だけです。これが小さな磁石のようにふるまいます。MRIが水素に耳をすますのは、水素がどこにでもあり、いちばん強い信号を出すからです。", + "p2": "磁場の中で、この小さな磁石は、ただ磁場の向きを指すわけではありません。歳差運動をします。傾いて回るこまのように、磁場の向きを軸にして首を振るのです。1.5Tでは1秒に約6400万回、63.9MHzで首を振ります。この速さがラーモア周波数です。磁場が強いほど高くなり、3Tでは127.7MHzになります。どちらもFMラジオに近く、一方はその帯域のすぐ下、もう一方はすぐ上です。", + "p3": "原子核がみんな同じ向きにそろうわけではありません。体温で揺さぶられ、ほとんどばらばらの向きを向いています。磁場と同じ向きに傾くものは、逆向きのものより100万個あたり約5個多いだけです。それでもこの小さな差は、水1立方ミリメートルの中で約3 × 10¹⁴個になります。これが集まって、小さな正味の磁石になります。装置が相手にするのは、この矢印です。", + "p4": "挿入図の矢印は、何倍にも拡大した絵です。頭の中の実際の一点ではありません。傾きも、見えるように本物よりずっと大きく描いています。絵の首振りは1秒に半回転で、1.5Tの本物より約1億2800万倍ゆっくりです。3Tに切り替えると、周波数も余分な原子核の数も2倍になるのがわかります。" + }, + "resonance": { + "title": "電波のパルスとそのエコー", + "p1": "正味の磁石に話をさせるため、装置はラーモア周波数ぴったりの短い電波パルスを送ります。このように首振りに合わせることを共鳴といいます。ブランコに乗った子どもを、ちょうどいいタイミングで押すのと同じです。パルスを出すのはボディコイルで、トンネルのすぐ内側にある、銅の棒を並べたかごです。パルスの長さは、長くても数ミリ秒です。", + "p2": "90°パルスは、正味の磁石を真横に寝るまで倒します。磁石はそこで首を振り続け、頭部コイルのそばを通るたびに、数マイクロボルトのごく小さな電圧を生みます。これが信号です。傾き角度のスライダーを動かしてみてください。信号を出すのは横向きの部分だけで、磁場に沿った部分は信号を出しません。", + "p3": "スピンはすぐに足並みが乱れます。ほかより少し速く首を振るものがあるからです。そのため信号は弱まっていきます。エコーまでの半分のところで、180°パルスがスピンをひっくり返します。すると速いものが後ろに回り、やがて追いつきます。スピンは再び足並みをそろえ、エコーを出します。そのタイミングがエコー時間です。チップを使うと、それぞれの瞬間で止められます。", + "p4": "それぞれの組織がどれくらい明るく見えるかは、2つの時計で決まります。T1は、正味の磁石が磁場に沿った向きに、元の約63%まで戻るのにかかる時間です。T2は、スピンの足並みが乱れて、横向きの信号が約37%まで弱まるのにかかる時間です。脂肪は早く戻り、脳脊髄液はゆっくり戻ります。組織を選んで比べてみましょう。パルスは体を少しあたためるので、装置はその発熱を安全な範囲に抑えています。" + }, + "gradients": { + "title": "場所を見分けるコイル", + "p1": "ここまでは、頭のどの場所も同じ周波数で答えるので、エコーがどこから来たのかわかりません。それを解決するのが、磁石の内側にあるもう3つのコイル、x、y、zの傾斜磁場コイルです。傾斜磁場とは、片側からもう片側へ、強さが一定の割合で変わっていく磁場です。それぞれのコイルは、中心より片側では主磁場を少し強め、反対側では少し弱めます。", + "p2": "変化はわずかです。1mあたり最大45mTの傾きでも、頭の端での磁場の変化は約5.4mTで、主磁場の3分の1パーセントほどです。それでも、場所ごとに違う周波数をもたせるには十分です。コイルは約0.2ミリ秒でオンになります。", + "p3": "コイルにはそれぞれ役目があります。zコイルは電波パルスと組んで、薄い1枚の断面だけを目覚めさせます。これがスライス選択です。yコイルは繰り返しのたびに、上から下へ違うねじれを与えます。これが位相エンコードです。xコイルはエコーを聞いているあいだに働くので、周波数で左右がわかります。これが周波数エンコードです。コイルを選んでその磁場を見るか、撮影の流れを追ってみましょう。", + "p4": "強い磁石の中で大きな電流を切り替えると、スピーカーのようにコイルががくんと揺れます。撮影中に聞こえる、あの大きなガンガンという音の正体です。100デシベルを超えることもあり、削岩機と同じか、それ以上の大きさです。だから撮影のときは、必ず耳栓かヘッドホンを渡されます。" + }, + "picture": { + "title": "エコーから画像へ", + "p1": "エコーは1つずつ、k空間の1ラインとして記録されます。k空間とは、エコーを場所ではなく、周波数とねじれで並べた格子です。1回の繰り返しで1ラインが埋まり、次の繰り返しではねじれを変えて別のラインを埋めます。モデルの画像は64 × 64なので、64回の繰り返しが必要です。実際の頭の撮影では、256ラインを使うことがよくあります。ここではラインを真ん中から外へ向かって埋めるので、早いうちから大まかな画像が見えてきます。", + "p2": "フーリエ変換という数学の手順が、この格子を画像に変えます。太いものから細いものまで、どんな縞模様をどう混ぜれば画像になるかを見つけるのです。k空間の真ん中には太い縞、つまり全体の明るさと暗さが入っています。端には細かな部分が入っています。8、16、32、64ラインを試して、画像がくっきりしていくのを見てみましょう。", + "p3": "どの組織が明るく写るかは、タイミングで決まります。T1強調の撮影は500ミリ秒ごとに繰り返し、早めの15ミリ秒で聞き取ります。ゆっくりの組織が戻りきる前に繰り返しが終わるので、脂肪は明るく、脳脊髄液は暗く写ります。T2強調の撮影は2500ミリ秒待ち、100ミリ秒で聞き取ります。今度はゆっくり消える液体が光り、灰白質が白質より明るくなります。このモデルでは、どの組織も水素の量は同じで、骨と空気は黒いままです。", + "p4": "1回の繰り返しで1ラインずつだと、256ラインにかかる時間は、T1の撮影で約2.1分、T2の撮影で約10.7分です。実際の装置はターボスピンエコーを使い、1回の繰り返しで4ラインから32ラインを読み取ります。16ラインなら、T2の撮影は約40秒です。このアイデアはまだ新しいものです。ポール・ラウターバーが最初のMR画像を発表したのは1973年で、彼とピーター・マンスフィールドは、その功績で2003年のノーベル賞を共に受けました。" + } + } +} From 26cdfa5017dc900f66c387774ac444bf9f092996 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:44:07 +0200 Subject: [PATCH 16/82] fix(mri-scanner): match the page default to the pacemaker wording --- explainers/mri-scanner/chapters.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html index 3eb63d37..bd5882b9 100644 --- a/explainers/mri-scanner/chapters.html +++ b/explainers/mri-scanner/chapters.html @@ -62,7 +62,7 @@

A magnet that never swit

The field is tens of thousands of times stronger than Earth's, which changes from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside - push current the other way and pull the stray field in close. Even so, the 0.5 mT line, beyond + push current the other way and pull the stray field in close. Even so, the 0.5 mT line, inside which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field lines to see it.

From 2440873aed7ec2e0e3d8142c6e1cbac0bac1b904 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:44:48 +0200 Subject: [PATCH 17/82] fix(mri-scanner): call the spins tiny magnets in the echo chapter --- explainers/mri-scanner/chapters.html | 14 +++++++------- explainers/mri-scanner/locales/en.json | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html index bd5882b9..fe968c40 100644 --- a/explainers/mri-scanner/chapters.html +++ b/explainers/mri-scanner/chapters.html @@ -193,17 +193,17 @@

A radio pulse and the field makes none.

- The spins soon drift out of step, because some wobble a little faster than others, and the - signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast - ones end up behind and catch up. They come back into step and give an echo at - the echo time. Use the chips to stop at each moment. + The tiny magnets soon drift out of step, because some wobble a little faster than others, and + the signal fades. Halfway to the echo, a 180° pulse flips them over, so the + fast ones end up behind and catch up. They come back into step and give an + echo at the echo time. Use the chips to stop at each moment.

Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the - sideways signal takes to fade to about 37 % as the spins fall out of step. Fat recovers fast and - brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the scanner - keeps that heating within safe limits. + sideways signal takes to fade to about 37 % as the tiny magnets fall out of step. Fat recovers + fast and brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the + scanner keeps that heating within safe limits.

diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index eddc32c9..dcd9cee9 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -229,8 +229,8 @@ "title": "A radio pulse and its echo", "p1": "To make the net magnet speak, the scanner sends a short radio pulse at exactly the Larmor frequency. Matching the wobble like this is called resonance, like pushing a child on a swing at just the right moment. The pulse comes from the body coil, a cage of copper rungs just inside the tunnel. It lasts a few milliseconds at most.", "p2": "A 90° pulse tips the net magnet over until it lies sideways. There it keeps wobbling, and as it sweeps past the head coil it makes a tiny voltage of a few microvolts. That is the signal. Move the tip angle slider: only the sideways part makes signal, the part along the field makes none.", - "p3": "The spins soon drift out of step, because some wobble a little faster than others, and the signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast ones end up behind and catch up. They come back into step and give an echo at the echo time. Use the chips to stop at each moment.", - "p4": "Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the sideways signal takes to fade to about 37 % as the spins fall out of step. Fat recovers fast and brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the scanner keeps that heating within safe limits." + "p3": "The tiny magnets soon drift out of step, because some wobble a little faster than others, and the signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast ones end up behind and catch up. They come back into step and give an echo at the echo time. Use the chips to stop at each moment.", + "p4": "Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the sideways signal takes to fade to about 37 % as the tiny magnets fall out of step. Fat recovers fast and brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the scanner keeps that heating within safe limits." }, "gradients": { "title": "Coils that tell places apart", From 16e2342e725668cb5a4822ffa251a5962c135964 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:44:48 +0200 Subject: [PATCH 18/82] feat(mri-scanner): add the Spanish copy --- explainers/mri-scanner/locales/es.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/es.json diff --git a/explainers/mri-scanner/locales/es.json b/explainers/mri-scanner/locales/es.json new file mode 100644 index 00000000..6e9f85fc --- /dev/null +++ b/explainers/mri-scanner/locales/es.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "Cómo funciona un escáner de resonancia magnética", + "eyebrow": "Imagen médica", + "tagline": "Un imán gigante, un pulso de radio y el hidrógeno de tu cuerpo: así capta un escáner un eco muy débil y lo convierte en una imagen.", + "description": "Una resonancia magnética en 3D: el imán que nunca se apaga, el hidrógeno que responde a la radio, las bobinas que golpean y los ecos que forman una imagen.", + "summary": "El imán superconductor, el hidrógeno que se inclina, el pulso de radio y su eco, las bobinas de gradiente y el espacio k.", + "socialAlt": "Un escáner de resonancia magnética abierto en corte, con las capas del imán encajadas alrededor del túnel, junto a la imagen de una sección de la cabeza y el título «Cómo funciona un escáner de resonancia magnética»" + }, + "stage": { + "label": "Modelo interactivo de un escáner de resonancia magnética", + "gauge": "Datos de la exploración en directo" + }, + "timeline": { + "label": "Tiempo dentro de una repetición", + "phases": "Saltar a un paso", + "speed": "Velocidad de reproducción", + "value": "{{phase}}", + "during": "Paso {{step}} de {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} repeticiones por minuto", + "phase": { + "excite": "Pulso de radio", + "encode": "Codificación", + "refocus": "Reenfoque", + "echo": "Eco", + "recover": "Recuperación" + }, + "jump": { + "excite": "Saltar al pulso de radio", + "encode": "Saltar a la codificación", + "refocus": "Saltar al pulso de reenfoque", + "echo": "Saltar al eco", + "recover": "Saltar a la recuperación" + } + }, + "controls": { + "label": "Controles del modelo", + "field": "Intensidad del campo", + "weighting": "Tipo de imagen", + "fieldOptions": { + "field15": "1,5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "Potenciada en T1", + "t2": "Potenciada en T2" + }, + "tissueOptions": { + "fat": "Grasa", + "whiteMatter": "Sustancia blanca", + "greyMatter": "Sustancia gris", + "fluid": "Líquido cerebral" + }, + "axisOptions": { + "x": "Bobina x", + "y": "Bobina y", + "z": "Bobina z", + "sequence": "Seguir la exploración" + }, + "momentOptions": { + "pulse90": "Pulso de 90°", + "pulse180": "Pulso de 180°", + "echoPeak": "Pico del eco", + "repetitionEnd": "Fin de la repetición" + }, + "linesOptions": { + "8": "8 líneas", + "16": "16 líneas", + "32": "32 líneas", + "64": "64 líneas" + }, + "groups": { + "field": "Intensidad del campo", + "weighting": "Tipo de imagen", + "tissue": "Tejido", + "axis": "Bobina de gradiente", + "moment": "Momento de la repetición", + "lines": "Líneas del espacio k" + }, + "views": { + "cutaway": { + "name": "Vista en corte", + "short": "Corte", + "hint": "Vista en corte (C)" + }, + "fieldLines": { + "name": "Líneas de campo", + "short": "Campo", + "hint": "Líneas de campo (F)" + }, + "labels": { + "name": "Etiquetas", + "short": "Etiquetas", + "hint": "Etiquetas (L)" + } + } + }, + "readouts": { + "larmor": "Frecuencia de giro", + "time": "Tiempo en la repetición", + "lines": "Líneas del espacio k" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} de {{total}} ms", + "linesOf": "{{value}} de {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Campo principal", + "field": "{{tesla}}, siempre encendido", + "boreLabel": "Ancho del túnel", + "bore": "{{cm}} de lado a lado" + }, + "magnet": { + "earth": "Frente al campo de la Tierra", + "earthValue": "De {{min}} a {{max}} veces más fuerte", + "helium": "Helio líquido", + "fringe": "Línea de seguridad de 0,5 mT", + "fringeValue": "{{along}} m a lo largo del túnel, {{side}} m hacia los lados" + }, + "spins": { + "larmor": "Frecuencia del bamboleo", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "justo por debajo de la radio FM", + "aboveFm": "justo por encima de la radio FM" + }, + "surplus": "Núcleos de más inclinados a favor del campo", + "surplusValue": "unos {{perMillion}} por millón", + "count": "Excedente en 1 mm³ de agua", + "countValue": "unos {{mantissa}} × 10{{exponent}}", + "slowdown": "Dibujo ralentizado", + "slowdownValue": "unos {{millions}} millones de veces" + }, + "resonance": { + "tip": "Ángulo de inclinación", + "across": "Parte transversal (señal)", + "along": "Parte a lo largo del campo", + "t1": "T1, recuperación", + "t2": "T2, desvanecimiento" + }, + "gradients": { + "edge": "Cambio en el borde de la cabeza", + "share": "Parte del campo principal", + "spread": "Abanico de frecuencias en la cabeza", + "rise": "Tiempo de encendido", + "role": "Tarea en esta exploración", + "roleValue": { + "x": "Frecuencia: de izquierda a derecha", + "y": "Fase: una torsión por línea", + "z": "Corte: elige una capa fina", + "sequence": "Sigue la secuencia" + } + }, + "picture": { + "kspace": "Espacio k, los ecos guardados", + "image": "La imagen", + "scanTime": "Una línea por repetición, 256 líneas", + "scanTimeValue": "{{minutes}} min para {{lines}} líneas", + "turbo": "Turbo espín eco, 16 líneas por repetición", + "turboValue": "unos {{seconds}} s", + "empty": "Pulsa reproducir para llenar la primera línea" + } + }, + "parts": { + "room": "Sala forrada de cobre", + "cover": "Carcasa", + "vacuumVessel": "Cámara de vacío", + "radiationShield": "Escudo térmico", + "heliumVessel": "Depósito de helio", + "mainCoils": "Bobinas del imán principal", + "shieldCoils": "Bobinas de blindaje", + "shims": "Ajuste del campo (shims)", + "coldHead": "Cabezal frío", + "quenchPipe": "Tubo de quench", + "gradientX": "Bobina de gradiente x", + "gradientY": "Bobina de gradiente y", + "gradientZ": "Bobina de gradiente z", + "bodyCoil": "Bobina de cuerpo", + "bore": "Túnel", + "table": "Camilla", + "patient": "Paciente", + "headCoil": "Bobina de cabeza", + "sliceSlab": "Corte elegido", + "spinArrows": "Núcleos de hidrógeno", + "netMagnet": "Imán neto", + "mainField": "Campo principal", + "rfWave": "Pulso de radio", + "echoWave": "Eco", + "fieldLinesGroup": "Líneas de campo", + "fringeLine": "Línea de 0,5 mT", + "screen": "Imagen" + }, + "sections": { + "overview": { + "title": "Un túnel que escucha tu cuerpo", + "p1": "Esto es un escáner de resonancia magnética con un imán de 1,5 teslas. El tesla mide la fuerza de un campo magnético. Te tumbas en la camilla, que te desliza dentro del túnel, de unos 70 cm de ancho. La máquina que lo rodea mide entre unos 1,6 y 1,9 m de largo y pesa de 3 a 6 toneladas.", + "p2": "Para explorar el cerebro, una bobina de cabeza te envuelve la cabeza como una jaula. Es una antena con entre 20 y 64 receptores pequeños que escuchan un eco de radio muy débil que sale de tu cuerpo. La sala está forrada de chapa de cobre para que no entren las radios de fuera, que podrían tapar ese eco. Aquí no hay rayos X. Una onda de radio de esta frecuencia lleva unos 50 millones de veces menos energía de la que hace falta para arrancarle un electrón a un átomo.", + "p3": "Bajo la carcasa blanca hay capas, una dentro de otra, como anillos: el imán en su funda fría, tres bobinas de gradiente y una antena de radio llamada bobina de cuerpo. Activa la vista en corte (C) para verlas. Los capítulos siguientes las explican una a una.", + "p4": "El modelo sigue una repetición, el ciclo corto que el escáner ejecuta una y otra vez para formar una imagen. En este modelo, una repetición dura de 0,5 a 2,5 s de tiempo real, pero los pulsos de radio que contiene duran solo milisegundos. Por eso la línea de tiempo está estirada: los pulsos ocupan casi todo el ciclo y el largo reposo se comprime. La línea de tiempo indica el paso, y los datos en directo muestran el tiempo real. Un examen completo de la cabeza, con muchas imágenes, dura unos 30 a 45 minutos." + }, + "magnet": { + "title": "Un imán que nunca se apaga", + "p1": "La pieza más grande es el imán principal: unos 30 a 40 km de hilo de niobio-titanio enrollado en bobinas. Si se enfría lo suficiente, este hilo se vuelve superconductor: la corriente circula por él sin ninguna resistencia. Varios cientos de amperios dan vueltas y más vueltas en un circuito cerrado. Una vez cerrado el circuito, mantener el campo no gasta energía. El campo pierde menos del 0,1 % al año.", + "p2": "Para seguir siendo superconductoras, las bobinas están sumergidas en helio líquido a 4,2 K, unos −269 °C. Solo está unos 1,5 K por encima del espacio profundo. Un imán clásico contiene unos 1500 litros de helio, mientras que los imanes sellados más nuevos solo necesitan 7. Un escudo térmico y un vacío alrededor del depósito de helio funcionan como un termo. Una pequeña nevera encima, el cabezal frío, vuelve a licuar el helio que se evapora; funciona día y noche y consume unos 6 o 7 kW.", + "p3": "El campo es decenas de miles de veces más fuerte que el de la Tierra, que varía de un sitio a otro. Elige 3 T para comparar con un imán más potente. Por fuera, unas bobinas de blindaje extra llevan corriente en sentido contrario y mantienen el campo disperso cerca del imán. Aun así, la línea de 0,5 mT, dentro de la cual un marcapasos puede dejar de ser seguro, llega a unos 4 m a lo largo del túnel. Activa las líneas de campo para verla.", + "p4": "Como el imán nunca se apaga, un cartel luminoso en la puerta lo avisa. Cualquier cosa con hierro puede salir volando hacia el túnel, y así han muerto personas golpeadas por bombonas de oxígeno. Por eso el personal revisa a todo el mundo y todo lo que entra por la puerta. Si el hilo se calienta en algún punto, deja de ser superconductor y la corriente se convierte en calor: es un quench. El campo cae a cero en un plazo de 2 a 5 segundos, y el helio, al hervir, sale rugiendo del edificio por el tubo de quench." + }, + "spins": { + "title": "El hidrógeno se inclina y se bambolea", + "p1": "Unos seis de cada diez átomos de tu cuerpo son de hidrógeno, sobre todo en el agua y la grasa. El centro de un átomo de hidrógeno, su núcleo, es un solo protón. Se comporta como un imán diminuto. La resonancia magnética se fija en el hidrógeno porque está en todas partes y da la señal más fuerte.", + "p2": "Dentro del campo, cada imán diminuto no se limita a apuntar hacia donde marca el campo. Describe una precesión: se bambolea alrededor de esa dirección, como una peonza inclinada que gira. A 1,5 T se bambolea unos 64 millones de veces por segundo: 63,9 MHz. Este ritmo es la frecuencia de Larmor, y crece con el campo, hasta 127,7 MHz a 3 T. Las dos están cerca de la radio FM: una justo por debajo de su banda y la otra justo por encima.", + "p3": "Los núcleos no se alinean todos. El calor del cuerpo los zarandea, así que apuntan casi en cualquier dirección. Por cada millón, solo unos 5 más se inclinan a favor del campo que en contra. Aun así, ese pequeño excedente suma unos 3 × 10¹⁴ núcleos en un milímetro cúbico de agua. Juntos forman un diminuto imán neto, la flecha con la que trabaja el escáner.", + "p4": "Las flechas del recuadro son un dibujo ampliado muchas veces, no un punto real de la cabeza. Además exageran mucho la inclinación, para que puedas verla. El bamboleo dibujado da media vuelta por segundo, unos 128 millones de veces más despacio que el real a 1,5 T. Cambia a 3 T y verás cómo se duplican la frecuencia y el excedente." + }, + "resonance": { + "title": "Un pulso de radio y su eco", + "p1": "Para hacer hablar al imán neto, el escáner envía un pulso de radio corto exactamente a la frecuencia de Larmor. Seguir así el ritmo del bamboleo se llama resonancia, como empujar a un niño en un columpio en el momento preciso. El pulso sale de la bobina de cuerpo, una jaula de barras de cobre justo por dentro del túnel. Dura unos pocos milisegundos como mucho.", + "p2": "Un pulso de 90° inclina el imán neto hasta dejarlo tumbado de lado. Ahí sigue bamboleándose, y al pasar por delante de la bobina de cabeza genera un voltaje diminuto, de unos pocos microvoltios. Esa es la señal. Mueve el control del ángulo de inclinación: solo la parte transversal da señal; la parte a lo largo del campo no da ninguna.", + "p3": "Los núcleos pronto pierden el compás, porque unos se bambolean un poco más rápido que otros, y la señal se apaga. A mitad de camino hacia el eco, un pulso de 180° les da la vuelta, así que los rápidos quedan detrás y alcanzan a los demás. Vuelven a ir al compás y dan un eco en el tiempo de eco. Usa los botones para parar en cada momento.", + "p4": "Dos relojes deciden lo brillante que se ve cada tejido. T1 es el tiempo que tarda el imán neto en recuperar alrededor del 63 % de su valor a lo largo del campo. T2 es el tiempo que tarda la señal transversal en bajar a alrededor del 37 %, a medida que los núcleos pierden el compás. La grasa se recupera rápido y el líquido cerebral, despacio; elige un tejido para comparar. Los pulsos también te calientan un poco, así que el escáner mantiene ese calor dentro de límites seguros." + }, + "gradients": { + "title": "Bobinas que distinguen lugares", + "p1": "Hasta ahora, cada punto de la cabeza responde con el mismo tono, así que el eco no puede decir de dónde viene. Tres bobinas más, dentro del imán, lo resuelven: las bobinas de gradiente x, y y z. Un gradiente es un campo que cambia poco a poco y de forma regular de un lado a otro. Cada bobina suma un poco al campo principal a un lado del centro y le resta un poco al otro lado.", + "p2": "El cambio es pequeño. Con hasta 45 mT por metro, el campo en el borde de la cabeza varía unos 5,4 mT, un tercio del 1 % del campo principal. Basta para que cada posición tenga su propia frecuencia. Las bobinas se encienden en unos 0,2 ms.", + "p3": "Cada bobina tiene su tarea. La bobina z trabaja con el pulso de radio para despertar solo una capa fina: es la selección de corte. La bobina y da a cada repetición una torsión distinta de arriba abajo: es la codificación de fase. La bobina x funciona mientras se escucha el eco, así que su tono distingue la izquierda de la derecha: es la codificación de frecuencia. Elige una bobina para ver su campo, o sigue la exploración.", + "p4": "Al conmutar corrientes grandes dentro de un imán potente, las bobinas dan sacudidas, como un altavoz. Ese es el golpeteo fuerte que oyes durante la exploración. Puede superar los 100 decibelios: tanto como un martillo neumático, o incluso más. Por eso siempre te dan tapones o auriculares." + }, + "picture": { + "title": "De los ecos a la imagen", + "p1": "Cada eco se guarda como una línea del espacio k, una cuadrícula que ordena los ecos por tono y torsión en lugar de por posición. Una repetición llena una línea; la siguiente cambia la torsión y llena otra. En este modelo la imagen es de 64 × 64, así que hacen falta 64 repeticiones; las exploraciones reales de la cabeza suelen usar 256 líneas. Aquí las líneas se llenan desde el centro hacia fuera, así que pronto aparece una imagen aproximada.", + "p2": "Un paso matemático llamado transformada de Fourier convierte esa cuadrícula en la imagen. Busca qué mezcla de franjas, anchas y finas, forma la imagen al sumarlas. El centro del espacio k guarda las franjas anchas: las luces y sombras generales. Los bordes guardan el detalle fino. Prueba con 8, 16, 32 o 64 líneas y verás cómo se enfoca la imagen.", + "p3": "Los tiempos deciden qué tejidos se ven brillantes. Una exploración potenciada en T1 se repite cada 500 ms y escucha pronto, a los 15 ms. La repetición termina antes de que los tejidos lentos se recuperen, así que la grasa sale brillante y el líquido cerebral, oscuro. Una exploración potenciada en T2 espera 2500 ms y escucha a los 100 ms: ahora brilla el líquido, cuya señal se apaga despacio, y la sustancia gris brilla más que la blanca. En este modelo todos los tejidos tienen la misma cantidad de hidrógeno, y el hueso y el aire se quedan negros.", + "p4": "Con una línea por repetición, 256 líneas tardan unos 2,1 minutos en la exploración T1 y unos 10,7 en la T2. Los escáneres reales usan turbo espín eco, que lee de 4 a 32 líneas en cada repetición; con 16 líneas, la exploración T2 tarda unos 40 segundos. La idea es joven. Paul Lauterbur publicó la primera imagen por resonancia magnética en 1973, y él y Peter Mansfield compartieron el Premio Nobel de 2003 por ello." + } + } +} From a8b5e58d78aed2463ba709b2b3895a2e1c6cd3b1 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:44:48 +0200 Subject: [PATCH 19/82] feat(mri-scanner): add the French copy --- explainers/mri-scanner/locales/fr.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/fr.json diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json new file mode 100644 index 00000000..9e4b7628 --- /dev/null +++ b/explainers/mri-scanner/locales/fr.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "Comment fonctionne un appareil d’IRM", + "eyebrow": "Imagerie médicale", + "tagline": "Un aimant géant, une impulsion radio et l’hydrogène de votre corps : comment l’appareil capte un faible écho et en fait une image.", + "description": "Un appareil d’IRM en 3D : l’aimant jamais éteint, l’hydrogène qui répond à une impulsion radio, les bobines qui cognent, et l’écho qui devient image.", + "summary": "L’aimant supraconducteur, l’hydrogène qui penche, l’impulsion radio et son écho, les bobines de gradient et l’espace k.", + "socialAlt": "Un appareil d’IRM en coupe, les couches de son aimant emboîtées autour du tunnel, à côté de l’image d’une coupe de tête et du titre « Comment fonctionne un appareil d’IRM »" + }, + "stage": { + "label": "Modèle interactif d’appareil d’IRM", + "gauge": "Mesures de l’examen en direct" + }, + "timeline": { + "label": "Temps dans une répétition", + "phases": "Aller à une étape", + "speed": "Vitesse de lecture", + "value": "{{phase}}", + "during": "Étape {{step}} sur {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} répétitions par minute", + "phase": { + "excite": "Impulsion radio", + "encode": "Codage", + "refocus": "Refocalisation", + "echo": "Écho", + "recover": "Récupération" + }, + "jump": { + "excite": "Aller à l’impulsion radio", + "encode": "Aller au codage", + "refocus": "Aller à l’impulsion de refocalisation", + "echo": "Aller à l’écho", + "recover": "Aller à la récupération" + } + }, + "controls": { + "label": "Commandes du modèle", + "field": "Intensité du champ", + "weighting": "Type d’image", + "fieldOptions": { + "field15": "1,5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "Pondérée en T1", + "t2": "Pondérée en T2" + }, + "tissueOptions": { + "fat": "Graisse", + "whiteMatter": "Substance blanche", + "greyMatter": "Substance grise", + "fluid": "Liquide cérébral" + }, + "axisOptions": { + "x": "Bobine x", + "y": "Bobine y", + "z": "Bobine z", + "sequence": "Suivre l’examen" + }, + "momentOptions": { + "pulse90": "Impulsion de 90°", + "pulse180": "Impulsion de 180°", + "echoPeak": "Pic de l’écho", + "repetitionEnd": "Fin de la répétition" + }, + "linesOptions": { + "8": "8 lignes", + "16": "16 lignes", + "32": "32 lignes", + "64": "64 lignes" + }, + "groups": { + "field": "Intensité du champ", + "weighting": "Type d’image", + "tissue": "Tissu", + "axis": "Bobine de gradient", + "moment": "Moment de la répétition", + "lines": "Lignes de l’espace k" + }, + "views": { + "cutaway": { + "name": "Vue en coupe", + "short": "Coupe", + "hint": "Vue en coupe (C)" + }, + "fieldLines": { + "name": "Lignes de champ", + "short": "Champ", + "hint": "Lignes de champ (F)" + }, + "labels": { + "name": "Étiquettes", + "short": "Étiquettes", + "hint": "Étiquettes (L)" + } + } + }, + "readouts": { + "larmor": "Fréquence des spins", + "time": "Temps dans la répétition", + "lines": "Lignes de l’espace k" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} sur {{total}} ms", + "linesOf": "{{value}} sur {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Champ principal", + "field": "{{tesla}}, toujours actif", + "boreLabel": "Largeur du tunnel", + "bore": "{{cm}} de diamètre" + }, + "magnet": { + "earth": "Comparé au champ terrestre", + "earthValue": "{{min}} à {{max}} fois plus fort", + "helium": "Hélium liquide", + "fringe": "Ligne de sécurité de 0,5 mT", + "fringeValue": "{{along}} m dans l’axe du tunnel, {{side}} m sur le côté" + }, + "spins": { + "larmor": "Fréquence de précession", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "juste sous la bande FM", + "aboveFm": "juste au-dessus de la bande FM" + }, + "surplus": "Noyaux en plus dans le sens du champ", + "surplusValue": "environ {{perMillion}} sur un million", + "count": "Excédent dans 1 mm³ d’eau", + "countValue": "environ {{mantissa}} × 10{{exponent}}", + "slowdown": "Dessin ralenti", + "slowdownValue": "environ {{millions}} millions de fois" + }, + "resonance": { + "tip": "Angle de bascule", + "across": "Part transversale (signal)", + "along": "Part dans l’axe du champ", + "t1": "T1, remontée", + "t2": "T2, déclin" + }, + "gradients": { + "edge": "Écart au bord de la tête", + "share": "Part du champ principal", + "spread": "Écart de fréquence sur la tête", + "rise": "Temps de montée", + "role": "Rôle dans cet examen", + "roleValue": { + "x": "Fréquence : de gauche à droite", + "y": "Phase : une torsion par ligne", + "z": "Coupe : choisit une tranche fine", + "sequence": "Suit la séquence" + } + }, + "picture": { + "kspace": "Espace k, les échos stockés", + "image": "L’image", + "scanTime": "Une ligne par répétition, 256 lignes", + "scanTimeValue": "{{minutes}} min pour {{lines}} lignes", + "turbo": "Turbo spin écho, 16 lignes par répétition", + "turboValue": "environ {{seconds}} s", + "empty": "Lancez la lecture pour remplir la première ligne" + } + }, + "parts": { + "room": "Salle tapissée de cuivre", + "cover": "Capot", + "vacuumVessel": "Enceinte à vide", + "radiationShield": "Écran thermique", + "heliumVessel": "Cuve d’hélium", + "mainCoils": "Bobines de l’aimant principal", + "shieldCoils": "Bobines de blindage", + "shims": "Correcteurs de champ", + "coldHead": "Tête froide", + "quenchPipe": "Tuyau de quench", + "gradientX": "Bobine de gradient x", + "gradientY": "Bobine de gradient y", + "gradientZ": "Bobine de gradient z", + "bodyCoil": "Antenne corps", + "bore": "Tunnel", + "table": "Table", + "patient": "Patient", + "headCoil": "Antenne tête", + "sliceSlab": "Coupe", + "spinArrows": "Noyaux d’hydrogène", + "netMagnet": "Aimant résultant", + "mainField": "Champ principal", + "rfWave": "Impulsion radio", + "echoWave": "Écho", + "fieldLinesGroup": "Lignes de champ", + "fringeLine": "Ligne de 0,5 mT", + "screen": "Image" + }, + "sections": { + "overview": { + "title": "Un tunnel qui écoute votre corps", + "p1": "Voici un appareil d’IRM doté d’un aimant de 1,5 tesla. Le tesla mesure la force d’un champ magnétique. Vous vous allongez sur la table, qui vous fait glisser dans le tunnel, large d’environ 70 cm. La machine autour mesure environ 1,6 à 1,9 m de long et pèse de 3 à 6 tonnes.", + "p2": "Pour un examen du cerveau, une antenne tête entoure votre tête comme une cage. Cette antenne compte de 20 à 64 petits récepteurs qui guettent un faible écho radio venu de votre corps. La salle est tapissée de feuilles de cuivre : les ondes radio du dehors restent dehors et ne couvrent pas cet écho. Ici, pas de rayons X. Une onde radio de cette fréquence transporte environ 50 millions de fois trop peu d’énergie pour arracher un électron à un atome.", + "p3": "Sous le capot blanc, des couches s’emboîtent comme des anneaux : l’aimant dans son enveloppe froide, trois bobines de gradient et une antenne radio, l’antenne corps. Activez la vue en coupe (C) pour les voir. Les chapitres suivants les présentent une à une.", + "p4": "Le modèle suit une seule répétition, le court cycle qu’un examen reprend encore et encore pour construire une image. Dans ce modèle, une répétition dure de 0,5 à 2,5 s en temps réel, mais les impulsions radio qu’elle contient ne se comptent qu’en millisecondes. La barre de lecture n’est donc pas à l’échelle : les impulsions occupent l’essentiel de la boucle et la longue pause est tassée. La barre indique l’étape, et les mesures en direct donnent le temps réel. Un examen complet de la tête, avec de nombreuses images, dure environ 30 à 45 minutes." + }, + "magnet": { + "title": "Un aimant qui ne s’éteint jamais", + "p1": "La plus grosse pièce est l’aimant principal : environ 30 à 40 km de fil de niobium-titane enroulé en bobines. Assez refroidi, ce fil devient supraconducteur : le courant y circule sans aucune résistance. Plusieurs centaines d’ampères tournent sans fin dans une boucle fermée. Une fois la boucle fermée, maintenir le champ ne consomme aucune énergie. Le champ faiblit de moins d’un dixième de pour cent par an.", + "p2": "Pour rester supraconductrices, les bobines baignent dans de l’hélium liquide à 4,2 K, soit environ −269 °C. Le vide spatial n’est qu’environ 1,5 K plus froid. Un aimant classique en contient environ 1 500 litres ; les aimants scellés plus récents se contentent de 7. Un écran thermique et un vide autour de la cuve d’hélium jouent le rôle d’une bouteille isotherme. Sur le dessus, un petit réfrigérateur, la tête froide, retransforme en liquide l’hélium évaporé. Il tourne jour et nuit et consomme environ 6 à 7 kW.", + "p3": "Le champ est des dizaines de milliers de fois plus fort que celui de la Terre, qui varie d’un endroit à l’autre. Choisissez 3 T pour comparer avec un aimant plus puissant. À l’extérieur, des bobines de blindage font circuler du courant en sens inverse et ramènent le champ de fuite près de l’appareil. Malgré cela, la ligne de 0,5 mT, à l’intérieur de laquelle un pacemaker risque de ne plus être en sécurité, s’étend à environ 4 m dans l’axe du tunnel. Activez les lignes de champ pour la voir.", + "p4": "Comme l’aimant ne s’éteint jamais, un panneau lumineux le rappelle à la porte. Tout objet qui contient du fer peut être aspiré dans le tunnel, et des bouteilles d’oxygène ont déjà tué des personnes de cette façon. C’est pourquoi le personnel contrôle chaque personne et chaque objet à l’entrée. Si le fil se réchauffe quelque part, il cesse d’être supraconducteur et le courant se change en chaleur : c’est le quench. Le champ tombe à zéro en 2 à 5 secondes, et l’hélium en ébullition s’échappe du bâtiment dans un grondement, par le tuyau de quench." + }, + "spins": { + "title": "L’hydrogène penche et tournoie", + "p1": "Dans votre corps, environ six atomes sur dix sont de l’hydrogène, surtout dans l’eau et la graisse. Le cœur d’un atome d’hydrogène, son noyau, est un simple proton. Il se comporte comme un minuscule aimant. L’IRM écoute l’hydrogène parce qu’il est partout et qu’il donne le signal le plus fort.", + "p2": "Dans le champ, chaque petit aimant ne se contente pas de s’aligner. Il fait un mouvement de précession : il tourne autour de la direction du champ, comme une toupie penchée. À 1,5 T, il fait environ 64 millions de tours par seconde, soit 63,9 MHz. Cette cadence s’appelle la fréquence de Larmor. Elle augmente avec le champ et atteint 127,7 MHz à 3 T. Les deux sont proches de la radio FM, l’une juste sous la bande, l’autre juste au-dessus.", + "p3": "Les noyaux ne s’alignent pas tous. La chaleur du corps les bouscule, si bien qu’ils pointent presque dans tous les sens. Ceux qui penchent dans le sens du champ sont à peine plus nombreux que ceux qui penchent à l’inverse : environ 5 de plus sur un million. Ce petit excédent représente quand même environ 3 × 10¹⁴ noyaux dans un millimètre cube d’eau. Ensemble, ils forment un minuscule aimant résultant : c’est la flèche avec laquelle travaille l’appareil.", + "p4": "Les flèches de l’encart sont un dessin, fortement agrandi, et non un vrai point de la tête. Elles montrent aussi une inclinaison bien plus marquée qu’en réalité, pour que vous puissiez le voir. La précession dessinée fait un demi-tour par seconde, environ 128 millions de fois plus lentement que la vraie, à 1,5 T. Passez à 3 T et regardez la fréquence et l’excédent doubler." + }, + "resonance": { + "title": "Une impulsion radio et son écho", + "p1": "Pour faire parler l’aimant résultant, l’appareil envoie une brève impulsion radio, exactement à la fréquence de Larmor. Se caler ainsi sur la rotation s’appelle la résonance, comme quand on pousse un enfant sur une balançoire au bon moment. L’impulsion vient de l’antenne corps, une cage de barreaux de cuivre placée juste à l’intérieur du tunnel. Elle dure au plus quelques millisecondes.", + "p2": "Une impulsion de 90° fait basculer l’aimant résultant jusqu’à le coucher sur le côté. Là, il continue de tourner et, en passant devant l’antenne tête, il crée une minuscule tension de quelques microvolts. C’est le signal. Déplacez le curseur de l’angle de bascule : seule la part transversale, couchée sur le côté, crée du signal, la part dans l’axe du champ n’en donne aucun.", + "p3": "Les petits aimants se décalent vite les uns des autres, car certains tournent un peu plus vite que d’autres, et le signal s’éteint. À mi-chemin de l’écho, une impulsion de 180° les retourne : les plus rapides se retrouvent derrière et rattrapent les autres. Ils se remettent en phase et produisent un écho au temps d’écho. Utilisez les boutons pour vous arrêter à chaque moment.", + "p4": "Deux horloges décident de la luminosité de chaque tissu. T1 est le temps que met l’aimant résultant pour regagner environ 63 % de sa valeur dans l’axe du champ. T2 est le temps que met le signal transversal pour tomber à environ 37 %, à mesure que les petits aimants se décalent. La graisse récupère vite, le liquide cérébral lentement : choisissez un tissu pour comparer. Les impulsions vous réchauffent aussi un peu, alors l’appareil maintient cet échauffement dans des limites sûres." + }, + "gradients": { + "title": "Des bobines pour situer chaque point", + "p1": "Jusqu’ici, chaque point de la tête répond à la même fréquence : l’écho ne peut donc pas dire d’où il vient. Trois autres bobines, à l’intérieur de l’aimant, règlent ce problème : les bobines de gradient x, y et z. Un gradient est un champ qui varie régulièrement d’un côté à l’autre. Chaque bobine renforce un peu le champ principal d’un côté du centre et l’affaiblit un peu de l’autre.", + "p2": "L’écart est faible. Le gradient monte jusqu’à 45 mT par mètre : au bord de la tête, le champ varie alors d’environ 5,4 mT, un tiers de pour cent du champ principal. Cela suffit à donner à chaque position sa propre fréquence. Les bobines s’enclenchent en 0,2 ms environ.", + "p3": "Chaque bobine a son rôle. La bobine z agit avec l’impulsion radio pour ne réveiller qu’une tranche fine : c’est la sélection de coupe. La bobine y donne à chaque répétition une torsion différente, de haut en bas : c’est le codage de phase. La bobine x fonctionne pendant l’écoute de l’écho, si bien que la fréquence de l’écho indique la gauche ou la droite : c’est le codage en fréquence. Choisissez une bobine pour voir son champ, ou suivez l’examen.", + "p4": "Allumer et couper de forts courants dans un aimant puissant fait sursauter les bobines, comme la membrane d’un haut-parleur. C’est le fort martèlement que vous entendez pendant l’examen. Il peut dépasser 100 décibels, à peu près autant qu’un marteau-piqueur, voire plus. C’est pourquoi on vous donne toujours des bouchons d’oreilles ou un casque." + }, + "picture": { + "title": "Des échos à l’image", + "p1": "Chaque écho est stocké comme une ligne de l’espace k, une grille qui range les échos par fréquence et par torsion, et non par position. Une répétition remplit une ligne ; la suivante change la torsion et en remplit une autre. Dans ce modèle, l’image fait 64 × 64, il faut donc 64 répétitions. Les vrais examens de la tête utilisent souvent 256 lignes. Ici, les lignes se remplissent du centre vers les bords, si bien qu’une image grossière apparaît tôt.", + "p2": "Une étape de calcul, la transformée de Fourier, change cette grille en image. Elle trouve quel mélange de rayures, larges et fines, reconstitue l’image. Le centre de l’espace k contient les rayures larges, les grandes zones de clair et d’ombre. Les bords contiennent les détails fins. Essayez 8, 16, 32 ou 64 lignes et regardez l’image devenir nette.", + "p3": "Le minutage décide des tissus qui apparaissent clairs. Une séquence pondérée en T1 se répète toutes les 500 ms et écoute tôt, à 15 ms. La répétition s’achève avant que les tissus lents aient récupéré : la graisse est claire et le liquide cérébral sombre. Une séquence pondérée en T2 attend 2 500 ms et écoute à 100 ms. Le liquide, qui s’estompe lentement, brille alors, et la substance grise ressort plus que la blanche. Dans ce modèle, chaque tissu contient la même quantité d’hydrogène, et l’os comme l’air restent noirs.", + "p4": "Avec une ligne par répétition, 256 lignes prennent environ 2,1 minutes pour la séquence T1 et 10,7 pour la séquence T2. Les vrais appareils utilisent le turbo spin écho, qui lit de 4 à 32 lignes à chaque répétition ; avec 16 lignes, la séquence T2 dure environ 40 secondes. L’idée est récente. Paul Lauterbur a publié la première image par résonance magnétique en 1973, et il a partagé avec Peter Mansfield le prix Nobel 2003 pour cette découverte." + } + } +} From dc369edf5ca8b11535d2324b2bce7215da93e38e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:44:48 +0200 Subject: [PATCH 20/82] feat(mri-scanner): add the German copy --- explainers/mri-scanner/locales/de.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/de.json diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json new file mode 100644 index 00000000..74453572 --- /dev/null +++ b/explainers/mri-scanner/locales/de.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "So funktioniert ein MRT-Gerät", + "eyebrow": "Medizinische Bildgebung", + "tagline": "Ein riesiger Magnet, ein Funkimpuls und der Wasserstoff in Ihrem Körper: So hört das Gerät ein leises Echo und macht daraus ein Bild.", + "description": "Ein MRT in 3D: der Magnet, der nie abschaltet, wie Wasserstoff auf einen Funkimpuls antwortet, die klopfenden Spulen und wie aus Echos ein Bild wird.", + "summary": "Der supraleitende Magnet, der sich neigende Wasserstoff, der Funkimpuls und sein Echo, die Gradientenspulen und der k-Raum.", + "socialAlt": "Ein aufgeschnittenes MRT-Gerät mit ineinanderliegenden Magnetschichten rund um den Tunnel, daneben ein Schnittbild eines Kopfes und der Titel „So funktioniert ein MRT-Gerät“" + }, + "stage": { + "label": "Interaktives Modell eines MRT-Geräts", + "gauge": "Live-Messwerte des Scans" + }, + "timeline": { + "label": "Zeit in einer Wiederholung", + "phases": "Zu einem Schritt springen", + "speed": "Wiedergabetempo", + "value": "{{phase}}", + "during": "Schritt {{step}} von {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} Wiederholungen pro Minute", + "phase": { + "excite": "Funkimpuls", + "encode": "Kodierung", + "refocus": "Refokussierung", + "echo": "Echo", + "recover": "Erholung" + }, + "jump": { + "excite": "Zum Funkimpuls springen", + "encode": "Zur Kodierung springen", + "refocus": "Zum Refokussierungsimpuls springen", + "echo": "Zum Echo springen", + "recover": "Zur Erholung springen" + } + }, + "controls": { + "label": "Steuerung des Modells", + "field": "Feldstärke", + "weighting": "Bildart", + "fieldOptions": { + "field15": "1,5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "T1-gewichtet", + "t2": "T2-gewichtet" + }, + "tissueOptions": { + "fat": "Fett", + "whiteMatter": "Weiße Substanz", + "greyMatter": "Graue Substanz", + "fluid": "Hirnwasser" + }, + "axisOptions": { + "x": "x-Spule", + "y": "y-Spule", + "z": "z-Spule", + "sequence": "Dem Scan folgen" + }, + "momentOptions": { + "pulse90": "90°-Impuls", + "pulse180": "180°-Impuls", + "echoPeak": "Echospitze", + "repetitionEnd": "Ende der Wiederholung" + }, + "linesOptions": { + "8": "8 Zeilen", + "16": "16 Zeilen", + "32": "32 Zeilen", + "64": "64 Zeilen" + }, + "groups": { + "field": "Feldstärke", + "weighting": "Bildart", + "tissue": "Gewebe", + "axis": "Gradientenspule", + "moment": "Zeitpunkt in der Wiederholung", + "lines": "Zeilen im k-Raum" + }, + "views": { + "cutaway": { + "name": "Schnittansicht", + "short": "Schnitt", + "hint": "Schnittansicht (C)" + }, + "fieldLines": { + "name": "Feldlinien", + "short": "Feld", + "hint": "Feldlinien (F)" + }, + "labels": { + "name": "Beschriftungen", + "short": "Namen", + "hint": "Beschriftungen (L)" + } + } + }, + "readouts": { + "larmor": "Spinfrequenz", + "time": "Zeit in der Wiederholung", + "lines": "Zeilen im k-Raum" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} von {{total}} ms", + "linesOf": "{{value}} von {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "minutes": "{{value}} min", + "seconds": "{{value}} s", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Hauptfeld", + "field": "{{tesla}}, immer an", + "boreLabel": "Breite der Röhre", + "bore": "{{cm}} breit" + }, + "magnet": { + "earth": "Verglichen mit dem Erdfeld", + "earthValue": "{{min}}- bis {{max}}-mal so stark", + "helium": "Flüssiges Helium", + "fringe": "Sicherheitslinie 0,5 mT", + "fringeValue": "{{along}} m entlang der Röhre, {{side}} m zur Seite" + }, + "spins": { + "larmor": "Taumelfrequenz", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "knapp unter dem UKW-Band", + "aboveFm": "knapp über dem UKW-Band" + }, + "surplus": "Zusätzliche Kerne, die sich mit dem Feld neigen", + "surplusValue": "etwa {{perMillion}} von einer Million", + "count": "Überschuss in 1 mm³ Wasser", + "countValue": "etwa {{mantissa}} × 10{{exponent}}", + "slowdown": "Zeichnung verlangsamt", + "slowdownValue": "etwa {{millions}} Millionen Mal" + }, + "resonance": { + "tip": "Kippwinkel", + "across": "Seitlicher Anteil (Signal)", + "along": "Anteil entlang des Feldes", + "t1": "T1, Wiederaufbau", + "t2": "T2, Abklingen" + }, + "gradients": { + "edge": "Änderung am Rand des Kopfes", + "share": "Anteil am Hauptfeld", + "spread": "Frequenzspanne über den Kopf", + "rise": "Einschaltzeit", + "role": "Aufgabe in diesem Scan", + "roleValue": { + "x": "Frequenz: von links nach rechts", + "y": "Phase: eine Verdrehung pro Zeile", + "z": "Schicht: wählt eine dünne Scheibe", + "sequence": "Folgt dem Ablauf" + } + }, + "picture": { + "kspace": "k-Raum, die gespeicherten Echos", + "image": "Das Bild", + "scanTime": "Eine Zeile pro Wiederholung, 256 Zeilen", + "scanTimeValue": "{{minutes}} min für {{lines}} Zeilen", + "turbo": "Turbo-Spin-Echo, 16 Zeilen pro Wiederholung", + "turboValue": "etwa {{seconds}} s", + "empty": "Drücken Sie auf Wiedergabe, um die erste Zeile zu füllen" + } + }, + "parts": { + "room": "Mit Kupfer ausgekleideter Raum", + "cover": "Verkleidung", + "vacuumVessel": "Vakuumbehälter", + "radiationShield": "Wärmeschild", + "heliumVessel": "Heliumbehälter", + "mainCoils": "Spulen des Hauptmagneten", + "shieldCoils": "Abschirmspulen", + "shims": "Shims", + "coldHead": "Kaltkopf", + "quenchPipe": "Quenchrohr", + "gradientX": "Gradientenspule x", + "gradientY": "Gradientenspule y", + "gradientZ": "Gradientenspule z", + "bodyCoil": "Körperspule", + "bore": "Röhre", + "table": "Liege", + "patient": "Patient", + "headCoil": "Kopfspule", + "sliceSlab": "Schicht", + "spinArrows": "Wasserstoffkerne", + "netMagnet": "Gesamtmagnet", + "mainField": "Hauptfeld", + "rfWave": "Funkimpuls", + "echoWave": "Echo", + "fieldLinesGroup": "Feldlinien", + "fringeLine": "0,5-mT-Linie", + "screen": "Bild" + }, + "sections": { + "overview": { + "title": "Eine Röhre, die Ihrem Körper zuhört", + "p1": "Das ist ein MRT-Gerät, auch Kernspintomograf genannt, mit einem Magneten von 1,5 Tesla. In Tesla misst man, wie stark ein Magnetfeld ist. Sie legen sich auf die Liege, und diese fährt Sie in die Röhre, einen Tunnel von etwa 70 cm Breite. Das Gerät drumherum ist etwa 1,6 bis 1,9 m lang und wiegt 3 bis 6 Tonnen.", + "p2": "Für eine Untersuchung des Gehirns sitzt eine Kopfspule wie ein Käfig um Ihren Kopf. Sie ist eine Antenne mit 20 bis 64 kleinen Empfängern, die auf ein schwaches Funkecho aus Ihrem Körper lauschen. Der Raum ist mit Kupferblech ausgekleidet. So bleiben Funksignale von außen draußen und können dieses Echo nicht übertönen. Röntgenstrahlen gibt es hier keine. Eine Funkwelle dieser Frequenz hat etwa 50 Millionen Mal zu wenig Energie, um ein Elektron aus einem Atom zu schlagen.", + "p3": "Unter der weißen Verkleidung stecken Schichten ineinander wie Ringe: der Magnet in seinem kalten Mantel, drei Gradientenspulen und eine Funkantenne, die Körperspule. Schalten Sie die Schnittansicht (C) ein, um sie zu sehen. Die nächsten Kapitel nehmen sie sich einzeln vor.", + "p4": "Das Modell begleitet eine Wiederholung: den kurzen Ablauf, den ein Scan immer wieder durchläuft, um ein Bild aufzubauen. In diesem Modell dauert eine Wiederholung in Wirklichkeit 0,5 bis 2,5 s, die Funkimpulse darin aber nur Millisekunden. Deshalb ist die Zeitleiste gedehnt: Die Impulse füllen den größten Teil der Schleife, die lange Pause ist gestaucht. Die Zeitleiste nennt den Schritt, die Live-Werte zeigen die echte Zeit. Eine ganze Untersuchung des Kopfes mit vielen Bildern dauert etwa 30 bis 45 Minuten." + }, + "magnet": { + "title": "Ein Magnet, der nie abschaltet", + "p1": "Das größte Bauteil ist der Hauptmagnet: etwa 30 bis 40 km Draht aus Niob-Titan, zu Spulen gewickelt. Kühlt man diesen Draht weit genug ab, wird er supraleitend. Das heißt: Strom fließt ganz ohne Widerstand hindurch. Mehrere hundert Ampere kreisen in einer geschlossenen Schleife. Ist die Schleife einmal geschlossen, braucht das Feld keine Stromzufuhr mehr. In einem Jahr wird es um weniger als ein Zehntel Prozent schwächer.", + "p2": "Damit die Spulen supraleitend bleiben, liegen sie in flüssigem Helium bei 4,2 K, etwa −269 °C. Das ist nur etwa 1,5 K wärmer als das tiefe Weltall. Ein klassischer Magnet fasst etwa 1.500 Liter davon, neuere versiegelte Magnete brauchen nur 7. Ein Wärmeschild und ein Vakuum um den Heliumbehälter wirken wie eine Thermoskanne. Eine kleine Kältemaschine obendrauf, der Kaltkopf, macht verdampftes Helium wieder flüssig. Er läuft Tag und Nacht und braucht etwa 6 bis 7 kW.", + "p3": "Das Feld ist Zehntausende Male so stark wie das Erdfeld, das von Ort zu Ort schwankt. Wählen Sie 3 T, um mit einem stärkeren Magneten zu vergleichen. Zusätzliche Abschirmspulen außen führen Strom in die Gegenrichtung und halten das Streufeld nah am Gerät. Trotzdem reicht die 0,5-mT-Linie etwa 4 m weit entlang der Röhre. Wer diese Linie überschreitet, ist mit einem Herzschrittmacher womöglich nicht mehr sicher. Schalten Sie die Feldlinien ein, um sie zu sehen.", + "p4": "Weil der Magnet nie abschaltet, warnt ein Leuchtschild an der Tür davor. Alles, was Eisen enthält, kann in die Röhre fliegen, und Sauerstoffflaschen haben auf diese Weise schon Menschen getötet. Deshalb kontrolliert das Personal an der Tür alle Menschen und alles, was hineinkommt. Erwärmt sich der Draht an einer Stelle, verliert er die Supraleitung, und der Strom wird zu Wärme: ein Quench. Das Feld fällt in 2 bis 5 Sekunden auf null, und das kochende Helium donnert durch das Quenchrohr aus dem Gebäude." + }, + "spins": { + "title": "Wasserstoff neigt sich und taumelt", + "p1": "Etwa sechs von zehn Atomen in Ihrem Körper sind Wasserstoff, vor allem in Wasser und Fett. Das Zentrum eines Wasserstoffatoms, sein Kern, ist ein einzelnes Proton. Es verhält sich wie ein winziger Magnet. Die MRT lauscht auf Wasserstoff, weil er überall vorkommt und das stärkste Signal liefert.", + "p2": "Im Feld zeigt jeder dieser winzigen Magnete nicht einfach in Feldrichtung. Er präzediert: Er taumelt um die Feldrichtung wie ein schräg stehender Kreisel. Bei 1,5 T taumelt er etwa 64 Millionen Mal pro Sekunde, also mit 63,9 MHz. Dieser Takt heißt Larmor-Frequenz, und er wächst mit dem Feld, auf 127,7 MHz bei 3 T. Beide liegen nah am UKW-Radio, die eine knapp unterhalb seines Bandes, die andere knapp oberhalb.", + "p3": "Die Kerne richten sich nicht alle aus. Die Körperwärme schubst sie herum, deshalb zeigen sie in fast alle Richtungen. Auf eine Million Kerne neigen sich nur etwa 5 mehr mit dem Feld als dagegen. Dieser kleine Überschuss ergibt in einem Kubikmillimeter Wasser trotzdem etwa 3 × 10¹⁴ Kerne. Zusammen bilden sie einen winzigen Gesamtmagneten, den Pfeil, mit dem das Gerät arbeitet.", + "p4": "Die Pfeile im kleinen Fenster sind eine Zeichnung, vielfach vergrößert, keine echte Stelle im Kopf. Sie zeigen die Neigung auch viel stärker, als sie ist, damit Sie sie erkennen. Das gezeichnete Taumeln schafft eine halbe Drehung pro Sekunde, etwa 128 Millionen Mal langsamer als das echte bei 1,5 T. Schalten Sie auf 3 T und sehen Sie, wie sich Frequenz und Überschuss verdoppeln." + }, + "resonance": { + "title": "Ein Funkimpuls und sein Echo", + "p1": "Um den Gesamtmagneten zum Sprechen zu bringen, sendet das Gerät einen kurzen Funkimpuls genau mit der Larmor-Frequenz. Diesen Gleichtakt mit dem Taumeln nennt man Resonanz, so als würden Sie ein Kind auf der Schaukel genau im richtigen Moment anschubsen. Der Impuls kommt aus der Körperspule, einem Käfig aus Kupferstäben direkt innerhalb der Röhre. Er dauert höchstens ein paar Millisekunden.", + "p2": "Ein 90°-Impuls kippt den Gesamtmagneten um, bis er seitlich liegt. Dort taumelt er weiter. Wenn er dabei an der Kopfspule vorbeistreicht, erzeugt er eine winzige Spannung von wenigen Mikrovolt. Das ist das Signal. Bewegen Sie den Regler für den Kippwinkel: Nur der seitliche Anteil liefert Signal, der Anteil entlang des Feldes keins.", + "p3": "Bald geraten die Spins aus dem Takt, weil manche etwas schneller taumeln als andere, und das Signal verblasst. Auf halbem Weg zum Echo klappt ein 180°-Impuls sie um. Die schnellen liegen dann hinten und holen auf. So kommen die Spins wieder in den Takt und liefern ein Echo, und zwar zur Echozeit. Mit den Schaltflächen halten Sie an jedem dieser Zeitpunkte an.", + "p4": "Zwei Uhren bestimmen, wie hell ein Gewebe erscheint. T1 ist die Zeit, bis der Gesamtmagnet entlang des Feldes wieder zu etwa 63 % aufgebaut ist. T2 ist die Zeit, bis das seitliche Signal auf etwa 37 % abgeklungen ist, weil die Spins aus dem Takt geraten. Fett erholt sich schnell, Hirnwasser langsam. Wählen Sie ein Gewebe zum Vergleich. Die Impulse wärmen Sie auch leicht auf, deshalb hält das Gerät diese Erwärmung in sicheren Grenzen." + }, + "gradients": { + "title": "Spulen, die Orte unterscheiden", + "p1": "Bisher antwortet jede Stelle im Kopf mit derselben Frequenz, also verrät das Echo nicht, woher es kommt. Drei weitere Spulen im Magneten ändern das: die Gradientenspulen x, y und z. Ein Gradient ist ein Feld, das sich von einer Seite zur anderen gleichmäßig ändert. Jede Spule verstärkt das Hauptfeld auf einer Seite der Mitte ein wenig und schwächt es auf der anderen Seite ein wenig ab.", + "p2": "Die Änderung ist klein. Bei bis zu 45 mT pro Meter verschiebt sich das Feld am Rand des Kopfes um etwa 5,4 mT, ein Drittel Prozent des Hauptfeldes. Das genügt, damit jede Position ihre eigene Frequenz bekommt. Die Spulen schalten sich in etwa 0,2 ms ein.", + "p3": "Jede Spule hat ihre eigene Aufgabe. Die z-Spule arbeitet mit dem Funkimpuls zusammen und weckt nur eine dünne Schicht: die Schichtauswahl. Die y-Spule gibt jeder Wiederholung eine andere Verdrehung von oben nach unten: die Phasenkodierung. Die x-Spule läuft, während das Gerät auf das Echo lauscht. So verrät die Frequenz, ob etwas links oder rechts liegt: die Frequenzkodierung. Wählen Sie eine Spule, um ihr Feld zu sehen, oder folgen Sie dem Scan.", + "p4": "Schaltet das Gerät große Ströme in einem starken Magneten, zucken die Spulen wie ein Lautsprecher. Das ist das laute Klopfen, das Sie während eines Scans hören. Es kann über 100 Dezibel erreichen, etwa so laut wie ein Presslufthammer oder noch lauter. Deshalb bekommen Sie immer Ohrstöpsel oder Kopfhörer." + }, + "picture": { + "title": "Von Echos zum Bild", + "p1": "Jedes Echo wird als eine Zeile im k-Raum gespeichert. Das ist ein Raster, das die Echos nach Frequenz und Verdrehung ordnet statt nach Ort. Eine Wiederholung füllt eine Zeile. Die nächste ändert die Verdrehung und füllt eine weitere. In diesem Modell hat das Bild 64 × 64 Pixel, es braucht also 64 Wiederholungen. Echte Aufnahmen des Kopfes nutzen oft 256 Zeilen. Hier füllen sich die Zeilen von der Mitte nach außen, darum erscheint früh ein grobes Bild.", + "p2": "Ein Rechenschritt namens Fourier-Transformation macht aus diesem Raster das Bild. Er findet heraus, welche Mischung aus breiten und feinen Streifen zusammen das Bild ergibt. Die Mitte des k-Raums enthält die breiten Streifen, also das grobe Hell und Dunkel. Die Ränder enthalten die feinen Details. Probieren Sie 8, 16, 32 oder 64 Zeilen und sehen Sie, wie das Bild schärfer wird.", + "p3": "Das Timing entscheidet, welche Gewebe hell erscheinen. Ein T1-gewichteter Scan wiederholt sich alle 500 ms und lauscht früh, nach 15 ms. Die Wiederholung endet, bevor sich langsame Gewebe erholt haben, deshalb ist Fett hell und Hirnwasser dunkel. Ein T2-gewichteter Scan wartet 2.500 ms und lauscht nach 100 ms: Jetzt leuchtet das langsam verblassende Hirnwasser, und graue Substanz ist heller als weiße. In diesem Modell enthält jedes Gewebe gleich viel Wasserstoff, und Knochen und Luft bleiben schwarz.", + "p4": "Mit einer Zeile pro Wiederholung dauern 256 Zeilen etwa 2,1 Minuten beim T1-Scan und 10,7 beim T2-Scan. Echte Geräte arbeiten mit Turbo-Spin-Echo und lesen in jeder Wiederholung 4 bis 32 Zeilen. Mit 16 Zeilen dauert der T2-Scan etwa 40 Sekunden. Die Idee ist jung. Paul Lauterbur veröffentlichte 1973 das erste MR-Bild, und 2003 teilte er sich dafür mit Peter Mansfield den Nobelpreis." + } + } +} From 8d6869705549ec69cf58833dae8d5860c2bae2e8 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 09:44:48 +0200 Subject: [PATCH 21/82] feat(mri-scanner): add the Ukrainian copy --- explainers/mri-scanner/locales/uk.json | 250 +++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 explainers/mri-scanner/locales/uk.json diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json new file mode 100644 index 00000000..33de0fd1 --- /dev/null +++ b/explainers/mri-scanner/locales/uk.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "Як працює апарат МРТ", + "eyebrow": "Медична візуалізація", + "tagline": "Величезний магніт, радіоімпульс і водень у вашому тілі: як апарат ловить слабке відлуння й перетворює його на зображення.", + "description": "Апарат МРТ у 3D: магніт, що ніколи не вимикається, як водень відповідає на радіоімпульс, чому котушки стукають і як відлуння стає зображенням.", + "summary": "Надпровідний магніт, нахилені ядра водню, радіоімпульс і його відлуння, градієнтні котушки та k-простір.", + "socialAlt": "Апарат МРТ у розрізі: шари магніта, вкладені один в одного довкола тунелю, поруч знімок зрізу голови й заголовок «Як працює апарат МРТ»" + }, + "stage": { + "label": "Інтерактивна модель апарата МРТ", + "gauge": "Поточні показники сканування" + }, + "timeline": { + "label": "Час в одному повторенні", + "phases": "Обрати крок", + "speed": "Швидкість відтворення", + "value": "{{phase}}", + "during": "Крок {{step}} з {{total}}, {{phase}}", + "speedValue": "{{value}}/хв", + "speedFormat": "Повторень за хвилину: {{value}}", + "phase": { + "excite": "Радіоімпульс", + "encode": "Кодування", + "refocus": "Рефокусування", + "echo": "Відлуння", + "recover": "Відновлення" + }, + "jump": { + "excite": "Перейти до радіоімпульсу", + "encode": "Перейти до кодування", + "refocus": "Перейти до рефокусувального імпульсу", + "echo": "Перейти до відлуння", + "recover": "Перейти до відновлення" + } + }, + "controls": { + "label": "Керування моделлю", + "field": "Сила поля", + "weighting": "Тип зображення", + "fieldOptions": { + "field15": "1,5 Тл", + "field30": "3 Тл" + }, + "weightingOptions": { + "t1": "T1-зважене", + "t2": "T2-зважене" + }, + "tissueOptions": { + "fat": "Жир", + "whiteMatter": "Біла речовина", + "greyMatter": "Сіра речовина", + "fluid": "Ліквор" + }, + "axisOptions": { + "x": "Котушка x", + "y": "Котушка y", + "z": "Котушка z", + "sequence": "Стежити за скануванням" + }, + "momentOptions": { + "pulse90": "Імпульс 90°", + "pulse180": "Імпульс 180°", + "echoPeak": "Пік відлуння", + "repetitionEnd": "Кінець повторення" + }, + "linesOptions": { + "8": "8 рядків", + "16": "16 рядків", + "32": "32 рядки", + "64": "64 рядки" + }, + "groups": { + "field": "Сила поля", + "weighting": "Тип зображення", + "tissue": "Тканина", + "axis": "Градієнтна котушка", + "moment": "Момент у повторенні", + "lines": "Рядки k-простору" + }, + "views": { + "cutaway": { + "name": "Розріз", + "short": "Розріз", + "hint": "Розріз (C)" + }, + "fieldLines": { + "name": "Силові лінії", + "short": "Поле", + "hint": "Силові лінії (F)" + }, + "labels": { + "name": "Підписи", + "short": "Підписи", + "hint": "Підписи (L)" + } + } + }, + "readouts": { + "larmor": "Частота спінів", + "time": "Час у повторенні", + "lines": "Рядки k-простору" + }, + "units": { + "mhz": "{{value}} МГц", + "msOf": "{{value}} з {{total}} мс", + "linesOf": "{{value}} з {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} мс", + "tesla": "{{value}} Тл", + "kelvin": "{{kelvin}} К, {{celsius}} °C", + "minutes": "{{value}} хв", + "seconds": "{{value}} с", + "kHz": "{{value}} кГц", + "mT": "{{value}} мТл", + "cm": "{{value}} см" + }, + "chapters": { + "overview": { + "fieldLabel": "Основне поле", + "field": "{{tesla}}, увімкнене завжди", + "boreLabel": "Ширина тунелю", + "bore": "{{cm}} завширшки" + }, + "magnet": { + "earth": "Порівняно з полем Землі", + "earthValue": "Від {{min}} до {{max}} разів сильніше", + "helium": "Рідкий гелій", + "fringe": "Межа безпеки 0,5 мТл", + "fringeValue": "{{along}} м уздовж тунелю, {{side}} м убік" + }, + "spins": { + "larmor": "Частота хитання", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "трохи нижче за FM-радіо", + "aboveFm": "трохи вище за FM-радіо" + }, + "surplus": "Зайві ядра, нахилені вздовж поля", + "surplusValue": "близько {{perMillion}} на мільйон", + "count": "Надлишок в 1 мм³ води", + "countValue": "близько {{mantissa}} × 10{{exponent}}", + "slowdown": "Малюнок сповільнено", + "slowdownValue": "приблизно в {{millions}} млн разів" + }, + "resonance": { + "tip": "Кут нахилу", + "across": "Поперечна частина (сигнал)", + "along": "Частина вздовж поля", + "t1": "T1, відновлення", + "t2": "T2, згасання" + }, + "gradients": { + "edge": "Зміна на краю голови", + "share": "Частка основного поля", + "spread": "Розкид частот по голові", + "rise": "Час увімкнення", + "role": "Роль у цьому скануванні", + "roleValue": { + "x": "Частота: зліва направо", + "y": "Фаза: одне закручування на рядок", + "z": "Зріз: вибирає один тонкий шар", + "sequence": "Стежить за послідовністю" + } + }, + "picture": { + "kspace": "k-простір, збережені відлуння", + "image": "Зображення", + "scanTime": "Один рядок за повторення, 256 рядків", + "scanTimeValue": "{{minutes}} хв на {{lines}} рядків", + "turbo": "Турбо спін-ехо, 16 рядків за повторення", + "turboValue": "близько {{seconds}} с", + "empty": "Натисніть відтворення, щоб заповнити перший рядок" + } + }, + "parts": { + "room": "Кімната, оббита міддю", + "cover": "Кожух", + "vacuumVessel": "Вакуумна оболонка", + "radiationShield": "Тепловий екран", + "heliumVessel": "Резервуар з гелієм", + "mainCoils": "Котушки основного магніта", + "shieldCoils": "Екранувальні котушки", + "shims": "Шими", + "coldHead": "Холодна головка", + "quenchPipe": "Труба квенчу", + "gradientX": "Градієнтна котушка x", + "gradientY": "Градієнтна котушка y", + "gradientZ": "Градієнтна котушка z", + "bodyCoil": "Котушка для тіла", + "bore": "Тунель", + "table": "Стіл", + "patient": "Пацієнт", + "headCoil": "Котушка для голови", + "sliceSlab": "Зріз", + "spinArrows": "Ядра водню", + "netMagnet": "Сумарний магніт", + "mainField": "Основне поле", + "rfWave": "Радіоімпульс", + "echoWave": "Відлуння", + "fieldLinesGroup": "Силові лінії", + "fringeLine": "Лінія 0,5 мТл", + "screen": "Зображення" + }, + "sections": { + "overview": { + "title": "Тунель, що слухає ваше тіло", + "p1": "Перед вами апарат МРТ, магнітно-резонансний томограф із магнітом на 1,5 тесла. Одиниця тесла показує, наскільки сильне магнітне поле. Ви лягаєте на стіл, і він засуває вас у тунель завширшки близько 70 см. Сам апарат довкола тунелю має приблизно від 1,6 до 1,9 м завдовжки й важить від 3 до 6 тонн.", + "p2": "Для знімка мозку на вас надягають котушку для голови, схожу на клітку. Це антена: від 20 до 64 маленьких приймачів ловлять слабке радіовідлуння з вашого тіла. Стіни кімнати оббиті мідними листами. Вони не пускають сторонні радіохвилі, щоб ті не заглушили це відлуння. Рентгенівського проміння тут немає. Радіохвиля такої частоти несе приблизно в 50 мільйонів разів менше енергії, ніж треба, щоб вибити електрон з атома.", + "p3": "Під білим кожухом шари вкладені один в одного, як кільця: магніт у своїй холодній оболонці, три градієнтні котушки і радіоантена, яку називають котушкою для тіла. Увімкніть розріз (C), щоб їх побачити. У наступних розділах ми розберемо їх по черзі.", + "p4": "Модель показує одне повторення: короткий цикл, який апарат проходить знову й знову, щоб зібрати одне зображення. У цій моделі повторення триває від 0,5 до 2,5 с реального часу, а радіоімпульси в ньому тривають лише мілісекунди. Тому шкалу часу розтягнуто: імпульси займають більшу частину циклу, а довгу паузу стиснуто. Шкала часу називає крок, а поточні показники відображають справжній час. Повне обстеження голови з багатьма знімками триває приблизно від 30 до 45 хвилин." + }, + "magnet": { + "title": "Магніт, що ніколи не вимикається", + "p1": "Найбільша частина апарата, основний магніт, складається з ніобій-титанового дроту завдовжки приблизно від 30 до 40 км, намотаного в котушки. Якщо цей дріт достатньо охолодити, він стає надпровідним: струм тече ним зовсім без опору. Кілька сотень амперів біжать по колу в замкненому контурі. Щойно контур замкнули, на утримання поля вже не треба енергії. За рік поле слабшає менш ніж на десяту частку відсотка.", + "p2": "Щоб котушки лишалися надпровідними, вони лежать у рідкому гелії за температури 4,2 К, тобто близько −269 °C. Це лише приблизно на 1,5 К тепліше, ніж у глибокому космосі. У класичному магніті близько 1 500 літрів гелію, а новим герметичним магнітам вистачає лише 7. Тепловий екран і вакуум довкола резервуара з гелієм працюють як термос. Маленький холодильник згори, холодна головка, знову перетворює випарений гелій на рідину. Вона працює вдень і вночі та споживає приблизно від 6 до 7 кВт.", + "p3": "Поле в десятки тисяч разів сильніше за земне, а земне в різних місцях різне. Оберіть 3 Тл, щоб порівняти з потужнішим магнітом. Додаткові екранувальні котушки зовні пускають струм у зворотний бік і підтягують розсіяне поле ближче до апарата. Та навіть так лінія 0,5 мТл, всередині якої з кардіостимулятором уже може бути небезпечно, сягає приблизно 4 м уздовж тунелю. Увімкніть силові лінії, щоб її побачити.", + "p4": "Магніт ніколи не вимикається, тому біля дверей про це попереджає табло, що світиться. Будь-яку річ із залізом може затягнути в тунель, і через кисневі балони так уже гинули люди. Ось чому персонал біля дверей перевіряє всіх і все. Якщо дріт десь нагріється, він перестає бути надпровідним, і струм перетворюється на тепло: це квенч. Поле падає до нуля за кілька секунд, від 2 до 5, а гелій, що википає, з ревом виривається з будівлі через трубу квенчу." + }, + "spins": { + "title": "Водень нахиляється й хитається", + "p1": "Водень становить приблизно шість із десяти атомів у вашому тілі, здебільшого у воді й жирі. Серцевина атома водню, його ядро, складається лише з одного протона. Воно поводиться як крихітний магніт. МРТ слухає саме водень, бо його всюди повно і він дає найсильніший сигнал.", + "p2": "Опинившись у полі, кожен такий крихітний магніт не просто вказує вздовж нього. Він прецесує: хитається довкола напрямку поля, як нахилена дзиґа. У полі 1,5 Тл він хитається приблизно 64 мільйони разів за секунду, тобто з частотою 63,9 МГц. Цю частоту називають частотою Лармора, і вона росте разом із полем: у полі 3 Тл вона сягає 127,7 МГц. Обидві близькі до FM-радіо: одна трохи нижча за його діапазон, друга трохи вища.", + "p3": "Ядра не вишиковуються всі в один бік. Тепло тіла їх розштовхує, тож вони дивляться майже куди завгодно. На кожен мільйон лише приблизно на 5 ядер більше нахиляються вздовж поля, ніж проти нього. Але навіть цей малий надлишок дає близько 3 × 10¹⁴ ядер в одному кубічному міліметрі води. Разом вони утворюють крихітний сумарний магніт, ту саму стрілку, з якою працює апарат.", + "p4": "Стрілки у вставці намальовано й збільшено в багато разів: це не справжня точка в голові. Нахил на них теж показано набагато сильнішим, ніж насправді, щоб його було видно. Намальоване хитання робить пів оберту за секунду. Це приблизно в 128 мільйонів разів повільніше, ніж справжнє у полі 1,5 Тл. Перемкніть на 3 Тл і подивіться, як частота й надлишок подвояться." + }, + "resonance": { + "title": "Радіоімпульс і його відлуння", + "p1": "Щоб сумарний магніт заговорив, апарат посилає короткий радіоімпульс точно на частоті Лармора. Таке влучання в такт хитанню називають резонансом. Так само ви розгойдуєте дитину на гойдалці, коли підштовхуєте її саме вчасно. Імпульс посилає котушка для тіла, клітка з мідних перекладин одразу за стінкою тунелю. Він триває щонайбільше кілька мілісекунд.", + "p2": "Імпульс 90° нахиляє сумарний магніт, доки той не ляже набік. Там він хитається далі й, пролітаючи повз котушку для голови, створює в ній крихітну напругу в кілька мікровольт. Це і є сигнал. Посуньте повзунок кута нахилу: сигнал дає лише поперечна частина, а частина вздовж поля не дає нічого.", + "p3": "Спіни, тобто крихітні магніти ядер, швидко збиваються з такту, бо деякі хитаються трохи швидше за інші, і сигнал згасає. На півдорозі до відлуння імпульс 180° перевертає їх, тож швидкі опиняються позаду й наздоганяють решту. Спіни знову входять у такт і дають відлуння в мить, яку називають часом відлуння. Натискайте кнопки, щоб зупинитися на кожному моменті.", + "p4": "Яскравість кожної тканини визначають два «годинники». T1 означає час, за який сумарний магніт відновлюється вздовж поля приблизно на 63 %. T2 означає час, за який поперечний сигнал згасає приблизно до 37 %, бо спіни збиваються з такту. Жир відновлюється швидко, а мозкова рідина, ліквор, повільно. Оберіть тканину, щоб порівняти. Імпульси ще й трохи вас нагрівають, тож апарат тримає це нагрівання в безпечних межах." + }, + "gradients": { + "title": "Котушки, що розрізняють місця", + "p1": "Поки що кожна точка голови відповідає на одній і тій самій частоті, тож з відлуння не зрозуміти, звідки воно прийшло. Це виправляють ще три котушки всередині магніта: градієнтні котушки x, y і z. Градієнт означає поле, що рівномірно змінюється від одного боку до іншого. Кожна котушка трохи додає до основного поля з одного боку від центру й трохи віднімає з іншого.", + "p2": "Зміна невелика. За градієнта до 45 мТл на метр поле на краю голови зсувається приблизно на 5,4 мТл, це третина відсотка основного поля. Цього досить, щоб кожне місце отримало власну частоту. Котушки вмикаються приблизно за 0,2 мс.", + "p3": "У кожної котушки своя робота. Котушка z разом із радіоімпульсом будить лише один тонкий зріз: це вибір зрізу. Котушка y у кожному повторенні по-іншому закручує спіни згори донизу: це фазове кодування. Котушка x працює, поки звучить відлуння, тож за частотою можна відрізнити лівий бік від правого: це частотне кодування. Оберіть котушку, щоб побачити її поле, або стежте за скануванням.", + "p4": "Коли в сильному магніті перемикаються великі струми, котушки смикаються, наче динамік. Звідси той гучний стукіт, який ви чуєте під час сканування. Він може перевищувати 100 децибел, це приблизно як відбійний молоток або й гучніше. Тому вам завжди дають беруші чи навушники." + }, + "picture": { + "title": "Як відлуння стає зображенням", + "p1": "Кожне відлуння зберігається як один рядок k-простору. Це сітка, що впорядковує відлуння не за місцем, а за частотою й закручуванням. Одне повторення заповнює один рядок, а наступне змінює закручування й заповнює інший. У цій моделі зображення має розмір 64 × 64, тож потрібно 64 повторення. Справжні знімки голови часто мають 256 рядків. Тут рядки заповнюються від середини до країв, тож грубе зображення з’являється рано.", + "p2": "Математичний крок під назвою перетворення Фур’є робить із цієї сітки зображення. Воно шукає, яка суміш смуг, широких і тонких, складається в картинку. Середина k-простору містить широкі смуги, тобто загальне світло й тінь. Краї містять дрібні деталі. Спробуйте 8, 16, 32 або 64 рядки й подивіться, як зображення стає чіткішим.", + "p3": "Які тканини будуть яскравими, вирішує розклад імпульсів. T1-зважене сканування повторюється кожні 500 мс і слухає рано, на 15 мс. Повторення закінчується, перш ніж повільні тканини відновляться, тож жир яскравий, а ліквор темний. T2-зважене сканування чекає 2 500 мс і слухає на 100 мс. Тепер сяє рідина, що повільно згасає, а сіра речовина світліша за білу. У цій моделі в кожній тканині однаково водню, а кістки й повітря лишаються чорними.", + "p4": "Якщо читати один рядок за повторення, 256 рядків займуть приблизно 2,1 хвилини для T1-сканування і 10,7 для T2. Справжні апарати використовують турбо спін-ехо: за кожне повторення вони зчитують від 4 до 32 рядків. Із 16 рядками T2-сканування триває близько 40 секунд. Ідея молода. Пол Лаутербур опублікував перше МР-зображення 1973 року, а 2003 року він разом із Пітером Менсфілдом отримав за це Нобелівську премію." + } + } +} From c027c115e00254e483260b45ac989c50ffa7e159 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:06:20 +0200 Subject: [PATCH 22/82] fix(mri-scanner): take the slowdown factor formatted for each language --- explainers/mri-scanner/locales/de.json | 2 +- explainers/mri-scanner/locales/en.json | 2 +- explainers/mri-scanner/locales/es.json | 2 +- explainers/mri-scanner/locales/fr.json | 2 +- explainers/mri-scanner/locales/ja.json | 2 +- explainers/mri-scanner/locales/pt.json | 2 +- explainers/mri-scanner/locales/uk.json | 2 +- explainers/mri-scanner/locales/zh.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json index 74453572..0538f628 100644 --- a/explainers/mri-scanner/locales/de.json +++ b/explainers/mri-scanner/locales/de.json @@ -142,7 +142,7 @@ "count": "Überschuss in 1 mm³ Wasser", "countValue": "etwa {{mantissa}} × 10{{exponent}}", "slowdown": "Zeichnung verlangsamt", - "slowdownValue": "etwa {{millions}} Millionen Mal" + "slowdownValue": "etwa {{factor}} Mal" }, "resonance": { "tip": "Kippwinkel", diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index dcd9cee9..9cad1f55 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -142,7 +142,7 @@ "count": "Surplus in 1 mm³ of water", "countValue": "about {{mantissa}} × 10{{exponent}}", "slowdown": "Drawing slowed down", - "slowdownValue": "about {{millions}} million times" + "slowdownValue": "about {{factor}} times" }, "resonance": { "tip": "Tip angle", diff --git a/explainers/mri-scanner/locales/es.json b/explainers/mri-scanner/locales/es.json index 6e9f85fc..8f96818d 100644 --- a/explainers/mri-scanner/locales/es.json +++ b/explainers/mri-scanner/locales/es.json @@ -142,7 +142,7 @@ "count": "Excedente en 1 mm³ de agua", "countValue": "unos {{mantissa}} × 10{{exponent}}", "slowdown": "Dibujo ralentizado", - "slowdownValue": "unos {{millions}} millones de veces" + "slowdownValue": "unos {{factor}} de veces" }, "resonance": { "tip": "Ángulo de inclinación", diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json index 9e4b7628..583d5c5a 100644 --- a/explainers/mri-scanner/locales/fr.json +++ b/explainers/mri-scanner/locales/fr.json @@ -142,7 +142,7 @@ "count": "Excédent dans 1 mm³ d’eau", "countValue": "environ {{mantissa}} × 10{{exponent}}", "slowdown": "Dessin ralenti", - "slowdownValue": "environ {{millions}} millions de fois" + "slowdownValue": "environ {{factor}} de fois" }, "resonance": { "tip": "Angle de bascule", diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json index 10aa5d55..14aaf50a 100644 --- a/explainers/mri-scanner/locales/ja.json +++ b/explainers/mri-scanner/locales/ja.json @@ -142,7 +142,7 @@ "count": "水1mm³の中の余分な原子核", "countValue": "約{{mantissa}} × 10{{exponent}}個", "slowdown": "絵をゆっくりにした倍率", - "slowdownValue": "約{{millions}}百万倍" + "slowdownValue": "約{{factor}}倍" }, "resonance": { "tip": "傾き角度", diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json index 46552236..df5ce359 100644 --- a/explainers/mri-scanner/locales/pt.json +++ b/explainers/mri-scanner/locales/pt.json @@ -142,7 +142,7 @@ "count": "Excedente em 1 mm³ de água", "countValue": "cerca de {{mantissa}} × 10{{exponent}}", "slowdown": "Desenho desacelerado", - "slowdownValue": "cerca de {{millions}} milhões de vezes" + "slowdownValue": "cerca de {{factor}} de vezes" }, "resonance": { "tip": "Ângulo de inclinação", diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json index 33de0fd1..9ac643ed 100644 --- a/explainers/mri-scanner/locales/uk.json +++ b/explainers/mri-scanner/locales/uk.json @@ -142,7 +142,7 @@ "count": "Надлишок в 1 мм³ води", "countValue": "близько {{mantissa}} × 10{{exponent}}", "slowdown": "Малюнок сповільнено", - "slowdownValue": "приблизно в {{millions}} млн разів" + "slowdownValue": "приблизно в {{factor}} разів" }, "resonance": { "tip": "Кут нахилу", diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json index 6b43464c..bd3a9b50 100644 --- a/explainers/mri-scanner/locales/zh.json +++ b/explainers/mri-scanner/locales/zh.json @@ -142,7 +142,7 @@ "count": "1 mm³水中多出的核", "countValue": "约{{mantissa}} × 10{{exponent}}个", "slowdown": "画面放慢了", - "slowdownValue": "约{{millions}}00万倍" + "slowdownValue": "约{{factor}}倍" }, "resonance": { "tip": "翻转角", From 68ee06c8477d7dd130a6ae278db32708e3d22db3 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:24:34 +0200 Subject: [PATCH 23/82] fix(mri-scanner): show the step count beside the scrubber's step name --- explainers/mri-scanner/locales/de.json | 2 +- explainers/mri-scanner/locales/en.json | 2 +- explainers/mri-scanner/locales/es.json | 2 +- explainers/mri-scanner/locales/fr.json | 2 +- explainers/mri-scanner/locales/ja.json | 2 +- explainers/mri-scanner/locales/pt.json | 2 +- explainers/mri-scanner/locales/uk.json | 2 +- explainers/mri-scanner/locales/zh.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json index 0538f628..12502a3f 100644 --- a/explainers/mri-scanner/locales/de.json +++ b/explainers/mri-scanner/locales/de.json @@ -15,7 +15,7 @@ "label": "Zeit in einer Wiederholung", "phases": "Zu einem Schritt springen", "speed": "Wiedergabetempo", - "value": "{{phase}}", + "value": "Schritt {{step}} von {{total}}", "during": "Schritt {{step}} von {{total}}, {{phase}}", "speedValue": "{{value}}/min", "speedFormat": "{{value}} Wiederholungen pro Minute", diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index 9cad1f55..3fdf0122 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -15,7 +15,7 @@ "label": "Time in one repetition", "phases": "Jump to a step", "speed": "Playback speed", - "value": "{{phase}}", + "value": "Step {{step}} of {{total}}", "during": "Step {{step}} of {{total}}, {{phase}}", "speedValue": "{{value}}/min", "speedFormat": "{{value}} repetitions per minute", diff --git a/explainers/mri-scanner/locales/es.json b/explainers/mri-scanner/locales/es.json index 8f96818d..710522d3 100644 --- a/explainers/mri-scanner/locales/es.json +++ b/explainers/mri-scanner/locales/es.json @@ -15,7 +15,7 @@ "label": "Tiempo dentro de una repetición", "phases": "Saltar a un paso", "speed": "Velocidad de reproducción", - "value": "{{phase}}", + "value": "Paso {{step}} de {{total}}", "during": "Paso {{step}} de {{total}}, {{phase}}", "speedValue": "{{value}}/min", "speedFormat": "{{value}} repeticiones por minuto", diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json index 583d5c5a..e15070fc 100644 --- a/explainers/mri-scanner/locales/fr.json +++ b/explainers/mri-scanner/locales/fr.json @@ -15,7 +15,7 @@ "label": "Temps dans une répétition", "phases": "Aller à une étape", "speed": "Vitesse de lecture", - "value": "{{phase}}", + "value": "Étape {{step}} sur {{total}}", "during": "Étape {{step}} sur {{total}}, {{phase}}", "speedValue": "{{value}}/min", "speedFormat": "{{value}} répétitions par minute", diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json index 14aaf50a..328d54c7 100644 --- a/explainers/mri-scanner/locales/ja.json +++ b/explainers/mri-scanner/locales/ja.json @@ -15,7 +15,7 @@ "label": "1回の繰り返しの中の時間", "phases": "ステップへ移動", "speed": "再生速度", - "value": "{{phase}}", + "value": "ステップ{{step}}/{{total}}", "during": "ステップ{{step}}/{{total}}:{{phase}}", "speedValue": "{{value}}回/分", "speedFormat": "1分あたり{{value}}回の繰り返し", diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json index df5ce359..fd4c8fed 100644 --- a/explainers/mri-scanner/locales/pt.json +++ b/explainers/mri-scanner/locales/pt.json @@ -15,7 +15,7 @@ "label": "Tempo dentro de uma repetição", "phases": "Ir para uma etapa", "speed": "Velocidade de reprodução", - "value": "{{phase}}", + "value": "Etapa {{step}} de {{total}}", "during": "Etapa {{step}} de {{total}}, {{phase}}", "speedValue": "{{value}}/min", "speedFormat": "{{value}} repetições por minuto", diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json index 9ac643ed..7f06a8f3 100644 --- a/explainers/mri-scanner/locales/uk.json +++ b/explainers/mri-scanner/locales/uk.json @@ -15,7 +15,7 @@ "label": "Час в одному повторенні", "phases": "Обрати крок", "speed": "Швидкість відтворення", - "value": "{{phase}}", + "value": "Крок {{step}} з {{total}}", "during": "Крок {{step}} з {{total}}, {{phase}}", "speedValue": "{{value}}/хв", "speedFormat": "Повторень за хвилину: {{value}}", diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json index bd3a9b50..e584383d 100644 --- a/explainers/mri-scanner/locales/zh.json +++ b/explainers/mri-scanner/locales/zh.json @@ -15,7 +15,7 @@ "label": "一个重复周期内的时间", "phases": "跳到某一步", "speed": "播放速度", - "value": "{{phase}}", + "value": "第{{step}}步,共{{total}}步", "during": "第{{step}}步,共{{total}}步:{{phase}}", "speedValue": "{{value}}次/分钟", "speedFormat": "每分钟重复{{value}}次", From e33ec20161a62babb9d1a082ba27939af5c671dc Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:25:12 +0200 Subject: [PATCH 24/82] fix(mri-scanner): count the step on the scrubber instead of repeating its name --- explainers/mri-scanner/src/timeline.dom.test.ts | 10 +++++----- explainers/mri-scanner/src/timeline.ts | 15 +++++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/explainers/mri-scanner/src/timeline.dom.test.ts b/explainers/mri-scanner/src/timeline.dom.test.ts index 1398ada6..a175f86e 100644 --- a/explainers/mri-scanner/src/timeline.dom.test.ts +++ b/explainers/mri-scanner/src/timeline.dom.test.ts @@ -62,11 +62,11 @@ describe('mri scanner timeline', () => { expect(CYCLE_UNITS / rate(PICTURE_SPEED)).toBe(2); }); - it('names the step on the scrubber, never a time or a percentage', () => { - expect(formatPhase(0)).toBe('Radio pulse'); - expect(formatPhase(PHASE_RANGES.echo[0])).toBe('Echo'); - expect(formatPhase(CYCLE_UNITS - 1)).toBe('Recover'); - PHASE_IDS.forEach((id) => expect(formatPhase(PHASE_RANGES[id][0])).not.toMatch(/\d|%/)); + it('counts the step on the scrubber, never a time or a percentage', () => { + expect(formatPhase(0)).toBe('Step 1 of 5'); + expect(formatPhase(PHASE_RANGES.echo[0])).toBe('Step 4 of 5'); + expect(formatPhase(CYCLE_UNITS - 1)).toBe('Step 5 of 5'); + PHASE_IDS.forEach((id) => expect(formatPhase(PHASE_RANGES[id][0])).not.toMatch(/%|ms/)); }); it('tells a screen reader which step of five is playing', () => { diff --git a/explainers/mri-scanner/src/timeline.ts b/explainers/mri-scanner/src/timeline.ts index cd04ea81..6618e3a4 100644 --- a/explainers/mri-scanner/src/timeline.ts +++ b/explainers/mri-scanner/src/timeline.ts @@ -31,16 +31,19 @@ function phaseName(phase: number): string { return t(phaseKey(phaseOf(phase))); } +function stepCount(phase: number): { step: string; total: string } { + return { + step: formatNumber(PHASE_IDS.indexOf(phaseOf(phase)) + 1), + total: formatNumber(PHASE_IDS.length), + }; +} + export function formatPhase(phase: number): string { - return t('timeline.value', { phase: phaseName(phase) }); + return t('timeline.value', stepCount(phase)); } export function describePhase(phase: number): string { - return t('timeline.during', { - step: formatNumber(PHASE_IDS.indexOf(phaseOf(phase)) + 1), - total: formatNumber(PHASE_IDS.length), - phase: phaseName(phase), - }); + return t('timeline.during', { ...stepCount(phase), phase: phaseName(phase) }); } export function formatSpeed(speed: number): string { From a76b588ede6dc8902180012264d41db3ce86d724 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:25:13 +0200 Subject: [PATCH 25/82] feat(mri-scanner): describe the link preview image --- explainers/mri-scanner/explainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/mri-scanner/explainer.json b/explainers/mri-scanner/explainer.json index 2794585f..f65e0733 100644 --- a/explainers/mri-scanner/explainer.json +++ b/explainers/mri-scanner/explainer.json @@ -7,6 +7,6 @@ "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"], "social": { "image": "social/og-image.png", - "alt": "A closed-bore MRI scanner in a shielded room, beside the title How an MRI scanner works" + "alt": "A cutaway MRI scanner with its nested magnet layers around the tunnel, beside a slice picture of a head and the title How an MRI scanner works" } } From 5abfd1770b2fd3f8bc9198c234cb8088810ea692 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:36:13 +0200 Subject: [PATCH 26/82] feat(mri-scanner): add text alternatives for the k-space and picture canvases --- explainers/mri-scanner/locales/de.json | 4 +++- explainers/mri-scanner/locales/en.json | 4 +++- explainers/mri-scanner/locales/es.json | 4 +++- explainers/mri-scanner/locales/fr.json | 4 +++- explainers/mri-scanner/locales/ja.json | 4 +++- explainers/mri-scanner/locales/pt.json | 4 +++- explainers/mri-scanner/locales/uk.json | 4 +++- explainers/mri-scanner/locales/zh.json | 4 +++- 8 files changed, 24 insertions(+), 8 deletions(-) diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json index 12502a3f..e923d624 100644 --- a/explainers/mri-scanner/locales/de.json +++ b/explainers/mri-scanner/locales/de.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{minutes}} min für {{lines}} Zeilen", "turbo": "Turbo-Spin-Echo, 16 Zeilen pro Wiederholung", "turboValue": "etwa {{seconds}} s", - "empty": "Drücken Sie auf Wiedergabe, um die erste Zeile zu füllen" + "empty": "Drücken Sie auf Wiedergabe, um die erste Zeile zu füllen", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index 3fdf0122..283c4e36 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{minutes}} min for {{lines}} lines", "turbo": "Turbo spin echo, 16 lines per repetition", "turboValue": "about {{seconds}} s", - "empty": "Press play to fill the first line" + "empty": "Press play to fill the first line", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/es.json b/explainers/mri-scanner/locales/es.json index 710522d3..d9e63f8b 100644 --- a/explainers/mri-scanner/locales/es.json +++ b/explainers/mri-scanner/locales/es.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{minutes}} min para {{lines}} líneas", "turbo": "Turbo espín eco, 16 líneas por repetición", "turboValue": "unos {{seconds}} s", - "empty": "Pulsa reproducir para llenar la primera línea" + "empty": "Pulsa reproducir para llenar la primera línea", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json index e15070fc..820f1d7a 100644 --- a/explainers/mri-scanner/locales/fr.json +++ b/explainers/mri-scanner/locales/fr.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{minutes}} min pour {{lines}} lignes", "turbo": "Turbo spin écho, 16 lignes par répétition", "turboValue": "environ {{seconds}} s", - "empty": "Lancez la lecture pour remplir la première ligne" + "empty": "Lancez la lecture pour remplir la première ligne", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json index 328d54c7..cc70e634 100644 --- a/explainers/mri-scanner/locales/ja.json +++ b/explainers/mri-scanner/locales/ja.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{lines}}ラインで{{minutes}}分", "turbo": "ターボスピンエコー、1回の繰り返しで16ライン", "turboValue": "約{{seconds}}秒", - "empty": "再生を押すと、最初のラインが埋まります" + "empty": "再生を押すと、最初のラインが埋まります", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json index fd4c8fed..74bf90f0 100644 --- a/explainers/mri-scanner/locales/pt.json +++ b/explainers/mri-scanner/locales/pt.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{minutes}} min para {{lines}} linhas", "turbo": "Turbo spin eco, 16 linhas por repetição", "turboValue": "cerca de {{seconds}} s", - "empty": "Aperte o play para preencher a primeira linha" + "empty": "Aperte o play para preencher a primeira linha", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json index 7f06a8f3..9d78cd44 100644 --- a/explainers/mri-scanner/locales/uk.json +++ b/explainers/mri-scanner/locales/uk.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{minutes}} хв на {{lines}} рядків", "turbo": "Турбо спін-ехо, 16 рядків за повторення", "turboValue": "близько {{seconds}} с", - "empty": "Натисніть відтворення, щоб заповнити перший рядок" + "empty": "Натисніть відтворення, щоб заповнити перший рядок", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json index e584383d..6862717e 100644 --- a/explainers/mri-scanner/locales/zh.json +++ b/explainers/mri-scanner/locales/zh.json @@ -171,7 +171,9 @@ "scanTimeValue": "{{lines}}行需{{minutes}}分钟", "turbo": "快速自旋回波,每个重复周期16行", "turboValue": "约{{seconds}}秒", - "empty": "按播放键,填入第一行" + "empty": "按播放键,填入第一行", + "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" } }, "parts": { From 04456c34657c975994c35e4e7e198b19f059475f Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:49 +0200 Subject: [PATCH 27/82] fix(mri-scanner): give the k-space and picture canvases a text alternative --- .../mri-scanner/src/ui/kspaceView.dom.test.ts | 21 +++++++++++++++++++ explainers/mri-scanner/src/ui/kspaceView.ts | 17 +++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts b/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts index 3c6d6d3f..1eddcab7 100644 --- a/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts +++ b/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts @@ -5,6 +5,7 @@ import { CanvasSurface } from '@core/ui/canvasSurface'; import type { CanvasFrame, Painter } from '@core/ui/canvasSurface'; import chapters from '../../chapters.html?raw'; import en from '../../locales/en.json'; +import { MODEL_SIZE } from '../model'; import { createMriScannerStore } from '../state'; import type { MriScannerStore } from '../state'; import { mountKspaceView } from './kspaceView'; @@ -71,6 +72,26 @@ describe('k-space and image canvases', () => { expect(drawn.fillRect.mock.calls.length).toBeGreaterThan(1); }); + function labelOf(name: string): string | null { + return document.querySelector(`[data-canvas="${name}"]`)?.getAttribute('aria-label') ?? null; + } + + it('describes both canvases as images with the empty text before the first line', () => { + document.querySelectorAll('canvas[data-canvas]').forEach((canvas) => { + expect(canvas.getAttribute('role')).toBe('img'); + }); + expect(labelOf('kspace')).toBe(en.chapters.picture.empty); + expect(labelOf('image')).toBe(en.chapters.picture.empty); + }); + + it('counts the filled lines in both canvas labels', () => { + store.getState().setLinesFilled(16); + const filled = (template: string) => + template.replace('{{lines}}', '16').replace('{{total}}', String(MODEL_SIZE)); + expect(labelOf('kspace')).toBe(filled(en.chapters.picture.kspaceAlt)); + expect(labelOf('image')).toBe(filled(en.chapters.picture.imageAlt)); + }); + it('redraws only when the field, the weighting or the lines change', () => { store.getState().setTipAngle(45); store.getState().setTissue('fat'); diff --git a/explainers/mri-scanner/src/ui/kspaceView.ts b/explainers/mri-scanner/src/ui/kspaceView.ts index 581ac2b9..5634bc77 100644 --- a/explainers/mri-scanner/src/ui/kspaceView.ts +++ b/explainers/mri-scanner/src/ui/kspaceView.ts @@ -56,17 +56,26 @@ export function paintEmpty(context: CanvasRenderingContext2D, frame: CanvasFrame context.fillText(t('chapters.picture.empty'), frame.width * HALF, frame.height * HALF, maxWidth); } -function squareSurface(root: ParentNode, name: string): CanvasSurface { +function squareCanvas(root: ParentNode, name: string): HTMLCanvasElement { const canvas = requireElement(root, `[data-canvas="${name}"]`); canvas.width = CANVAS_SIZE; canvas.height = CANVAS_SIZE; - return new CanvasSurface(canvas); + canvas.setAttribute('role', 'img'); + return canvas; +} + +function canvasLabel(key: string, lines: number): string { + return lines > 0 ? t(key, { lines, total: MODEL_SIZE }) : t('chapters.picture.empty'); } export function mountKspaceView(root: ParentNode, store: MriScannerStore): Disposer { - const kspace = squareSurface(root, 'kspace'); - const image = squareSurface(root, 'image'); + const kspaceCanvas = squareCanvas(root, 'kspace'); + const imageCanvas = squareCanvas(root, 'image'); + const kspace = new CanvasSurface(kspaceCanvas); + const image = new CanvasSurface(imageCanvas); const stopWatching = watchShallowLocalized(store, selectInputs, ([field, weighting, lines]) => { + kspaceCanvas.setAttribute('aria-label', canvasLabel('chapters.picture.kspaceAlt', lines)); + imageCanvas.setAttribute('aria-label', canvasLabel('chapters.picture.imageAlt', lines)); kspace.paint((context, frame) => paintGrid(context, frame, kspaceDisplay(field, weighting, lines)), ); From dd33535acc0182711b57205b2a61d89f7c3633bb Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:43:42 +0200 Subject: [PATCH 28/82] fix(mri-scanner): turn the along-field part over with the refocusing pulse --- .../mri-scanner/src/model/spins.test.ts | 50 ++++++++++++++++--- explainers/mri-scanner/src/model/spins.ts | 38 +++++++++++--- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/explainers/mri-scanner/src/model/spins.test.ts b/explainers/mri-scanner/src/model/spins.test.ts index 639a861a..938fbc8e 100644 --- a/explainers/mri-scanner/src/model/spins.test.ts +++ b/explainers/mri-scanner/src/model/spins.test.ts @@ -1,11 +1,23 @@ import { describe, expect, it } from 'vitest'; +import { FIELD_IDS, TISSUE_IDS, WEIGHTING_IDS } from '../ids'; import type { Point } from '../ids'; import { CYCLE_UNITS, MOMENTS, PHASE_RANGES } from './sequence'; import { tissueSignal } from './signal'; import { echoAmplitude, magnetisation, SPIN_COUNT, spinArrows } from './spins'; const RIGHT_ANGLE = 90; +const SMALL_TIP = 30; +const HALF_TURN = 180; const LOOSE = 3; +const SPIN_ECHO_SHIFT = 0.013; + +function alongOf(point: Point): number { + return -point[2]; +} + +function expectNearSignal(actual: number, expected: number): void { + expect(Math.abs(actual - expected)).toBeLessThan(SPIN_ECHO_SHIFT); +} function length([x, y, z]: Point): number { return Math.hypot(x, y, z); @@ -39,7 +51,7 @@ describe('net magnetisation', () => { const fanned = magnetisation(MOMENTS.pulse180, 't2', 'greyMatter', 'field30', RIGHT_ANGLE); const echo = magnetisation(MOMENTS.echoPeak, 't2', 'greyMatter', 'field30', RIGHT_ANGLE); expect(fanned[0]).toBeCloseTo(0, 6); - expect(echo[0]).toBeCloseTo(tissueSignal('greyMatter', 'field30', 't2'), 6); + expectNearSignal(echo[0], tissueSignal('greyMatter', 'field30', 't2')); }); it('regrows along the field during recovery', () => { @@ -54,11 +66,26 @@ describe('net magnetisation', () => { expect(late[2]).toBeLessThan(early[2]); }); - it.each([30, RIGHT_ANGLE, 150])('closes the loop smoothly for a %d degree pulse', (tip) => { - const start = magnetisation(0, 't1', 'greyMatter', 'field15', tip); - const end = magnetisation(CYCLE_UNITS, 't1', 'greyMatter', 'field15', tip); - expectPointClose(start, end, 6); + it('turns the along-field part over with the refocusing pulse at a small tip', () => { + const before = magnetisation(PHASE_RANGES.refocus[0], 't1', 'fat', 'field15', SMALL_TIP); + const after = magnetisation(PHASE_RANGES.refocus[1], 't1', 'fat', 'field15', SMALL_TIP); + expect(alongOf(before)).toBeGreaterThan(0); + expect(alongOf(after)).toBeLessThan(0); + }); + + it('points back up the field after a 180 degree tip and its refocusing pulse', () => { + const after = magnetisation(PHASE_RANGES.refocus[1], 't2', 'fat', 'field15', HALF_TURN); + expect(alongOf(after)).toBeGreaterThan(0); }); + + it.each([SMALL_TIP, RIGHT_ANGLE, 150, HALF_TURN])( + 'closes the loop smoothly for a %d degree pulse', + (tip) => { + const start = magnetisation(0, 't1', 'greyMatter', 'field15', tip); + const end = magnetisation(CYCLE_UNITS, 't1', 'greyMatter', 'field15', tip); + expectPointClose(start, end, 6); + }, + ); }); describe('spin arrows', () => { @@ -91,7 +118,7 @@ describe('spin arrows', () => { describe('echo amplitude', () => { it('peaks at the echo with the tissue signal and is silent outside the echo', () => { const peak = echoAmplitude(MOMENTS.echoPeak, 't1', 'fat', 'field15', RIGHT_ANGLE); - expect(peak).toBeCloseTo(tissueSignal('fat', 'field15', 't1'), 6); + expectNearSignal(peak, tissueSignal('fat', 'field15', 't1')); expect(echoAmplitude(MOMENTS.echoPeak - 40, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeLessThan( peak, ); @@ -105,6 +132,17 @@ describe('echo amplitude', () => { expect(echoAmplitude(MOMENTS.pulse90, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBe(0); }); + it.each(TISSUE_IDS)('keeps the 90 degree echo of %s at the tissue signal', (tissue) => { + FIELD_IDS.forEach((field) => + WEIGHTING_IDS.forEach((weighting) => + expectNearSignal( + echoAmplitude(MOMENTS.echoPeak, weighting, tissue, field, RIGHT_ANGLE), + tissueSignal(tissue, field, weighting), + ), + ), + ); + }); + it('shrinks with a smaller tip', () => { const full = echoAmplitude(MOMENTS.echoPeak, 't2', 'fluid', 'field30', RIGHT_ANGLE); const small = echoAmplitude(MOMENTS.echoPeak, 't2', 'fluid', 'field30', 30); diff --git a/explainers/mri-scanner/src/model/spins.ts b/explainers/mri-scanner/src/model/spins.ts index 938f2cbe..d654acad 100644 --- a/explainers/mri-scanner/src/model/spins.ts +++ b/explainers/mri-scanner/src/model/spins.ts @@ -1,4 +1,4 @@ -import { clamp, smoothstep, toRadians } from '@core/math'; +import { clamp, lerp, smoothstep, toRadians } from '@core/math'; import type { FieldId, Point, TissueId, WeightingId } from '../ids'; import { MOMENTS, PHASE_RANGES, realMs, WEIGHTINGS } from './sequence'; import { RELAXATION } from './tissues'; @@ -50,9 +50,31 @@ const OFFSETS = Array.from( ); const JITTERS = OFFSETS.map(() => randomUnit(random)); -function steadyAlong(t1: number, tr: number, tipCos: number): number { - const decay = Math.exp(-tr / t1); - return (1 - decay) / (1 - decay * tipCos); +function steadyAlong(t1: number, weighting: WeightingId, tipCos: number): number { + const { tr, te } = WEIGHTINGS[weighting]; + const afterRefocus = Math.exp(-(tr - te / 2) / t1); + const full = Math.exp(-tr / t1); + return (1 - 2 * afterRefocus + full) / (1 + tipCos * full); +} + +function regrow(start: number, ms: number, t1: number): number { + return 1 - (1 - start) * Math.exp(-ms / t1); +} + +interface AlongInput { + t1: number; + tipped: number; + elapsed: number; + refocusMs: number; + flip: number; +} + +function alongField(resting: number, tip: number, along: AlongInput): number { + const { t1, tipped, elapsed, refocusMs, flip } = along; + const beforeRefocus = regrow(resting * Math.cos(tipped), elapsed, t1); + const atRefocus = regrow(resting * Math.cos(tip), refocusMs, t1); + const afterRefocus = regrow(-atRefocus, elapsed - refocusMs, t1); + return lerp(beforeRefocus, afterRefocus, (1 - Math.cos(flip)) / 2); } function dephasing(phase: number): number { @@ -68,15 +90,17 @@ function dephasing(phase: number): number { function spinState(phase: number, input: SpinInput): SpinState { const { t1, t2 } = RELAXATION[input.field][input.tissue]; const tip = toRadians(input.tipDeg); - const resting = steadyAlong(t1, WEIGHTINGS[input.weighting].tr, Math.cos(tip)); + const resting = steadyAlong(t1, input.weighting, Math.cos(tip)); const tipped = tip * smoothstep(phase, ...PHASE_RANGES.excite); const elapsed = realMs(phase, input.weighting); const fade = 1 - smoothstep(phase, FADE_START_UNITS, MOMENTS.repetitionEnd); + const flip = Math.PI * smoothstep(phase, ...PHASE_RANGES.refocus); + const refocusMs = realMs(MOMENTS.pulse180, input.weighting); return { - along: 1 - (1 - resting * Math.cos(tipped)) * Math.exp(-elapsed / t1), + along: alongField(resting, tip, { t1, tipped, elapsed, refocusMs, flip }), across: resting * Math.sin(tipped) * Math.exp(-elapsed / t2) * fade, spread: Math.PI * dephasing(phase), - flip: Math.PI * smoothstep(phase, ...PHASE_RANGES.refocus), + flip, }; } From c8c043cebf1d4d756f3336ee7287aedfb5029bb9 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:44:16 +0200 Subject: [PATCH 29/82] fix(mri-scanner): pause playback when the reader picks a line count --- explainers/mri-scanner/src/state/store.test.ts | 6 ++++++ explainers/mri-scanner/src/state/store.ts | 5 ++++- explainers/mri-scanner/src/ui/actions.test.ts | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/explainers/mri-scanner/src/state/store.test.ts b/explainers/mri-scanner/src/state/store.test.ts index 7ba4d74d..50a4c5d9 100644 --- a/explainers/mri-scanner/src/state/store.test.ts +++ b/explainers/mri-scanner/src/state/store.test.ts @@ -125,6 +125,12 @@ describe('mri scanner store', () => { expect(store.getState()).toMatchObject({ phase: MOMENTS.pulse180, playing: false }); }); + it('pauses on a chosen line count so the fill stays on screen', () => { + const store = createMriScannerStore({ playing: true }); + store.getState().setLinesFilled(32); + expect(store.getState()).toMatchObject({ linesFilled: 32, playing: false }); + }); + it('starts a new scan on a field or weighting change', () => { const store = storeOn('magnet'); store.getState().setLinesFilled(40); diff --git a/explainers/mri-scanner/src/state/store.ts b/explainers/mri-scanner/src/state/store.ts index 7b2f9217..82390f89 100644 --- a/explainers/mri-scanner/src/state/store.ts +++ b/explainers/mri-scanner/src/state/store.ts @@ -123,7 +123,10 @@ export function createMriScannerStore( setWeighting: (weighting) => { if (weighting !== get().weighting) set({ weighting, ...newScan() }); }, - setLinesFilled: (lines) => set({ linesFilled: snapToRange(lines, LINES_RANGE) }), + setLinesFilled: (lines) => { + get().pause(); + set({ linesFilled: snapToRange(lines, LINES_RANGE) }); + }, addLine: () => set({ linesFilled: nextLineCount(get().linesFilled) }), setTipAngle: (degrees) => set({ tipAngle: snapToRange(degrees, TIP_ANGLE_RANGE) }), setTissue: (tissue) => set({ tissue }), diff --git a/explainers/mri-scanner/src/ui/actions.test.ts b/explainers/mri-scanner/src/ui/actions.test.ts index 58458407..ac35b8ef 100644 --- a/explainers/mri-scanner/src/ui/actions.test.ts +++ b/explainers/mri-scanner/src/ui/actions.test.ts @@ -50,6 +50,7 @@ describe('chapter actions', () => { it('fills a chosen number of lines and marks only matching counts', () => { const state = run('lines', '32'); expect(state.linesFilled).toBe(32); + expect(state.playing).toBe(false); expect(current('lines', state)).toBe('32'); expect(current('lines', createMriScannerStore({ linesFilled: 5 }).getState())).toBe(''); }); From 4a9895c3da5cae4784b73e322e2d2f15aaf39e6e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:45:28 +0200 Subject: [PATCH 30/82] fix(mri-scanner): follow the speed slider in the precession slowdown --- explainers/mri-scanner/src/state/derived.ts | 9 +++++++++ explainers/mri-scanner/src/ui/format.dom.test.ts | 5 ++++- explainers/mri-scanner/src/ui/format.ts | 6 +++--- explainers/mri-scanner/src/ui/mountSpinsReadouts.ts | 2 +- explainers/mri-scanner/src/ui/mountUi.dom.test.ts | 6 ++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/explainers/mri-scanner/src/state/derived.ts b/explainers/mri-scanner/src/state/derived.ts index b2075767..7103dff1 100644 --- a/explainers/mri-scanner/src/state/derived.ts +++ b/explainers/mri-scanner/src/state/derived.ts @@ -18,6 +18,7 @@ import { activeGradient, echoAmplitude, gradientLevel, + larmorHz, magnetisation, phaseOf, pictureFor, @@ -54,6 +55,14 @@ export const PRECESSION_TURNS_PER_CYCLE = (DISPLAY_TURNS_PER_S * SECONDS_PER_MINUTE) / DEFAULT_SPEED; export const CHOSEN_AXIS_LEVEL = 1; +export function drawnTurnsPerSecond(speed: number): number { + return (PRECESSION_TURNS_PER_CYCLE * speed) / SECONDS_PER_MINUTE; +} + +export function drawnSlowdown(field: FieldId, speed: number): number { + return larmorHz(field) / drawnTurnsPerSecond(speed); +} + const PICTURE_STATES_PER_WEIGHTING = LINES_RANGE.max + 1; const RESTING_NET: Point = [0, 0, 0]; diff --git a/explainers/mri-scanner/src/ui/format.dom.test.ts b/explainers/mri-scanner/src/ui/format.dom.test.ts index b9be04e1..27de8360 100644 --- a/explainers/mri-scanner/src/ui/format.dom.test.ts +++ b/explainers/mri-scanner/src/ui/format.dom.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { initI18n } from '@core/i18n'; import en from '../../locales/en.json'; +import { DEFAULT_SPEED } from '../model'; import { formatAcross, formatAlong, @@ -53,7 +54,9 @@ describe('readout formats', () => { expect(formatLarmorBand('field30')).toBe('127.7 MHz, just above FM radio'); expect(formatSurplus('field15')).toBe('about 5 in a million'); expect(formatSurplusCount('field15')).toBe('about 3.3 × 10¹⁴'); - expect(formatSlowdown('field15')).toBe('about 128 million times'); + expect(formatSlowdown('field15', DEFAULT_SPEED)).toBe('about 128 million times'); + expect(formatSlowdown('field30', DEFAULT_SPEED)).toBe('about 255 million times'); + expect(formatSlowdown('field15', DEFAULT_SPEED * 2)).toBe('about 63.9 million times'); expect(toSuperscript(-19)).toBe('⁻¹⁹'); }); diff --git a/explainers/mri-scanner/src/ui/format.ts b/explainers/mri-scanner/src/ui/format.ts index da721393..cf4801be 100644 --- a/explainers/mri-scanner/src/ui/format.ts +++ b/explainers/mri-scanner/src/ui/format.ts @@ -20,11 +20,11 @@ import { riseTimeMs, scanSeconds, shareOfField, - slowdown, spinSurplus, surplusPerMm3, tipComponents, } from '../model'; +import { drawnSlowdown } from '../state'; import type { TimeGauge } from '../state'; const WHOLE = 0; @@ -130,9 +130,9 @@ export function formatSurplusCount(field: FieldId): string { }); } -export function formatSlowdown(field: FieldId): string { +export function formatSlowdown(field: FieldId, speed: number): string { return t('chapters.spins.slowdownValue', { - factor: formatCompact(slowdown(field), SLOWDOWN_DIGITS), + factor: formatCompact(drawnSlowdown(field, speed), SLOWDOWN_DIGITS), }); } diff --git a/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts index 10f57f90..82d46015 100644 --- a/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts +++ b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts @@ -11,6 +11,6 @@ export function mountSpinsReadouts(root: Document, store: MriScannerStore): Disp larmor: (state) => formatLarmorBand(state.field), surplus: (state) => formatSurplus(state.field), surplusCount: (state) => formatSurplusCount(state.field), - slowdown: (state) => formatSlowdown(state.field), + slowdown: (state) => formatSlowdown(state.field, state.speed), }); } diff --git a/explainers/mri-scanner/src/ui/mountUi.dom.test.ts b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts index f68e18c4..f885d417 100644 --- a/explainers/mri-scanner/src/ui/mountUi.dom.test.ts +++ b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts @@ -77,6 +77,12 @@ describe('chapter widgets', () => { expect(readout('fieldShare')).toBe(fill(units.percent, { value: '0.18' })); }); + it('follows the speed slider in the spins slowdown', () => { + expect(readout('slowdown')).toBe(fill(copy.spins.slowdownValue, { factor: '128 million' })); + store.getState().setSpeed(store.getState().speed * 2); + expect(readout('slowdown')).toBe(fill(copy.spins.slowdownValue, { factor: '63.9 million' })); + }); + it('keeps the spins larmor readout apart from the gauge readout of the same id', () => { dispose(); document.body.innerHTML = `
${chapters}`; From c370ba40f44bcfe43ffdd446f1753428f8c35b1b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:46:00 +0200 Subject: [PATCH 31/82] fix(mri-scanner): compute the spin arrows only while the voxel view is on --- explainers/mri-scanner/src/state/derived.test.ts | 11 ++++++++++- explainers/mri-scanner/src/state/derived.ts | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/explainers/mri-scanner/src/state/derived.test.ts b/explainers/mri-scanner/src/state/derived.test.ts index 38260110..4ea44d99 100644 --- a/explainers/mri-scanner/src/state/derived.test.ts +++ b/explainers/mri-scanner/src/state/derived.test.ts @@ -88,7 +88,7 @@ describe('readings', () => { describe('assembly state', () => { it('builds the sequence and spin readings from the model', () => { - const state = createAssemblyState(sourceAt()); + const state = createAssemblyState(sourceAt({ view: { ...DEFAULT_VIEW, voxel: true } })); expect(state.sequence).toMatchObject({ phase: MOMENTS.echoPeak, step: 'echo', @@ -137,6 +137,15 @@ describe('assembly state', () => { expect(front.picture).toBe(back.picture); }); + it('skips the spin readings while the voxel view is off', () => { + const state = createAssemblyState(sourceAt({ view: { ...DEFAULT_VIEW, voxel: true } })); + const { spins } = state; + writeAssemblyState(state, sourceAt({ view: DEFAULT_VIEW })); + expect(state.spins).toBe(spins); + expect(state.spins.arrows).toHaveLength(0); + expect(state.spins.precession).toBe(0); + }); + it('copies the view flags without sharing the store object', () => { const view = { ...DEFAULT_VIEW, voxel: true }; const state = createAssemblyState(sourceAt({ view })); diff --git a/explainers/mri-scanner/src/state/derived.ts b/explainers/mri-scanner/src/state/derived.ts index 7103dff1..6d730247 100644 --- a/explainers/mri-scanner/src/state/derived.ts +++ b/explainers/mri-scanner/src/state/derived.ts @@ -126,7 +126,11 @@ function writeSequence( target.echo = echoAmplitude(phase, weighting, tissue, field, tipAngle); } -function writeSpins(target: SpinReading, source: SpinSource): void { +function writeSpins(target: SpinReading, source: SpinSource & Pick): void { + if (!source.view.voxel) { + Object.assign(target, EMPTY_SPINS); + return; + } const { phase, weighting, tissue, field, tipAngle } = source; target.net = magnetisation(phase, weighting, tissue, field, tipAngle); target.arrows = spinArrows(phase, weighting, tissue, field, tipAngle); From f8e3b48c68c31ba434cbf679e9bb05f6aec5e932 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:46:54 +0200 Subject: [PATCH 32/82] fix(vite): read the explainer slug relative to the project root --- vite/chunks.test.ts | 51 +++++++++++++++++++++++++++++++++++++-------- vite/chunks.ts | 25 +++++++++++++++++----- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/vite/chunks.test.ts b/vite/chunks.test.ts index ade27a84..db77543e 100644 --- a/vite/chunks.test.ts +++ b/vite/chunks.test.ts @@ -48,6 +48,8 @@ describe('dependsOn', () => { }); }); +const FIXTURE_ROOT = '/'; + const packages = graph( { '/src/core/mount.ts': ['/node_modules/i18next/index.js'], @@ -83,28 +85,59 @@ const packages = graph( describe('isExplainerOnlyPackage', () => { it('is true for a package that only one explainer imports, from any of its files', () => { - expect(isExplainerOnlyPackage('/node_modules/fft.js/lib/fft.js', packages)).toBe(true); + expect(isExplainerOnlyPackage('/node_modules/fft.js/lib/fft.js', packages, FIXTURE_ROOT)).toBe( + true, + ); }); it('follows imports through other packages, cycles included', () => { - expect(isExplainerOnlyPackage('/node_modules/inner/index.js', packages)).toBe(true); + expect(isExplainerOnlyPackage('/node_modules/inner/index.js', packages, FIXTURE_ROOT)).toBe( + true, + ); }); it('counts dynamic imports as reaching the package', () => { - expect(isExplainerOnlyPackage('/node_modules/lazy/index.js', packages)).toBe(true); - expect(isExplainerOnlyPackage('/node_modules/lazy-core/index.js', packages)).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/lazy/index.js', packages, FIXTURE_ROOT)).toBe( + true, + ); + expect(isExplainerOnlyPackage('/node_modules/lazy-core/index.js', packages, FIXTURE_ROOT)).toBe( + false, + ); }); it('is false for a package that two explainers or the core import', () => { - expect(isExplainerOnlyPackage('/node_modules/both/index.js', packages)).toBe(false); - expect(isExplainerOnlyPackage('/node_modules/i18next/index.js', packages)).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/both/index.js', packages, FIXTURE_ROOT)).toBe( + false, + ); + expect(isExplainerOnlyPackage('/node_modules/i18next/index.js', packages, FIXTURE_ROOT)).toBe( + false, + ); }); it('is false for project code and for a package nothing imports', () => { - expect(isExplainerOnlyPackage('/explainers/mri-scanner/src/model/image.ts', packages)).toBe( + expect( + isExplainerOnlyPackage('/explainers/mri-scanner/src/model/image.ts', packages, FIXTURE_ROOT), + ).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/orphan/index.js', packages, FIXTURE_ROOT)).toBe( false, ); - expect(isExplainerOnlyPackage('/node_modules/orphan/index.js', packages)).toBe(false); - expect(isExplainerOnlyPackage('/node_modules/unknown/index.js', packages)).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/unknown/index.js', packages, FIXTURE_ROOT)).toBe( + false, + ); + }); + + it('reads the slug below the project root when the checkout sits in an explainers folder', () => { + const root = '/home/reader/explainers/site'; + const nested = graph({ + [`${root}/explainers/mri-scanner/src/model/kspace.ts`]: [ + `${root}/node_modules/fft.js/index.js`, + ], + [`${root}/explainers/sundial/src/model/sun.ts`]: [`${root}/node_modules/both/index.js`], + [`${root}/explainers/naval-drone/src/model/hull.ts`]: [`${root}/node_modules/both/index.js`], + [`${root}/node_modules/fft.js/index.js`]: [], + [`${root}/node_modules/both/index.js`]: [], + }); + expect(isExplainerOnlyPackage(`${root}/node_modules/fft.js/index.js`, nested, root)).toBe(true); + expect(isExplainerOnlyPackage(`${root}/node_modules/both/index.js`, nested, root)).toBe(false); }); }); diff --git a/vite/chunks.ts b/vite/chunks.ts index dd0973de..7b4e01d1 100644 --- a/vite/chunks.ts +++ b/vite/chunks.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; + export interface ModuleInfo { importedIds: readonly string[]; importers: readonly string[]; @@ -9,7 +11,8 @@ export interface ModuleGraph { } const PACKAGE_MODULE = /[\\/]node_modules[\\/]/; -const EXPLAINER_MODULE = /[\\/]explainers[\\/]([^\\/]+)[\\/]/; +const EXPLAINER_MODULE = /^explainers\/([^/]+)\//; +const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url)); export function dependsOn(id: string, target: RegExp, graph: ModuleGraph): boolean { const visited = new Set([id]); @@ -45,12 +48,24 @@ function projectImporters(id: string, graph: ModuleGraph): Set { return importers; } -function explainerSlug(id: string): string | undefined { - return EXPLAINER_MODULE.exec(id)?.[1]; +function toPosix(path: string): string { + return path.replaceAll('\\', '/'); +} + +function explainerSlug(id: string, root: string): string | undefined { + const base = toPosix(root).replace(/\/?$/, '/'); + const path = toPosix(id); + if (!path.startsWith(base)) return undefined; + return EXPLAINER_MODULE.exec(path.slice(base.length))?.[1]; } -export function isExplainerOnlyPackage(id: string, graph: ModuleGraph): boolean { +export function isExplainerOnlyPackage( + id: string, + graph: ModuleGraph, + root: string = PROJECT_ROOT, +): boolean { if (!PACKAGE_MODULE.test(id)) return false; - const owners = new Set([...projectImporters(id, graph)].map(explainerSlug)); + const importers = [...projectImporters(id, graph)]; + const owners = new Set(importers.map((importer) => explainerSlug(importer, root))); return owners.size === 1 && !owners.has(undefined); } From a9347c0b4a04337864212741e808fc947030dc5d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:47:12 +0200 Subject: [PATCH 33/82] test(vite): cover absolute posix and Windows ids in the chunk rule --- vite/chunks.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/vite/chunks.test.ts b/vite/chunks.test.ts index db77543e..4cf9387c 100644 --- a/vite/chunks.test.ts +++ b/vite/chunks.test.ts @@ -140,4 +140,32 @@ describe('isExplainerOnlyPackage', () => { expect(isExplainerOnlyPackage(`${root}/node_modules/fft.js/index.js`, nested, root)).toBe(true); expect(isExplainerOnlyPackage(`${root}/node_modules/both/index.js`, nested, root)).toBe(false); }); + + it('handles a full absolute posix id', () => { + const root = '/Users/reader/site'; + const absolute = graph({ + [`${root}/explainers/mri-scanner/src/model/kspace.ts`]: [ + `${root}/node_modules/fft.js/index.js`, + ], + [`${root}/node_modules/fft.js/index.js`]: [], + }); + expect(isExplainerOnlyPackage(`${root}/node_modules/fft.js/index.js`, absolute, root)).toBe( + true, + ); + }); + + it('handles a Windows id with backslashes', () => { + const root = 'C:\\Users\\reader\\site'; + const fft = `${root}\\node_modules\\fft.js\\index.js`; + const windows = graph({ + [`${root}\\explainers\\mri-scanner\\src\\model\\kspace.ts`]: [fft], + [`${root}\\src\\core\\mount.ts`]: [`${root}\\node_modules\\i18next\\index.js`], + [fft]: [], + [`${root}\\node_modules\\i18next\\index.js`]: [], + }); + expect(isExplainerOnlyPackage(fft, windows, root)).toBe(true); + expect(isExplainerOnlyPackage(`${root}\\node_modules\\i18next\\index.js`, windows, root)).toBe( + false, + ); + }); }); From 176d1fa8bf62aafc0f13d7318ea48803bccc2db2 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:47:22 +0200 Subject: [PATCH 34/82] fix(mri-scanner): drop the readouts spacing override in the tip widget --- explainers/mri-scanner/src/style.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/explainers/mri-scanner/src/style.css b/explainers/mri-scanner/src/style.css index 40c32718..b6393e52 100644 --- a/explainers/mri-scanner/src/style.css +++ b/explainers/mri-scanner/src/style.css @@ -49,10 +49,6 @@ overflow-wrap: break-word; } -.range-widget .readouts { - margin-block: 12px 0; -} - .kspace-widget { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); From e8507109bda8b39f171a4dec46248b46fb1ac130 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:47:47 +0200 Subject: [PATCH 35/82] fix(mri-scanner): drop the unused playback flag from the line counter --- .../mri-scanner/src/state/store.test.ts | 25 ++++++++----------- explainers/mri-scanner/src/state/store.ts | 6 ++--- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/explainers/mri-scanner/src/state/store.test.ts b/explainers/mri-scanner/src/state/store.test.ts index 50a4c5d9..7e8b843e 100644 --- a/explainers/mri-scanner/src/state/store.test.ts +++ b/explainers/mri-scanner/src/state/store.test.ts @@ -12,9 +12,6 @@ import { TIP_ANGLE_RANGE, } from './store'; -const PLAYBACK = true; -const SEEK = false; - function storeOn(preset: Parameters[0] = 'magnet') { const store = createMriScannerStore(); store.getState().applyPreset(preset); @@ -27,26 +24,24 @@ function secondsFor(units: number, speed: number): number { describe('line crossing', () => { it('counts a forward playback step that reaches the end of the echo', () => { - expect(crossesLineDone(600, 700, PLAYBACK)).toBe(true); - expect(crossesLineDone(600, LINE_DONE_UNITS, PLAYBACK)).toBe(true); + expect(crossesLineDone(600, 700)).toBe(true); + expect(crossesLineDone(600, LINE_DONE_UNITS)).toBe(true); }); it('ignores a step that leaves from the line mark or stays short of it', () => { - expect(crossesLineDone(LINE_DONE_UNITS, 700, PLAYBACK)).toBe(false); - expect(crossesLineDone(100, 600, PLAYBACK)).toBe(false); - expect(crossesLineDone(700, 900, PLAYBACK)).toBe(false); + expect(crossesLineDone(LINE_DONE_UNITS, 700)).toBe(false); + expect(crossesLineDone(100, 600)).toBe(false); + expect(crossesLineDone(700, 900)).toBe(false); }); it('counts a step that spans the loop wrap and passes the mark on either side', () => { - expect(crossesLineDone(600, 100, PLAYBACK)).toBe(true); - expect(crossesLineDone(900, 700, PLAYBACK)).toBe(true); - expect(crossesLineDone(900, 100, PLAYBACK)).toBe(false); + expect(crossesLineDone(600, 100)).toBe(true); + expect(crossesLineDone(900, 700)).toBe(true); + expect(crossesLineDone(900, 100)).toBe(false); }); - it('never counts a seek or a still phase', () => { - expect(crossesLineDone(600, 700, SEEK)).toBe(false); - expect(crossesLineDone(600, 100, SEEK)).toBe(false); - expect(crossesLineDone(600, 600, PLAYBACK)).toBe(false); + it('never counts a still phase', () => { + expect(crossesLineDone(600, 600)).toBe(false); }); it('wraps to a new scan after the last line', () => { diff --git a/explainers/mri-scanner/src/state/store.ts b/explainers/mri-scanner/src/state/store.ts index 82390f89..1c403650 100644 --- a/explainers/mri-scanner/src/state/store.ts +++ b/explainers/mri-scanner/src/state/store.ts @@ -71,8 +71,8 @@ export function snapToRange(value: number, { min, max, step }: SteppedRange): nu return clamp(min + Math.round((value - min) / step) * step, min, max); } -export function crossesLineDone(previous: number, next: number, byPlayback: boolean): boolean { - if (!byPlayback || previous === next) return false; +export function crossesLineDone(previous: number, next: number): boolean { + if (previous === next) return false; if (next > previous) return previous < LINE_DONE_UNITS && LINE_DONE_UNITS <= next; return previous < LINE_DONE_UNITS || LINE_DONE_UNITS <= next; } @@ -96,7 +96,7 @@ function countLinesOnPlayback(store: MriScannerStore): MriScannerStore { tick: (deltaSeconds) => { const previous = store.getState().phase; advance(deltaSeconds); - if (crossesLineDone(previous, store.getState().phase, true)) store.getState().addLine(); + if (crossesLineDone(previous, store.getState().phase)) store.getState().addLine(); }, }); return store; From 5d8d7dd8903ffa3873ce7ed2fb03b488067b669a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:48:13 +0200 Subject: [PATCH 36/82] fix(mri-scanner): drop the duplicate chapter control settings from the presets --- explainers/mri-scanner/src/state/presets.test.ts | 4 ++-- explainers/mri-scanner/src/state/presets.ts | 10 ---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/explainers/mri-scanner/src/state/presets.test.ts b/explainers/mri-scanner/src/state/presets.test.ts index b0069eb2..99ca2e3e 100644 --- a/explainers/mri-scanner/src/state/presets.test.ts +++ b/explainers/mri-scanner/src/state/presets.test.ts @@ -63,8 +63,8 @@ describe('chapter presets', () => { it('starts the chapter controls the spec names', () => { expect(PRESETS.overview.start).toEqual({ linesFilled: 24 }); expect(PRESETS.magnet.start).toBeUndefined(); - expect(PRESETS.spins.start).toEqual({ tissue: 'whiteMatter' }); - expect(PRESETS.resonance.start).toEqual({ tipAngle: 90, tissue: 'whiteMatter' }); + expect(PRESETS.spins.start).toBeUndefined(); + expect(PRESETS.resonance.start).toBeUndefined(); expect(PRESETS.gradients.start).toEqual({ gradientAxis: null }); expect(PRESETS.picture.start).toEqual({ linesFilled: 8 }); }); diff --git a/explainers/mri-scanner/src/state/presets.ts b/explainers/mri-scanner/src/state/presets.ts index 26040456..22979a68 100644 --- a/explainers/mri-scanner/src/state/presets.ts +++ b/explainers/mri-scanner/src/state/presets.ts @@ -10,18 +10,14 @@ export interface ChapterControls { linesFilled: number; } -export type ChapterControl = keyof ChapterControls; - export interface Preset extends ScenePreset { view: Partial; - controls?: readonly ChapterControl[]; start?: Partial; } const LOOP_START = 0; const OVERVIEW_LINES = 24; const PICTURE_START_LINES = 8; -const RESTING_TIP_DEG = 90; const MAGNET_LABELS: readonly PartId[] = [ 'vacuumVessel', @@ -61,8 +57,6 @@ export const PRESETS: Record = { speed: DEFAULT_SPEED, view: { cutaway: true, fieldLines: false, voxel: true }, pauseAt: LOOP_START, - controls: ['tissue'], - start: { tissue: 'whiteMatter' }, labels: SPIN_PARTS, highlight: [...SPIN_PARTS, 'headCoil'], }, @@ -71,8 +65,6 @@ export const PRESETS: Record = { speed: DEFAULT_SPEED, view: { cutaway: true, fieldLines: false, voxel: true }, startAt: LOOP_START, - controls: ['tipAngle', 'tissue'], - start: { tipAngle: RESTING_TIP_DEG, tissue: 'whiteMatter' }, labels: ['bodyCoil', 'headCoil', 'rfWave', 'echoWave', 'netMagnet'], highlight: ['bodyCoil', 'headCoil', 'rfWave', 'echoWave', 'spinArrows', 'netMagnet'], }, @@ -81,7 +73,6 @@ export const PRESETS: Record = { speed: DEFAULT_SPEED, view: { cutaway: true, fieldLines: false, voxel: false }, startAt: PHASE_RANGES.encode[0], - controls: ['gradientAxis'], start: { gradientAxis: null }, labels: [...GRADIENT_COILS, 'bodyCoil'], highlight: GRADIENT_COILS, @@ -91,7 +82,6 @@ export const PRESETS: Record = { speed: PICTURE_SPEED, view: { cutaway: false, fieldLines: false, voxel: false }, startAt: PHASE_RANGES.echo[0], - controls: ['linesFilled'], start: { linesFilled: PICTURE_START_LINES }, labels: ['screen', 'headCoil', 'echoWave'], highlight: ['screen', 'headCoil', 'patient', 'echoWave'], From 4fd106cfda5b3865e762cae65c83d5a96ccb8630 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:48:52 +0200 Subject: [PATCH 37/82] fix(mri-scanner): import the model constants from the model directly --- explainers/mri-scanner/src/state/derived.ts | 2 +- explainers/mri-scanner/src/state/presets.test.ts | 2 +- explainers/mri-scanner/src/state/presets.ts | 3 +-- explainers/mri-scanner/src/state/store.ts | 4 ++-- explainers/mri-scanner/src/timeline.dom.test.ts | 5 +---- explainers/mri-scanner/src/timeline.ts | 13 +------------ 6 files changed, 7 insertions(+), 22 deletions(-) diff --git a/explainers/mri-scanner/src/state/derived.ts b/explainers/mri-scanner/src/state/derived.ts index 6d730247..5403e72b 100644 --- a/explainers/mri-scanner/src/state/derived.ts +++ b/explainers/mri-scanner/src/state/derived.ts @@ -11,6 +11,7 @@ import type { } from '../ids'; import { CYCLE_UNITS, + DEFAULT_SPEED, DISPLAY_TURNS_PER_S, FIELDS, SECONDS_PER_MINUTE, @@ -26,7 +27,6 @@ import { rfPulse, spinArrows, } from '../model'; -import { DEFAULT_SPEED } from '../timeline'; import { LINES_RANGE } from './store'; import type { MriScannerState } from './store'; diff --git a/explainers/mri-scanner/src/state/presets.test.ts b/explainers/mri-scanner/src/state/presets.test.ts index 99ca2e3e..f2070258 100644 --- a/explainers/mri-scanner/src/state/presets.test.ts +++ b/explainers/mri-scanner/src/state/presets.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { PART_IDS, PRESET_IDS } from '../ids'; import { PHASE_RANGES } from '../model'; import { CAMERA_VIEWS } from '../scene/cameraViews'; -import { DEFAULT_SPEED, PICTURE_SPEED } from '../timeline'; +import { DEFAULT_SPEED, PICTURE_SPEED } from '../model'; import { PRESETS } from './presets'; const SCENE_FLAGS = ['cutaway', 'fieldLines', 'voxel'] as const; diff --git a/explainers/mri-scanner/src/state/presets.ts b/explainers/mri-scanner/src/state/presets.ts index 22979a68..6c7af1ee 100644 --- a/explainers/mri-scanner/src/state/presets.ts +++ b/explainers/mri-scanner/src/state/presets.ts @@ -1,7 +1,6 @@ import type { ScenePreset } from '@core/scene/presetBinder'; import type { CameraView, GradientAxisId, PartId, PresetId, TissueId, ViewOptions } from '../ids'; -import { PHASE_RANGES } from '../model'; -import { DEFAULT_SPEED, PICTURE_SPEED } from '../timeline'; +import { DEFAULT_SPEED, PHASE_RANGES, PICTURE_SPEED } from '../model'; export interface ChapterControls { tipAngle: number; diff --git a/explainers/mri-scanner/src/state/store.ts b/explainers/mri-scanner/src/state/store.ts index 1c403650..fd9dd7d6 100644 --- a/explainers/mri-scanner/src/state/store.ts +++ b/explainers/mri-scanner/src/state/store.ts @@ -10,8 +10,8 @@ import type { ViewOptions, WeightingId, } from '../ids'; -import { LINE_DONE_UNITS, MODEL_SIZE, MOMENTS } from '../model'; -import { MRI_SCANNER_TIMELINE, SPEED_RANGE } from '../timeline'; +import { LINE_DONE_UNITS, MODEL_SIZE, MOMENTS, SPEED_RANGE } from '../model'; +import { MRI_SCANNER_TIMELINE } from '../timeline'; import { PRESETS } from './presets'; import type { ChapterControls, Preset } from './presets'; diff --git a/explainers/mri-scanner/src/timeline.dom.test.ts b/explainers/mri-scanner/src/timeline.dom.test.ts index a175f86e..dd25f46e 100644 --- a/explainers/mri-scanner/src/timeline.dom.test.ts +++ b/explainers/mri-scanner/src/timeline.dom.test.ts @@ -5,12 +5,9 @@ import { MOMENT_IDS, PHASE_IDS } from './ids'; import type { PhaseId } from './ids'; import { CYCLE_UNITS, PHASE_RANGES } from './model'; import { PHASE_TONES } from './theme'; +import { DEFAULT_SPEED, MOMENTS, PICTURE_SPEED, SPEED_RANGE } from './model'; import { - DEFAULT_SPEED, - MOMENTS, MRI_SCANNER_TIMELINE, - PICTURE_SPEED, - SPEED_RANGE, describePhase, describeSpeed, formatPhase, diff --git a/explainers/mri-scanner/src/timeline.ts b/explainers/mri-scanner/src/timeline.ts index 6618e3a4..885b23f8 100644 --- a/explainers/mri-scanner/src/timeline.ts +++ b/explainers/mri-scanner/src/timeline.ts @@ -3,16 +3,7 @@ import { formatNumber } from '@core/format'; import { t } from '@core/i18n'; import { MOMENT_IDS, PHASE_IDS } from './ids'; import type { MomentId, PhaseId } from './ids'; -import { - CYCLE_UNITS, - DEFAULT_SPEED, - MOMENTS, - PHASE_RANGES, - PICTURE_SPEED, - SPEED_RANGE, - phaseOf, - rate, -} from './model'; +import { CYCLE_UNITS, MOMENTS, PHASE_RANGES, SPEED_RANGE, phaseOf, rate } from './model'; import { PHASE_TONES } from './theme'; const SCRUBBER_STEP_UNITS = 1; @@ -87,5 +78,3 @@ export const MRI_SCANNER_TIMELINE: Timeline = { describe: describeSpeed, }, }; - -export { DEFAULT_SPEED, MOMENTS, PICTURE_SPEED, SPEED_RANGE }; From cc651465eceeb03721afb4ffa7156166852cb7a1 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:39:58 +0200 Subject: [PATCH 38/82] fix(mri-scanner): clarify the English copy after review Name the Larmor frequency once, explain MRI, mT, K, echo time and T2 loss, name the model's simplifications, split long sentences, match the step names to the moment chips, use the range widget readouts class, fix the default fallbacks and drop the unused minute and second units. --- explainers/mri-scanner/chapters.html | 139 +++++++++++++------------ explainers/mri-scanner/locales/de.json | 2 - explainers/mri-scanner/locales/en.json | 66 ++++++------ explainers/mri-scanner/locales/es.json | 2 - explainers/mri-scanner/locales/fr.json | 2 - explainers/mri-scanner/locales/ja.json | 2 - explainers/mri-scanner/locales/pt.json | 2 - explainers/mri-scanner/locales/uk.json | 2 - explainers/mri-scanner/locales/zh.json | 2 - 9 files changed, 105 insertions(+), 114 deletions(-) diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html index fe968c40..5dd076fe 100644 --- a/explainers/mri-scanner/chapters.html +++ b/explainers/mri-scanner/chapters.html @@ -4,31 +4,31 @@

A tunnel that listens to your body

- This is an MRI scanner with a 1.5 tesla magnet. The - tesla measures how strong a magnetic field is. You lie on the - table, and it slides you into the bore, a tunnel about 70 cm - wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes. + This is an MRI scanner with a 1.5 tesla magnet. MRI stands for magnetic + resonance imaging. The tesla measures how strong a magnetic field is. You lie + on the table, and it slides you into the bore, a tunnel about + 70 cm wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes.

For a brain scan, a head coil sits around your head like a cage. It is an antenna with 20 to 64 small receivers that listen for a faint radio echo from your body. The - room is lined with copper sheet to keep outside radio out, so it cannot drown that echo. There - are no X-rays here. A radio wave at this pitch carries about 50 million times too little energy - to knock an electron out of an atom. + room is lined with copper sheet. It keeps outside radio waves from drowning that echo. There are + no X-rays here. The scanner's radio waves carry about 50 million times too little energy to + knock an electron out of an atom.

- Under the white cover, layers sit one inside the other like rings: the magnet - in its cold jacket, three gradient coils and a radio antenna called the - body coil. Switch on the cutaway (C) to see them. The next chapters take them - one at a time. + Under the white cover, layers sit one inside the other like rings. From the + outside in, you find the magnet in its cold jacket, three gradient coils and + the body coil, a radio antenna. Switch on the cutaway (C) to see them. The next + chapters take them one at a time.

The model follows one repetition, the short cycle a scan runs again and again to build one picture. In this model one repetition lasts 0.5 to 2.5 s of real time, but the - radio pulses inside it take only milliseconds. So the timeline is stretched: the pulses fill - most of the loop and the long rest is squeezed. The timeline names the step, and the live - readings show the real time. A whole head exam, with many pictures, takes about 30 to 45 - minutes. + radio pulses inside it take only milliseconds. So the timeline is not to scale. The few + milliseconds of pulses and echo fill about two thirds of it. The long rest is squeezed into the + last third. The timeline names the step, and the live readings show the real time. A whole head + exam, with many pictures, takes about 30 to 45 minutes.

@@ -53,25 +53,26 @@

A magnet that never swit less than a tenth of a percent in a year.

- To stay superconducting, the coils sit in liquid helium at 4.2 K, about −269 - °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres - of it, while newer sealed magnets need just 7. A heat shield and a vacuum around the helium - vessel work like a thermos flask. A small fridge on top, the cold head, turns - boiled helium back into liquid; it runs day and night and draws about 6 to 7 kW. + To stay superconducting, the coils sit in liquid helium at 4.2 kelvin (K), + about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about + 1,500 litres of it, while newer sealed magnets need 7 litres or even less. A heat shield and a + vacuum around the helium vessel work like a thermos flask. A small fridge on top, the + cold head, turns boiled helium back into liquid. It runs day and night and + draws about 6 to 7 kW.

- The field is tens of thousands of times stronger than Earth's, which changes from place to - place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside - push current the other way and pull the stray field in close. Even so, the 0.5 mT line, inside - which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field - lines to see it. + The field is tens of thousands of times stronger than Earth's. Earth's own field differs from + place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on + the outside carry current the other way and pull in the field that leaks out. Even so, the field + stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that + line, a pacemaker may no longer work safely. The dashed ring on the floor marks it.

Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting - and the current turns into heat: a quench. The field drops to zero in 2 to 5 - seconds, and the boiling helium roars out of the building through the + and the current turns into heat. This is a quench. The field drops to zero in 2 + to 5 seconds. The boiling helium roars out of the building through the quench pipe.

@@ -121,21 +122,22 @@

Hydrogen leans and wobbles

In the field, each tiny magnet does not simply point along it. It precesses: it wobbles around the field direction like a tilted spinning top. At 1.5 T it wobbles about 64 - million times a second, 63.9 MHz. This rate is the Larmor frequency, and it - grows with the field, to 127.7 MHz at 3 T. Both are close to FM radio, one just below the band - and one just above it. + million times a second, 63.9 MHz. This rate, the pitch of the wobble, is the + Larmor frequency. It grows with the field, to 127.7 MHz at 3 T. Both are close + to FM radio, one just below the band and one just above it.

The nuclei do not all line up. Body heat jostles them, so they point almost every which way. Only about 5 in a million more lean with the field than against it. That small surplus still - adds up to about 3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny + adds up to about 3.3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny net magnet, the arrow the scanner works with.

The arrows in the inset are a drawing, enlarged many times, not a real spot in the head. They - also show the lean much stronger than it is, so you can see it. The drawn wobble turns half a - turn per second, about 128 million times slower than the real one at 1.5 T. Switch to 3 T and - watch the frequency and the surplus double. + also show the lean much stronger than it is, so you can see it. Press play to watch them turn. + The drawing is slowed down enormously, and the reading shows by how much. That number also + changes with the playback speed. Switch to 3 T: the real frequency and the surplus double, but + the drawing keeps its pace.

-
Wobble frequency
+
Larmor frequency
63.9 MHz, just below FM radio
@@ -188,22 +190,25 @@

A radio pulse and

A 90° pulse tips the net magnet over until it lies sideways. There it keeps - wobbling, and as it sweeps past the head coil it makes a tiny voltage of a few microvolts. That + wobbling. As it sweeps past the head coil, it makes a tiny voltage, measured in microvolts. That is the signal. Move the tip angle slider: only the sideways part makes signal, the part along the field makes none.

- The tiny magnets soon drift out of step, because some wobble a little faster than others, and - the signal fades. Halfway to the echo, a 180° pulse flips them over, so the - fast ones end up behind and catch up. They come back into step and give an - echo at the echo time. Use the chips to stop at each moment. + The tiny magnets soon drift out of step, because some wobble a little faster than others. The + signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast + ones end up behind and catch up. They come back into step and give an echo. The + time from the 90° pulse to the echo is the echo time. Use the buttons below to + stop at each moment.

Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the - sideways signal takes to fade to about 37 % as the tiny magnets fall out of step. Fat recovers - fast and brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the - scanner keeps that heating within safe limits. + sideways signal takes to fade to about 37 %. The 180° flip undoes only steady differences. + Random nudges between neighbours stay, so the echo is a little weaker. That loss is T2. Fat + recovers fast and brain fluid slowly. Pick a tissue to compare. The values for each tissue are + rough figures chosen for this model. The pulses also warm you slightly, so the scanner keeps + that heating within safe limits.

@@ -211,7 +216,7 @@

A radio pulse and >90°

-
+
Sideways part (signal)
100 %
@@ -306,15 +311,15 @@

A radio pulse and

Coils that tell places apart

So far every spot in the head answers at the same pitch, so the echo cannot tell where it came - from. Three more coils inside the magnet fix that: the gradient coils x, y and - z. A gradient is a field that changes steadily from one side to the other. Each - coil adds a little to the main field on one side of the centre and takes a little away on the - other. + from. Three more coils inside the magnet fix that. They are the + gradient coils x, y and z. A gradient is a field that changes + steadily from one side to the other. Each coil adds a little to the main field on one side of + the centre. It takes a little away on the other.

- The change is small. At up to 45 mT per metre, the field at the edge of the head shifts by about - 5.4 mT, a third of a percent of the main field. That is enough to give every position its own - frequency. The coils switch on in about 0.2 ms. + The change is small. Each coil changes the field by up to 45 mT per metre. At the edge of the + head that adds up to about 5.4 mT. That is a third of a percent of a 1.5 T field. It is enough + to give every position its own frequency. The coils switch on in about 0.2 ms.

Each coil has its own job. The z coil works with the radio pulse to wake just one thin slice: @@ -376,7 +381,7 @@

Coils that tell pl
0.36 %

-
Pitch spread across the head
+
Pitch shift at the edge of the head
230 kHz
@@ -385,7 +390,7 @@

Coils that tell pl

Job in this scan
-
Follows the sequence
+
Changes with each step
@@ -394,14 +399,14 @@

Coils that tell pl

06

From echoes to a picture

- Each echo is stored as one line of k-space, a grid that sorts the echoes by - pitch and twist instead of by place. One repetition fills one line; the next one changes the - twist and fills another. In this model the picture is 64 × 64, so it takes 64 repetitions; real + Each echo is stored as one line of k-space. This is a grid that sorts the + echoes by pitch and twist, not by place. One repetition fills one line. The next one changes the + twist and fills another. In this model the picture is 64 × 64, so it takes 64 repetitions. Real head scans often use 256 lines. Here the lines fill from the middle outwards, so a rough picture shows up early.

- A maths step called the Fourier transform turns that grid into the picture. It + A calculation called the Fourier transform turns that grid into the picture. It finds which mix of stripes, broad and fine, adds up to the image. The middle of k-space holds the broad stripes, the overall light and shade. The edges hold the fine detail. Try 8, 16, 32 or 64 lines and watch the picture sharpen. @@ -410,15 +415,17 @@

From echoes to a pictu The timing decides which tissues look bright. A T1-weighted scan repeats every 500 ms and listens early, at 15 ms. The repetition ends before slow tissues have grown back, so fat is bright and brain fluid is dark. A T2-weighted scan waits 2,500 ms and - listens at 100 ms: slow-fading fluid now shines, and grey matter beats white. In this model - every tissue holds the same amount of hydrogen, and bone and air stay black. + listens at 100 ms. Now slow-fading fluid shines, and grey matter looks brighter than white + matter. The readings show how bright each tissue looks, with the brightest at 100 %. The head + here is a simple drawing made of ovals, not a real scan. In this model, every tissue holds the + same amount of hydrogen, and bone and air stay black.

With one line per repetition, 256 lines take about 2.1 minutes for the T1 scan and 10.7 for the - T2 scan. Real scanners use turbo spin echo, which reads 4 to 32 lines in each - repetition; at 16 lines, the T2 scan takes about 40 seconds. The idea is young. Paul Lauterbur - published the first MR image in 1973, and he and Peter Mansfield shared the 2003 Nobel Prize for - it. + T2 scan. Most brain scans today use turbo spin echo, which reads 4 to 32 lines + in each repetition. At 16 lines, the T2 scan takes about 40 seconds. This model takes one slice. + A real head scan takes many. Paul Lauterbur published the first MR image in 1973. He and Peter + Mansfield shared the 2003 Nobel Prize for it.

-
One line per repetition, 256 lines
-
2.1 min for 256 lines
+
One line per repetition
+
10.7 min for 256 lines
Turbo spin echo, 16 lines per repetition
-
about 8 s
+
about 40 s

diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json index e923d624..f0c0e03d 100644 --- a/explainers/mri-scanner/locales/de.json +++ b/explainers/mri-scanner/locales/de.json @@ -110,8 +110,6 @@ "ms": "{{value}} ms", "tesla": "{{value}} T", "kelvin": "{{kelvin}} K, {{celsius}} °C", - "minutes": "{{value}} min", - "seconds": "{{value}} s", "kHz": "{{value}} kHz", "mT": "{{value}} mT", "cm": "{{value}} cm" diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index 283c4e36..8b9c33a5 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -20,16 +20,16 @@ "speedValue": "{{value}}/min", "speedFormat": "{{value}} repetitions per minute", "phase": { - "excite": "Radio pulse", + "excite": "90° pulse", "encode": "Encode", - "refocus": "Refocus", + "refocus": "180° pulse", "echo": "Echo", "recover": "Recover" }, "jump": { - "excite": "Jump to the radio pulse", + "excite": "Jump to the 90° pulse", "encode": "Jump to the encoding", - "refocus": "Jump to the refocusing pulse", + "refocus": "Jump to the 180° pulse", "echo": "Jump to the echo", "recover": "Jump to the recovery" } @@ -97,7 +97,7 @@ } }, "readouts": { - "larmor": "Spin frequency", + "larmor": "Larmor frequency", "time": "Time in the repetition", "lines": "Lines of k-space" }, @@ -110,8 +110,6 @@ "ms": "{{value}} ms", "tesla": "{{value}} T", "kelvin": "{{kelvin}} K, {{celsius}} °C", - "minutes": "{{value}} min", - "seconds": "{{value}} s", "kHz": "{{value}} kHz", "mT": "{{value}} mT", "cm": "{{value}} cm" @@ -131,7 +129,7 @@ "fringeValue": "{{along}} m along the tunnel, {{side}} m to the side" }, "spins": { - "larmor": "Wobble frequency", + "larmor": "Larmor frequency", "larmorBand": "{{value}}, {{band}}", "band": { "belowFm": "just below FM radio", @@ -154,26 +152,26 @@ "gradients": { "edge": "Change at the head's edge", "share": "Share of the main field", - "spread": "Pitch spread across the head", + "spread": "Pitch shift at the edge of the head", "rise": "Switch-on time", "role": "Job in this scan", "roleValue": { "x": "Frequency: left to right", - "y": "Phase: one twist per line", + "y": "Phase: a new twist each repetition", "z": "Slice: picks one thin slab", - "sequence": "Follows the sequence" + "sequence": "Changes with each step" } }, "picture": { "kspace": "k-space, the stored echoes", "image": "The picture", - "scanTime": "One line per repetition, 256 lines", + "scanTime": "One line per repetition", "scanTimeValue": "{{minutes}} min for {{lines}} lines", "turbo": "Turbo spin echo, 16 lines per repetition", "turboValue": "about {{seconds}} s", - "empty": "Press play to fill the first line", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "No lines yet. The next echo fills the first one.", + "kspaceAlt": "The k-space grid, with {{lines}} of {{total}} lines filled", + "imageAlt": "The head slice, rebuilt from {{lines}} of {{total}} lines" } }, "parts": { @@ -208,45 +206,45 @@ "sections": { "overview": { "title": "A tunnel that listens to your body", - "p1": "This is an MRI scanner with a 1.5 tesla magnet. The tesla measures how strong a magnetic field is. You lie on the table, and it slides you into the bore, a tunnel about 70 cm wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes.", - "p2": "For a brain scan, a head coil sits around your head like a cage. It is an antenna with 20 to 64 small receivers that listen for a faint radio echo from your body. The room is lined with copper sheet to keep outside radio out, so it cannot drown that echo. There are no X-rays here. A radio wave at this pitch carries about 50 million times too little energy to knock an electron out of an atom.", - "p3": "Under the white cover, layers sit one inside the other like rings: the magnet in its cold jacket, three gradient coils and a radio antenna called the body coil. Switch on the cutaway (C) to see them. The next chapters take them one at a time.", - "p4": "The model follows one repetition, the short cycle a scan runs again and again to build one picture. In this model one repetition lasts 0.5 to 2.5 s of real time, but the radio pulses inside it take only milliseconds. So the timeline is stretched: the pulses fill most of the loop and the long rest is squeezed. The timeline names the step, and the live readings show the real time. A whole head exam, with many pictures, takes about 30 to 45 minutes." + "p1": "This is an MRI scanner with a 1.5 tesla magnet. MRI stands for magnetic resonance imaging. The tesla measures how strong a magnetic field is. You lie on the table, and it slides you into the bore, a tunnel about 70 cm wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes.", + "p2": "For a brain scan, a head coil sits around your head like a cage. It is an antenna with 20 to 64 small receivers that listen for a faint radio echo from your body. The room is lined with copper sheet. It keeps outside radio waves from drowning that echo. There are no X-rays here. The scanner's radio waves carry about 50 million times too little energy to knock an electron out of an atom.", + "p3": "Under the white cover, layers sit one inside the other like rings. From the outside in, you find the magnet in its cold jacket, three gradient coils and the body coil, a radio antenna. Switch on the cutaway (C) to see them. The next chapters take them one at a time.", + "p4": "The model follows one repetition, the short cycle a scan runs again and again to build one picture. In this model one repetition lasts 0.5 to 2.5 s of real time, but the radio pulses inside it take only milliseconds. So the timeline is not to scale. The few milliseconds of pulses and echo fill about two thirds of it. The long rest is squeezed into the last third. The timeline names the step, and the live readings show the real time. A whole head exam, with many pictures, takes about 30 to 45 minutes." }, "magnet": { "title": "A magnet that never switches off", "p1": "The biggest part is the main magnet: about 30 to 40 km of niobium-titanium wire wound into coils. Cooled far enough, this wire becomes superconducting, which means current flows through it with no resistance at all. Several hundred amperes go round and round in a closed loop. Once the loop is closed, holding the field takes no power. It fades by less than a tenth of a percent in a year.", - "p2": "To stay superconducting, the coils sit in liquid helium at 4.2 K, about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres of it, while newer sealed magnets need just 7. A heat shield and a vacuum around the helium vessel work like a thermos flask. A small fridge on top, the cold head, turns boiled helium back into liquid; it runs day and night and draws about 6 to 7 kW.", - "p3": "The field is tens of thousands of times stronger than Earth's, which changes from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside push current the other way and pull the stray field in close. Even so, the 0.5 mT line, inside which a pacemaker may no longer be safe, reaches about 4 m along the tunnel. Turn on the field lines to see it.", - "p4": "Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting and the current turns into heat: a quench. The field drops to zero in 2 to 5 seconds, and the boiling helium roars out of the building through the quench pipe." + "p2": "To stay superconducting, the coils sit in liquid helium at 4.2 kelvin (K), about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres of it, while newer sealed magnets need 7 litres or even less. A heat shield and a vacuum around the helium vessel work like a thermos flask. A small fridge on top, the cold head, turns boiled helium back into liquid. It runs day and night and draws about 6 to 7 kW.", + "p3": "The field is tens of thousands of times stronger than Earth's. Earth's own field differs from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside carry current the other way and pull in the field that leaks out. Even so, the field stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that line, a pacemaker may no longer work safely. The dashed ring on the floor marks it.", + "p4": "Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting and the current turns into heat. This is a quench. The field drops to zero in 2 to 5 seconds. The boiling helium roars out of the building through the quench pipe." }, "spins": { "title": "Hydrogen leans and wobbles", "p1": "About six in ten atoms in your body are hydrogen, mostly in water and fat. The core of a hydrogen atom, its nucleus, is a single proton. It behaves like a tiny magnet. MRI listens to hydrogen because it is everywhere and gives the strongest signal.", - "p2": "In the field, each tiny magnet does not simply point along it. It precesses: it wobbles around the field direction like a tilted spinning top. At 1.5 T it wobbles about 64 million times a second, 63.9 MHz. This rate is the Larmor frequency, and it grows with the field, to 127.7 MHz at 3 T. Both are close to FM radio, one just below the band and one just above it.", - "p3": "The nuclei do not all line up. Body heat jostles them, so they point almost every which way. Only about 5 in a million more lean with the field than against it. That small surplus still adds up to about 3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny net magnet, the arrow the scanner works with.", - "p4": "The arrows in the inset are a drawing, enlarged many times, not a real spot in the head. They also show the lean much stronger than it is, so you can see it. The drawn wobble turns half a turn per second, about 128 million times slower than the real one at 1.5 T. Switch to 3 T and watch the frequency and the surplus double." + "p2": "In the field, each tiny magnet does not simply point along it. It precesses: it wobbles around the field direction like a tilted spinning top. At 1.5 T it wobbles about 64 million times a second, 63.9 MHz. This rate, the pitch of the wobble, is the Larmor frequency. It grows with the field, to 127.7 MHz at 3 T. Both are close to FM radio, one just below the band and one just above it.", + "p3": "The nuclei do not all line up. Body heat jostles them, so they point almost every which way. Only about 5 in a million more lean with the field than against it. That small surplus still adds up to about 3.3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny net magnet, the arrow the scanner works with.", + "p4": "The arrows in the inset are a drawing, enlarged many times, not a real spot in the head. They also show the lean much stronger than it is, so you can see it. Press play to watch them turn. The drawing is slowed down enormously, and the reading shows by how much. That number also changes with the playback speed. Switch to 3 T: the real frequency and the surplus double, but the drawing keeps its pace." }, "resonance": { "title": "A radio pulse and its echo", "p1": "To make the net magnet speak, the scanner sends a short radio pulse at exactly the Larmor frequency. Matching the wobble like this is called resonance, like pushing a child on a swing at just the right moment. The pulse comes from the body coil, a cage of copper rungs just inside the tunnel. It lasts a few milliseconds at most.", - "p2": "A 90° pulse tips the net magnet over until it lies sideways. There it keeps wobbling, and as it sweeps past the head coil it makes a tiny voltage of a few microvolts. That is the signal. Move the tip angle slider: only the sideways part makes signal, the part along the field makes none.", - "p3": "The tiny magnets soon drift out of step, because some wobble a little faster than others, and the signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast ones end up behind and catch up. They come back into step and give an echo at the echo time. Use the chips to stop at each moment.", - "p4": "Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the sideways signal takes to fade to about 37 % as the tiny magnets fall out of step. Fat recovers fast and brain fluid slowly; pick a tissue to compare. The pulses also warm you slightly, so the scanner keeps that heating within safe limits." + "p2": "A 90° pulse tips the net magnet over until it lies sideways. There it keeps wobbling. As it sweeps past the head coil, it makes a tiny voltage, measured in microvolts. That is the signal. Move the tip angle slider: only the sideways part makes signal, the part along the field makes none.", + "p3": "The tiny magnets soon drift out of step, because some wobble a little faster than others. The signal fades. Halfway to the echo, a 180° pulse flips them over, so the fast ones end up behind and catch up. They come back into step and give an echo. The time from the 90° pulse to the echo is the echo time. Use the buttons below to stop at each moment.", + "p4": "Two clocks decide how bright each tissue looks. T1 is the time the net magnet takes to grow about 63 % of the way back along the field. T2 is the time the sideways signal takes to fade to about 37 %. The 180° flip undoes only steady differences. Random nudges between neighbours stay, so the echo is a little weaker. That loss is T2. Fat recovers fast and brain fluid slowly. Pick a tissue to compare. The values for each tissue are rough figures chosen for this model. The pulses also warm you slightly, so the scanner keeps that heating within safe limits." }, "gradients": { "title": "Coils that tell places apart", - "p1": "So far every spot in the head answers at the same pitch, so the echo cannot tell where it came from. Three more coils inside the magnet fix that: the gradient coils x, y and z. A gradient is a field that changes steadily from one side to the other. Each coil adds a little to the main field on one side of the centre and takes a little away on the other.", - "p2": "The change is small. At up to 45 mT per metre, the field at the edge of the head shifts by about 5.4 mT, a third of a percent of the main field. That is enough to give every position its own frequency. The coils switch on in about 0.2 ms.", + "p1": "So far every spot in the head answers at the same pitch, so the echo cannot tell where it came from. Three more coils inside the magnet fix that. They are the gradient coils x, y and z. A gradient is a field that changes steadily from one side to the other. Each coil adds a little to the main field on one side of the centre. It takes a little away on the other.", + "p2": "The change is small. Each coil changes the field by up to 45 mT per metre. At the edge of the head that adds up to about 5.4 mT. That is a third of a percent of a 1.5 T field. It is enough to give every position its own frequency. The coils switch on in about 0.2 ms.", "p3": "Each coil has its own job. The z coil works with the radio pulse to wake just one thin slice: slice selection. The y coil gives each repetition a different twist from top to bottom: phase encoding. The x coil runs while the echo is heard, so its pitch tells left from right: frequency encoding. Pick a coil to see its field, or follow the scan.", "p4": "Switching big currents inside a strong magnet makes the coils jolt, like a loudspeaker. That is the loud knocking you hear during a scan. It can pass 100 decibels, about as loud as a jackhammer or louder. That is why you always get earplugs or headphones." }, "picture": { "title": "From echoes to a picture", - "p1": "Each echo is stored as one line of k-space, a grid that sorts the echoes by pitch and twist instead of by place. One repetition fills one line; the next one changes the twist and fills another. In this model the picture is 64 × 64, so it takes 64 repetitions; real head scans often use 256 lines. Here the lines fill from the middle outwards, so a rough picture shows up early.", - "p2": "A maths step called the Fourier transform turns that grid into the picture. It finds which mix of stripes, broad and fine, adds up to the image. The middle of k-space holds the broad stripes, the overall light and shade. The edges hold the fine detail. Try 8, 16, 32 or 64 lines and watch the picture sharpen.", - "p3": "The timing decides which tissues look bright. A T1-weighted scan repeats every 500 ms and listens early, at 15 ms. The repetition ends before slow tissues have grown back, so fat is bright and brain fluid is dark. A T2-weighted scan waits 2,500 ms and listens at 100 ms: slow-fading fluid now shines, and grey matter beats white. In this model every tissue holds the same amount of hydrogen, and bone and air stay black.", - "p4": "With one line per repetition, 256 lines take about 2.1 minutes for the T1 scan and 10.7 for the T2 scan. Real scanners use turbo spin echo, which reads 4 to 32 lines in each repetition; at 16 lines, the T2 scan takes about 40 seconds. The idea is young. Paul Lauterbur published the first MR image in 1973, and he and Peter Mansfield shared the 2003 Nobel Prize for it." + "p1": "Each echo is stored as one line of k-space. This is a grid that sorts the echoes by pitch and twist, not by place. One repetition fills one line. The next one changes the twist and fills another. In this model the picture is 64 × 64, so it takes 64 repetitions. Real head scans often use 256 lines. Here the lines fill from the middle outwards, so a rough picture shows up early.", + "p2": "A calculation called the Fourier transform turns that grid into the picture. It finds which mix of stripes, broad and fine, adds up to the image. The middle of k-space holds the broad stripes, the overall light and shade. The edges hold the fine detail. Try 8, 16, 32 or 64 lines and watch the picture sharpen.", + "p3": "The timing decides which tissues look bright. A T1-weighted scan repeats every 500 ms and listens early, at 15 ms. The repetition ends before slow tissues have grown back, so fat is bright and brain fluid is dark. A T2-weighted scan waits 2,500 ms and listens at 100 ms. Now slow-fading fluid shines, and grey matter looks brighter than white matter. The readings show how bright each tissue looks, with the brightest at 100 %. The head here is a simple drawing made of ovals, not a real scan. In this model, every tissue holds the same amount of hydrogen, and bone and air stay black.", + "p4": "With one line per repetition, 256 lines take about 2.1 minutes for the T1 scan and 10.7 for the T2 scan. Most brain scans today use turbo spin echo, which reads 4 to 32 lines in each repetition. At 16 lines, the T2 scan takes about 40 seconds. This model takes one slice. A real head scan takes many. Paul Lauterbur published the first MR image in 1973. He and Peter Mansfield shared the 2003 Nobel Prize for it." } } } diff --git a/explainers/mri-scanner/locales/es.json b/explainers/mri-scanner/locales/es.json index d9e63f8b..6e52c88e 100644 --- a/explainers/mri-scanner/locales/es.json +++ b/explainers/mri-scanner/locales/es.json @@ -110,8 +110,6 @@ "ms": "{{value}} ms", "tesla": "{{value}} T", "kelvin": "{{kelvin}} K, {{celsius}} °C", - "minutes": "{{value}} min", - "seconds": "{{value}} s", "kHz": "{{value}} kHz", "mT": "{{value}} mT", "cm": "{{value}} cm" diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json index 820f1d7a..6fec8b64 100644 --- a/explainers/mri-scanner/locales/fr.json +++ b/explainers/mri-scanner/locales/fr.json @@ -110,8 +110,6 @@ "ms": "{{value}} ms", "tesla": "{{value}} T", "kelvin": "{{kelvin}} K, {{celsius}} °C", - "minutes": "{{value}} min", - "seconds": "{{value}} s", "kHz": "{{value}} kHz", "mT": "{{value}} mT", "cm": "{{value}} cm" diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json index cc70e634..deade457 100644 --- a/explainers/mri-scanner/locales/ja.json +++ b/explainers/mri-scanner/locales/ja.json @@ -110,8 +110,6 @@ "ms": "{{value}}ミリ秒", "tesla": "{{value}}T", "kelvin": "{{kelvin}}K、{{celsius}}°C", - "minutes": "{{value}}分", - "seconds": "{{value}}秒", "kHz": "{{value}}kHz", "mT": "{{value}}mT", "cm": "{{value}}cm" diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json index 74bf90f0..06de3eff 100644 --- a/explainers/mri-scanner/locales/pt.json +++ b/explainers/mri-scanner/locales/pt.json @@ -110,8 +110,6 @@ "ms": "{{value}} ms", "tesla": "{{value}} T", "kelvin": "{{kelvin}} K, {{celsius}} °C", - "minutes": "{{value}} min", - "seconds": "{{value}} s", "kHz": "{{value}} kHz", "mT": "{{value}} mT", "cm": "{{value}} cm" diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json index 9d78cd44..0f468669 100644 --- a/explainers/mri-scanner/locales/uk.json +++ b/explainers/mri-scanner/locales/uk.json @@ -110,8 +110,6 @@ "ms": "{{value}} мс", "tesla": "{{value}} Тл", "kelvin": "{{kelvin}} К, {{celsius}} °C", - "minutes": "{{value}} хв", - "seconds": "{{value}} с", "kHz": "{{value}} кГц", "mT": "{{value}} мТл", "cm": "{{value}} см" diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json index 6862717e..ab3837ef 100644 --- a/explainers/mri-scanner/locales/zh.json +++ b/explainers/mri-scanner/locales/zh.json @@ -110,8 +110,6 @@ "ms": "{{value}}毫秒", "tesla": "{{value}} T", "kelvin": "{{kelvin}} K,{{celsius}} °C", - "minutes": "{{value}}分钟", - "seconds": "{{value}}秒", "kHz": "{{value}} kHz", "mT": "{{value}} mT", "cm": "{{value}}厘米" From 190404502cb911504300ecc6532e0711d23f1eaf Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:43:08 +0200 Subject: [PATCH 39/82] fix(mri-scanner): apply the review to the Portuguese copy --- explainers/mri-scanner/locales/pt.json | 70 +++++++++++++------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json index 06de3eff..99fc094b 100644 --- a/explainers/mri-scanner/locales/pt.json +++ b/explainers/mri-scanner/locales/pt.json @@ -2,7 +2,7 @@ "meta": { "title": "Como funciona um aparelho de ressonância magnética", "eyebrow": "Diagnóstico por imagem", - "tagline": "Um ímã gigante, um pulso de rádio e o hidrogênio do seu corpo: como o aparelho escuta um eco fraquinho e o transforma em imagem.", + "tagline": "Um ímã gigante, um pulso de rádio e o hidrogênio do seu corpo: como o aparelho escuta um eco bem fraco e o transforma em imagem.", "description": "Ressonância magnética em 3D: o ímã que nunca desliga, o hidrogênio que responde a um pulso de rádio, as bobinas que batem e os ecos que viram imagem.", "summary": "O ímã supercondutor, o hidrogênio que se inclina, o pulso de rádio e seu eco, as bobinas de gradiente e o espaço k.", "socialAlt": "Um aparelho de ressonância magnética em corte, com as camadas do ímã umas dentro das outras em volta do túnel. Ao lado, a imagem de um corte da cabeça e o título “Como funciona um aparelho de ressonância magnética”" @@ -20,16 +20,16 @@ "speedValue": "{{value}}/min", "speedFormat": "{{value}} repetições por minuto", "phase": { - "excite": "Pulso de rádio", + "excite": "Pulso de 90°", "encode": "Codificação", - "refocus": "Refocalização", + "refocus": "Pulso de 180°", "echo": "Eco", "recover": "Recuperação" }, "jump": { - "excite": "Ir para o pulso de rádio", + "excite": "Ir para o pulso de 90°", "encode": "Ir para a codificação", - "refocus": "Ir para o pulso de refocalização", + "refocus": "Ir para o pulso de 180°", "echo": "Ir para o eco", "recover": "Ir para a recuperação" } @@ -97,7 +97,7 @@ } }, "readouts": { - "larmor": "Frequência de precessão", + "larmor": "Frequência de Larmor", "time": "Tempo na repetição", "lines": "Linhas do espaço k" }, @@ -129,7 +129,7 @@ "fringeValue": "{{along}} m ao longo do túnel, {{side}} m para o lado" }, "spins": { - "larmor": "Frequência da oscilação", + "larmor": "Frequência de Larmor", "larmorBand": "{{value}}, {{band}}", "band": { "belowFm": "logo abaixo do rádio FM", @@ -152,31 +152,31 @@ "gradients": { "edge": "Variação na borda da cabeça", "share": "Fração do campo principal", - "spread": "Faixa de frequências na cabeça", + "spread": "Desvio de frequência na borda da cabeça", "rise": "Tempo para ligar", "role": "Função neste exame", "roleValue": { "x": "Frequência: da esquerda para a direita", - "y": "Fase: uma torção por linha", + "y": "Fase: uma torção nova a cada repetição", "z": "Fatia: escolhe uma camada fina", - "sequence": "Segue a sequência" + "sequence": "Muda a cada etapa" } }, "picture": { "kspace": "Espaço k, os ecos guardados", "image": "A imagem", - "scanTime": "Uma linha por repetição, 256 linhas", + "scanTime": "Uma linha por repetição", "scanTimeValue": "{{minutes}} min para {{lines}} linhas", "turbo": "Turbo spin eco, 16 linhas por repetição", "turboValue": "cerca de {{seconds}} s", - "empty": "Aperte o play para preencher a primeira linha", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "Ainda não há linhas. O próximo eco preenche a primeira.", + "kspaceAlt": "A grade do espaço k, com {{lines}} de {{total}} linhas preenchidas", + "imageAlt": "O corte da cabeça, reconstruído a partir de {{lines}} de {{total}} linhas" } }, "parts": { "room": "Sala revestida de cobre", - "cover": "Cobertura", + "cover": "Carcaça", "vacuumVessel": "Câmara de vácuo", "radiationShield": "Escudo térmico", "heliumVessel": "Reservatório de hélio", @@ -207,44 +207,44 @@ "overview": { "title": "Um túnel que escuta o seu corpo", "p1": "Este é um aparelho de ressonância magnética com um ímã de 1,5 tesla. O tesla é a unidade que mede a força de um campo magnético. Você se deita na mesa, e ela desliza com você para dentro do túnel, que tem cerca de 70 cm de largura. A máquina em volta mede cerca de 1,6 a 1,9 m de comprimento e pesa de 3 a 6 toneladas.", - "p2": "Num exame do cérebro, uma bobina de cabeça envolve sua cabeça como uma gaiola. Ela é uma antena com 20 a 64 pequenos receptores, que escutam um eco de rádio fraquinho vindo do seu corpo. A sala é revestida com chapas de cobre para barrar as ondas de rádio de fora, que poderia abafar esse eco. Aqui não há raios X. Uma onda de rádio nessa frequência carrega cerca de 50 milhões de vezes menos energia do que seria preciso para arrancar um elétron de um átomo.", - "p3": "Sob a cobertura branca, as camadas ficam umas dentro das outras, como anéis: o ímã na sua capa fria, três bobinas de gradiente e uma antena de rádio chamada bobina de corpo. Ligue a vista em corte (C) para vê-las. Os próximos capítulos mostram uma de cada vez.", - "p4": "O modelo acompanha uma repetição, o ciclo curto que o exame repete muitas vezes para montar uma imagem. Neste modelo, uma repetição dura de 0,5 a 2,5 s de tempo real, mas os pulsos de rádio dentro dela levam só milissegundos. Por isso a linha do tempo é esticada: os pulsos ocupam a maior parte do ciclo, e o longo descanso fica espremido. A linha do tempo mostra o nome da etapa, e as leituras ao vivo mostram o tempo real. Um exame completo da cabeça, com muitas imagens, leva cerca de 30 a 45 minutos." + "p2": "Num exame do cérebro, uma bobina de cabeça envolve sua cabeça como uma gaiola. Ela é uma antena com 20 a 64 pequenos receptores, que escutam um eco de rádio bem fraco vindo do seu corpo. A sala é revestida com chapas de cobre. Elas barram as ondas de rádio de fora, que poderiam abafar esse eco. Aqui não há raios X. As ondas de rádio do aparelho carregam cerca de 50 milhões de vezes menos energia do que seria preciso para arrancar um elétron de um átomo.", + "p3": "Sob a carcaça branca, as camadas ficam umas dentro das outras, como anéis. De fora para dentro, você encontra o ímã na sua capa fria, três bobinas de gradiente e a bobina de corpo, uma antena de rádio. Ligue a vista em corte (C) para vê-las. Os próximos capítulos mostram uma de cada vez.", + "p4": "O modelo acompanha uma repetição, o ciclo curto que o exame repete muitas vezes para montar uma imagem. Neste modelo, uma repetição dura de 0,5 a 2,5 s de tempo real, mas os pulsos de rádio dentro dela levam só milissegundos. Por isso a linha do tempo não está em escala. Os poucos milissegundos de pulsos e eco ocupam cerca de dois terços dela. O longo descanso fica espremido no último terço. A linha do tempo mostra o nome da etapa, e as leituras ao vivo mostram o tempo real. Um exame completo da cabeça, com muitas imagens, leva cerca de 30 a 45 minutos." }, "magnet": { "title": "Um ímã que nunca desliga", - "p1": "A maior peça é o ímã principal: cerca de 30 a 40 km de fio de nióbio-titânio enrolado em bobinas. Quando é resfriado o bastante, esse fio fica supercondutor, ou seja, a corrente passa por ele sem nenhuma resistência. Várias centenas de ampères circulam sem parar num circuito fechado. Depois que o circuito é fechado, manter o campo não gasta energia. Em um ano, ele perde menos de um décimo de um por cento da força.", - "p2": "Para continuar supercondutoras, as bobinas ficam mergulhadas em hélio líquido a 4,2 K, cerca de −269 °C. Isso é só cerca de 1,5 K mais quente que o espaço profundo. Um ímã clássico guarda cerca de 1.500 litros de hélio, enquanto os ímãs selados mais novos precisam de apenas 7. Um escudo térmico e um vácuo em volta do reservatório de hélio funcionam como uma garrafa térmica. Uma pequena geladeira no topo, o cabeçote de refrigeração, transforma o hélio que ferveu de volta em líquido. Ela funciona dia e noite e consome cerca de 6 a 7 kW.", - "p3": "O campo é dezenas de milhares de vezes mais forte que o da Terra, que varia de um lugar para outro. Escolha 3 T para comparar com um ímã mais forte. Bobinas de blindagem extras, do lado de fora, fazem a corrente circular no sentido contrário e puxam para perto o campo que escapa. Mesmo assim, a linha de 0,5 mT, dentro da qual um marca-passo pode deixar de ser seguro, chega a cerca de 4 m ao longo do túnel. Ligue as linhas de campo para vê-la.", - "p4": "Como o ímã nunca desliga, um aviso luminoso na porta lembra disso. Qualquer coisa com ferro pode voar para dentro do túnel, e cilindros de oxigênio já mataram pessoas desse jeito. É por isso que a equipe confere todo mundo e tudo o que entra pela porta. Se o fio esquenta em algum ponto, ele deixa de ser supercondutor e a corrente vira calor: é o quench. O campo cai a zero em 2 a 5 segundos, e o hélio fervendo sai rugindo do prédio pelo tubo de quench." + "p1": "A maior peça é o ímã principal: cerca de 30 a 40 km de fio de nióbio-titânio enrolado em bobinas. Quando é resfriado o bastante, esse fio fica supercondutor, ou seja, a corrente passa por ele sem nenhuma resistência. Várias centenas de ampères circulam sem parar num circuito fechado. Depois que o circuito é fechado, manter o campo não gasta energia. Ele perde menos de 0,1 % da força por ano.", + "p2": "Para continuar supercondutoras, as bobinas ficam mergulhadas em hélio líquido a 4,2 kelvin (K), cerca de −269 °C. Isso é só cerca de 1,5 K mais quente que o espaço profundo. Um ímã clássico guarda cerca de 1.500 litros de hélio, enquanto os ímãs selados mais novos precisam de 7 litros ou até menos. Um escudo térmico e um vácuo em volta do reservatório de hélio funcionam como uma garrafa térmica. Uma pequena geladeira no topo, o cabeçote de refrigeração, volta a transformar em líquido o hélio que evapora. Ela funciona dia e noite e consome cerca de 6 a 7 kW.", + "p3": "O campo é dezenas de milhares de vezes mais forte que o da Terra. O próprio campo da Terra varia de um lugar para outro. Escolha 3 T para comparar com um ímã mais forte. Bobinas de blindagem extras, do lado de fora, levam corrente no sentido contrário e puxam de volta o campo que escapa. Mesmo assim, o campo fica acima de 0,5 mT, meio milésimo de tesla, até cerca de 4 m ao longo do túnel. Dentro desse limite, um marca-passo pode deixar de funcionar com segurança. O anel tracejado no chão mostra onde ele fica.", + "p4": "Como o ímã nunca desliga, um aviso luminoso na porta lembra disso. Qualquer coisa com ferro pode voar para dentro do túnel, e cilindros de oxigênio já mataram pessoas desse jeito. É por isso que a equipe confere todo mundo e tudo o que entra pela porta. Se o fio esquenta em algum ponto, ele deixa de ser supercondutor e a corrente vira calor. Isso se chama quench. O campo cai a zero em 2 a 5 segundos. O hélio fervendo sai rugindo do prédio pelo tubo de quench." }, "spins": { "title": "O hidrogênio se inclina e oscila", "p1": "Cerca de seis em cada dez átomos do seu corpo são de hidrogênio, a maioria na água e na gordura. O centro de um átomo de hidrogênio, o seu núcleo, é um único próton. Ele se comporta como um ímã minúsculo. A ressonância magnética escuta o hidrogênio porque ele está em toda parte e dá o sinal mais forte.", - "p2": "Dentro do campo, cada um desses ímãs minúsculos não fica simplesmente apontado na direção dele. Ele faz precessão: oscila em volta dessa direção, como um pião inclinado que gira. A 1,5 T, ele oscila cerca de 64 milhões de vezes por segundo, ou 63,9 MHz. Esse ritmo é a frequência de Larmor, e ela cresce com o campo, chegando a 127,7 MHz a 3 T. As duas ficam perto do rádio FM, uma logo abaixo da faixa e a outra logo acima dela.", - "p3": "Os núcleos não se alinham todos. O calor do corpo os sacode, e eles apontam para quase todos os lados. Os que se inclinam a favor do campo superam os que se inclinam contra ele por só cerca de 5 em um milhão. Mesmo assim, esse pequeno excedente soma cerca de 3 × 10¹⁴ núcleos em um milímetro cúbico de água. Juntos, eles formam um minúsculo ímã resultante, a seta com que o aparelho trabalha.", - "p4": "As setas no quadro em destaque são um desenho, ampliado muitas vezes, e não um ponto real da cabeça. Elas também mostram a inclinação bem mais forte do que ela é, para que você consiga vê-la. A oscilação desenhada dá meia volta por segundo, cerca de 128 milhões de vezes mais devagar que a real a 1,5 T. Mude para 3 T e veja a frequência e o excedente dobrarem." + "p2": "Dentro do campo, cada um desses ímãs minúsculos não fica simplesmente apontado na direção dele. Ele faz precessão: oscila em volta dessa direção, como um pião inclinado que gira. A 1,5 T, ele oscila cerca de 64 milhões de vezes por segundo, ou 63,9 MHz. Esse ritmo, o tom da oscilação, é a frequência de Larmor. Ela cresce com o campo e chega a 127,7 MHz a 3 T. As duas ficam perto do rádio FM, uma logo abaixo da faixa e a outra logo acima dela.", + "p3": "Os núcleos não se alinham todos. O calor do corpo os sacode, e eles apontam para quase todos os lados. A cada milhão, só cerca de 5 a mais se inclinam a favor do campo do que contra ele. Mesmo assim, esse pequeno excedente soma cerca de 3,3 × 10¹⁴ núcleos em um milímetro cúbico de água. Juntos, eles formam um minúsculo ímã resultante, a seta com que o aparelho trabalha.", + "p4": "As setas no quadro em destaque são um desenho, ampliado muitas vezes, e não um ponto real da cabeça. Elas também exageram muito a inclinação, para você conseguir vê-la. Aperte o play para ver as setas girarem. O desenho está muitíssimo mais lento que a realidade, e a leitura mostra quantas vezes. Esse número também muda com a velocidade de reprodução. Mude para 3 T: a frequência real e o excedente dobram, mas o desenho mantém o mesmo ritmo." }, "resonance": { "title": "Um pulso de rádio e seu eco", "p1": "Para fazer o ímã resultante falar, o aparelho envia um pulso de rádio curto exatamente na frequência de Larmor. Acompanhar a oscilação desse jeito se chama ressonância, como empurrar uma criança no balanço bem na hora certa. O pulso sai da bobina de corpo, uma gaiola de barras de cobre logo por dentro do túnel. Ele dura no máximo alguns milissegundos.", - "p2": "Um pulso de 90° tomba o ímã resultante até ele ficar deitado de lado. Ali ele continua oscilando e, ao passar pela bobina de cabeça, gera uma tensão minúscula, de alguns microvolts. Esse é o sinal. Mexa no controle do ângulo de inclinação: só a parte lateral gera sinal, e a parte ao longo do campo não gera nenhum.", - "p3": "Os núcleos logo perdem o compasso, porque alguns oscilam um pouco mais rápido que outros, e o sinal se apaga. Na metade do caminho até o eco, um pulso de 180° inverte todos eles. Assim, os mais rápidos ficam para trás e alcançam os outros. Eles voltam ao mesmo compasso e geram um eco no tempo de eco. Use os botões para parar em cada momento.", - "p4": "Dois relógios decidem o brilho de cada tecido. T1 é o tempo que o ímã resultante leva para voltar cerca de 63 % do caminho ao longo do campo. T2 é o tempo que o sinal lateral leva para cair a cerca de 37 %, à medida que os núcleos perdem o compasso. A gordura se recupera rápido, e o líquido cerebral, devagar: escolha um tecido para comparar. Os pulsos também aquecem você um pouco, então o aparelho mantém esse aquecimento dentro de limites seguros." + "p2": "Um pulso de 90° tomba o ímã resultante até ele ficar deitado de lado. Ali ele continua oscilando. Ao passar pela bobina de cabeça, ele gera uma tensão minúscula, medida em microvolts. Esse é o sinal. Mexa no controle do ângulo de inclinação: só a parte lateral gera sinal, e a parte ao longo do campo não gera nenhum.", + "p3": "Os núcleos logo perdem o compasso, porque alguns oscilam um pouco mais rápido que outros. O sinal se apaga. Na metade do caminho até o eco, um pulso de 180° inverte todos eles. Assim, os mais rápidos ficam para trás e alcançam os outros. Eles voltam ao mesmo compasso e geram um eco. O tempo entre o pulso de 90° e o eco é o tempo de eco. Use os botões abaixo para parar em cada momento.", + "p4": "Dois relógios decidem o brilho de cada tecido. T1 é o tempo que o ímã resultante leva para recuperar cerca de 63 % do seu valor ao longo do campo. T2 é o tempo que o sinal lateral leva para cair a cerca de 37 %. A inversão de 180° só desfaz as diferenças que não mudam. Já os pequenos empurrões aleatórios entre núcleos vizinhos continuam, e por isso o eco sai um pouco mais fraco. Essa perda é o T2. A gordura se recupera rápido, e o líquido cerebral, devagar. Escolha um tecido para comparar. Os valores de cada tecido são aproximados e foram escolhidos para este modelo. Os pulsos também aquecem você um pouco, então o aparelho mantém esse aquecimento dentro de limites seguros." }, "gradients": { "title": "Bobinas que distinguem os lugares", - "p1": "Até aqui, todo ponto da cabeça responde no mesmo tom, então o eco não tem como dizer de onde veio. Mais três bobinas dentro do ímã resolvem isso: as bobinas de gradiente x, y e z. Um gradiente é um campo que muda aos poucos, por igual, de um lado para o outro. Cada bobina soma um pouco ao campo principal de um lado do centro e tira um pouco do outro lado.", - "p2": "A mudança é pequena. Com até 45 mT por metro, o campo na borda da cabeça muda cerca de 5,4 mT, um terço de um por cento do campo principal. Isso basta para dar a cada posição a sua própria frequência. As bobinas ligam em cerca de 0,2 ms.", - "p3": "Cada bobina tem a sua função. A bobina z trabalha junto com o pulso de rádio para despertar só uma fatia fina: é a seleção de fatia. A bobina y dá a cada repetição uma torção diferente, de cima para baixo: é a codificação de fase. A bobina x funciona enquanto o eco é escutado, e assim o tom do eco separa a esquerda da direita: é a codificação de frequência. Escolha uma bobina para ver o campo dela ou siga o exame.", + "p1": "Até aqui, todo ponto da cabeça responde no mesmo tom, então o eco não tem como dizer de onde veio. Mais três bobinas dentro do ímã resolvem isso. São as bobinas de gradiente x, y e z. Um gradiente é um campo que muda aos poucos, por igual, de um lado para o outro. Cada bobina soma um pouco ao campo principal de um lado do centro. Do outro lado, ela tira um pouco.", + "p2": "A mudança é pequena. Cada bobina muda o campo em até 45 mT por metro. Na borda da cabeça, isso chega a cerca de 5,4 mT. É um terço de 1 % de um campo de 1,5 T. Mesmo assim, basta para dar a cada posição a sua própria frequência. As bobinas ligam em cerca de 0,2 ms.", + "p3": "Cada bobina tem a sua função. A bobina z trabalha junto com o pulso de rádio para despertar só uma fatia fina: é a seleção de fatia. A bobina y dá a cada repetição uma torção diferente, de cima para baixo: é a codificação de fase. A bobina x fica ligada enquanto o aparelho escuta o eco. Assim, o tom do eco separa a esquerda da direita: é a codificação de frequência. Escolha uma bobina para ver o campo dela ou siga o exame.", "p4": "Ligar e desligar correntes fortes dentro de um ímã potente faz as bobinas darem trancos, como um alto-falante. São essas as batidas altas que você ouve durante o exame. O barulho pode passar de 100 decibéis, mais ou menos como uma britadeira, ou até mais. Por isso você sempre recebe protetores de ouvido ou fones." }, "picture": { "title": "Dos ecos à imagem", - "p1": "Cada eco é guardado como uma linha do espaço k, uma grade que organiza os ecos por tom e por torção, e não por lugar. Uma repetição preenche uma linha; a seguinte muda a torção e preenche outra. Neste modelo, a imagem tem 64 × 64, então são necessárias 64 repetições. Exames reais da cabeça costumam usar 256 linhas. Aqui as linhas são preenchidas do meio para fora, por isso uma imagem borrada já aparece cedo.", - "p2": "Uma etapa de matemática chamada transformada de Fourier converte essa grade na imagem. Ela descobre quais listras, largas e finas, formam a imagem quando somadas. O meio do espaço k guarda as listras largas: o claro e o escuro gerais da imagem. As bordas guardam os detalhes finos. Experimente 8, 16, 32 ou 64 linhas e veja a imagem ficar mais nítida.", - "p3": "Os tempos escolhidos decidem quais tecidos aparecem claros. Um exame ponderado em T1 se repete a cada 500 ms e escuta cedo, aos 15 ms. A repetição termina antes que os tecidos lentos tenham voltado a crescer, então a gordura fica clara e o líquido cerebral, escuro. Um exame ponderado em T2 espera 2.500 ms e escuta aos 100 ms: agora o líquido, que demora a se apagar, brilha, e a substância cinzenta fica mais clara que a branca. Neste modelo, todo tecido tem a mesma quantidade de hidrogênio, e osso e ar ficam pretos.", - "p4": "Com uma linha por repetição, 256 linhas levam cerca de 2,1 minutos no exame T1 e 10,7 no exame T2. Aparelhos reais usam turbo spin eco, que lê de 4 a 32 linhas em cada repetição. Com 16 linhas, o exame T2 leva cerca de 40 segundos. A ideia é recente. Paul Lauterbur publicou a primeira imagem por ressonância magnética em 1973, e ele e Peter Mansfield dividiram o Prêmio Nobel de 2003 por isso." + "p1": "Cada eco é guardado como uma linha do espaço k. É uma grade que organiza os ecos por tom e por torção, e não por lugar. Uma repetição preenche uma linha. A seguinte muda a torção e preenche outra. Neste modelo, a imagem tem 64 × 64, então são necessárias 64 repetições. Exames reais da cabeça costumam usar 256 linhas. Aqui as linhas são preenchidas do meio para fora, por isso uma imagem borrada já aparece cedo.", + "p2": "Um cálculo chamado transformada de Fourier converte essa grade na imagem. Ela descobre quais listras, largas e finas, formam a imagem quando somadas. O meio do espaço k guarda as listras largas: o claro e o escuro gerais da imagem. As bordas guardam os detalhes finos. Experimente 8, 16, 32 ou 64 linhas e veja a imagem ficar mais nítida.", + "p3": "Os tempos escolhidos decidem quais tecidos aparecem claros. Um exame ponderado em T1 se repete a cada 500 ms e escuta cedo, aos 15 ms. A repetição termina antes que os tecidos lentos tenham voltado a crescer, então a gordura fica clara e o líquido cerebral, escuro. Um exame ponderado em T2 espera 2.500 ms e escuta aos 100 ms. Agora brilha o líquido, cujo sinal some devagar. A substância cinzenta parece mais clara que a branca. As leituras mostram o brilho de cada tecido, com o mais claro em 100 %. A cabeça aqui é um desenho simples feito de ovais, e não um exame real. Neste modelo, todo tecido tem a mesma quantidade de hidrogênio, e osso e ar ficam pretos.", + "p4": "Com uma linha por repetição, 256 linhas levam cerca de 2,1 minutos no exame T1 e 10,7 no exame T2. A maioria dos exames do cérebro hoje usa turbo spin eco, que lê de 4 a 32 linhas em cada repetição. Com 16 linhas, o exame T2 leva cerca de 40 segundos. Este modelo mostra uma só fatia. Um exame real da cabeça faz muitas. Paul Lauterbur publicou a primeira imagem por ressonância magnética em 1973. Ele e Peter Mansfield dividiram o Prêmio Nobel de 2003 por isso." } } } From 71162161af6d08cdc4c0b78106b0eca694d445ba Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:43:29 +0200 Subject: [PATCH 40/82] fix(mri-scanner): apply the review to the German copy --- explainers/mri-scanner/locales/de.json | 66 +++++++++++++------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json index f0c0e03d..b2acdcdd 100644 --- a/explainers/mri-scanner/locales/de.json +++ b/explainers/mri-scanner/locales/de.json @@ -4,7 +4,7 @@ "eyebrow": "Medizinische Bildgebung", "tagline": "Ein riesiger Magnet, ein Funkimpuls und der Wasserstoff in Ihrem Körper: So hört das Gerät ein leises Echo und macht daraus ein Bild.", "description": "Ein MRT in 3D: der Magnet, der nie abschaltet, wie Wasserstoff auf einen Funkimpuls antwortet, die klopfenden Spulen und wie aus Echos ein Bild wird.", - "summary": "Der supraleitende Magnet, der sich neigende Wasserstoff, der Funkimpuls und sein Echo, die Gradientenspulen und der k-Raum.", + "summary": "Der supraleitende Magnet, wie sich Wasserstoff neigt, der Funkimpuls und sein Echo, die Gradientenspulen und der k-Raum.", "socialAlt": "Ein aufgeschnittenes MRT-Gerät mit ineinanderliegenden Magnetschichten rund um den Tunnel, daneben ein Schnittbild eines Kopfes und der Titel „So funktioniert ein MRT-Gerät“" }, "stage": { @@ -20,16 +20,16 @@ "speedValue": "{{value}}/min", "speedFormat": "{{value}} Wiederholungen pro Minute", "phase": { - "excite": "Funkimpuls", + "excite": "90°-Impuls", "encode": "Kodierung", - "refocus": "Refokussierung", + "refocus": "180°-Impuls", "echo": "Echo", "recover": "Erholung" }, "jump": { - "excite": "Zum Funkimpuls springen", + "excite": "Zum 90°-Impuls springen", "encode": "Zur Kodierung springen", - "refocus": "Zum Refokussierungsimpuls springen", + "refocus": "Zum 180°-Impuls springen", "echo": "Zum Echo springen", "recover": "Zur Erholung springen" } @@ -97,7 +97,7 @@ } }, "readouts": { - "larmor": "Spinfrequenz", + "larmor": "Larmor-Frequenz", "time": "Zeit in der Wiederholung", "lines": "Zeilen im k-Raum" }, @@ -129,14 +129,14 @@ "fringeValue": "{{along}} m entlang der Röhre, {{side}} m zur Seite" }, "spins": { - "larmor": "Taumelfrequenz", + "larmor": "Larmor-Frequenz", "larmorBand": "{{value}}, {{band}}", "band": { "belowFm": "knapp unter dem UKW-Band", "aboveFm": "knapp über dem UKW-Band" }, "surplus": "Zusätzliche Kerne, die sich mit dem Feld neigen", - "surplusValue": "etwa {{perMillion}} von einer Million", + "surplusValue": "etwa {{perMillion}} pro Million", "count": "Überschuss in 1 mm³ Wasser", "countValue": "etwa {{mantissa}} × 10{{exponent}}", "slowdown": "Zeichnung verlangsamt", @@ -152,26 +152,26 @@ "gradients": { "edge": "Änderung am Rand des Kopfes", "share": "Anteil am Hauptfeld", - "spread": "Frequenzspanne über den Kopf", + "spread": "Frequenzverschiebung am Rand des Kopfes", "rise": "Einschaltzeit", "role": "Aufgabe in diesem Scan", "roleValue": { "x": "Frequenz: von links nach rechts", - "y": "Phase: eine Verdrehung pro Zeile", + "y": "Phase: eine neue Verdrehung pro Wiederholung", "z": "Schicht: wählt eine dünne Scheibe", - "sequence": "Folgt dem Ablauf" + "sequence": "Ändert sich mit jedem Schritt" } }, "picture": { "kspace": "k-Raum, die gespeicherten Echos", "image": "Das Bild", - "scanTime": "Eine Zeile pro Wiederholung, 256 Zeilen", + "scanTime": "Eine Zeile pro Wiederholung", "scanTimeValue": "{{minutes}} min für {{lines}} Zeilen", "turbo": "Turbo-Spin-Echo, 16 Zeilen pro Wiederholung", "turboValue": "etwa {{seconds}} s", - "empty": "Drücken Sie auf Wiedergabe, um die erste Zeile zu füllen", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "Noch keine Zeilen. Das nächste Echo füllt die erste.", + "kspaceAlt": "Das Raster des k-Raums, {{lines}} von {{total}} Zeilen sind gefüllt", + "imageAlt": "Das Schnittbild des Kopfes, berechnet aus {{lines}} von {{total}} Zeilen" } }, "parts": { @@ -206,45 +206,45 @@ "sections": { "overview": { "title": "Eine Röhre, die Ihrem Körper zuhört", - "p1": "Das ist ein MRT-Gerät, auch Kernspintomograf genannt, mit einem Magneten von 1,5 Tesla. In Tesla misst man, wie stark ein Magnetfeld ist. Sie legen sich auf die Liege, und diese fährt Sie in die Röhre, einen Tunnel von etwa 70 cm Breite. Das Gerät drumherum ist etwa 1,6 bis 1,9 m lang und wiegt 3 bis 6 Tonnen.", - "p2": "Für eine Untersuchung des Gehirns sitzt eine Kopfspule wie ein Käfig um Ihren Kopf. Sie ist eine Antenne mit 20 bis 64 kleinen Empfängern, die auf ein schwaches Funkecho aus Ihrem Körper lauschen. Der Raum ist mit Kupferblech ausgekleidet. So bleiben Funksignale von außen draußen und können dieses Echo nicht übertönen. Röntgenstrahlen gibt es hier keine. Eine Funkwelle dieser Frequenz hat etwa 50 Millionen Mal zu wenig Energie, um ein Elektron aus einem Atom zu schlagen.", - "p3": "Unter der weißen Verkleidung stecken Schichten ineinander wie Ringe: der Magnet in seinem kalten Mantel, drei Gradientenspulen und eine Funkantenne, die Körperspule. Schalten Sie die Schnittansicht (C) ein, um sie zu sehen. Die nächsten Kapitel nehmen sie sich einzeln vor.", - "p4": "Das Modell begleitet eine Wiederholung: den kurzen Ablauf, den ein Scan immer wieder durchläuft, um ein Bild aufzubauen. In diesem Modell dauert eine Wiederholung in Wirklichkeit 0,5 bis 2,5 s, die Funkimpulse darin aber nur Millisekunden. Deshalb ist die Zeitleiste gedehnt: Die Impulse füllen den größten Teil der Schleife, die lange Pause ist gestaucht. Die Zeitleiste nennt den Schritt, die Live-Werte zeigen die echte Zeit. Eine ganze Untersuchung des Kopfes mit vielen Bildern dauert etwa 30 bis 45 Minuten." + "p1": "Das ist ein MRT-Gerät mit einem Magneten von 1,5 Tesla. Man nennt es auch Kernspintomograf. MRT steht für Magnetresonanztomografie. In Tesla misst man, wie stark ein Magnetfeld ist. Sie legen sich auf die Liege, die Sie in die Röhre fährt, einen Tunnel von etwa 70 cm Breite. Das Gerät drumherum ist etwa 1,6 bis 1,9 m lang und wiegt 3 bis 6 Tonnen.", + "p2": "Für eine Untersuchung des Gehirns sitzt eine Kopfspule wie ein Käfig um Ihren Kopf. Die Spule ist eine Antenne mit 20 bis 64 kleinen Empfängern, die auf ein schwaches Funkecho aus Ihrem Körper lauschen. Der Raum ist mit Kupferblech ausgekleidet. So bleiben Funkwellen von außen draußen und können dieses Echo nicht übertönen. Röntgenstrahlen gibt es hier keine. Die Funkwellen des Geräts haben etwa 50 Millionen Mal zu wenig Energie, um ein Elektron aus einem Atom zu schlagen.", + "p3": "Unter der weißen Verkleidung stecken Schichten ineinander wie Ringe. Von außen nach innen finden Sie den Magneten in seinem kalten Mantel, drei Gradientenspulen und die Körperspule, eine Funkantenne. Schalten Sie die Schnittansicht (C) ein, um sie zu sehen. Die nächsten Kapitel nehmen sie sich einzeln vor.", + "p4": "Das Modell begleitet eine Wiederholung: den kurzen Ablauf, den ein Scan immer wieder durchläuft, um ein Bild aufzubauen. Eine Wiederholung dauert hier in echter Zeit 0,5 bis 2,5 s, die Funkimpulse darin aber nur Millisekunden. Deshalb ist die Zeitleiste nicht maßstabsgetreu. Die wenigen Millisekunden mit Impulsen und Echo füllen etwa zwei Drittel davon. Die lange Pause ist ins letzte Drittel gestaucht. Die Zeitleiste nennt den Schritt, die Live-Werte zeigen die echte Zeit. Eine ganze Untersuchung des Kopfes mit vielen Bildern dauert etwa 30 bis 45 Minuten." }, "magnet": { "title": "Ein Magnet, der nie abschaltet", "p1": "Das größte Bauteil ist der Hauptmagnet: etwa 30 bis 40 km Draht aus Niob-Titan, zu Spulen gewickelt. Kühlt man diesen Draht weit genug ab, wird er supraleitend. Das heißt: Strom fließt ganz ohne Widerstand hindurch. Mehrere hundert Ampere kreisen in einer geschlossenen Schleife. Ist die Schleife einmal geschlossen, braucht das Feld keine Stromzufuhr mehr. In einem Jahr wird es um weniger als ein Zehntel Prozent schwächer.", - "p2": "Damit die Spulen supraleitend bleiben, liegen sie in flüssigem Helium bei 4,2 K, etwa −269 °C. Das ist nur etwa 1,5 K wärmer als das tiefe Weltall. Ein klassischer Magnet fasst etwa 1.500 Liter davon, neuere versiegelte Magnete brauchen nur 7. Ein Wärmeschild und ein Vakuum um den Heliumbehälter wirken wie eine Thermoskanne. Eine kleine Kältemaschine obendrauf, der Kaltkopf, macht verdampftes Helium wieder flüssig. Er läuft Tag und Nacht und braucht etwa 6 bis 7 kW.", - "p3": "Das Feld ist Zehntausende Male so stark wie das Erdfeld, das von Ort zu Ort schwankt. Wählen Sie 3 T, um mit einem stärkeren Magneten zu vergleichen. Zusätzliche Abschirmspulen außen führen Strom in die Gegenrichtung und halten das Streufeld nah am Gerät. Trotzdem reicht die 0,5-mT-Linie etwa 4 m weit entlang der Röhre. Wer diese Linie überschreitet, ist mit einem Herzschrittmacher womöglich nicht mehr sicher. Schalten Sie die Feldlinien ein, um sie zu sehen.", - "p4": "Weil der Magnet nie abschaltet, warnt ein Leuchtschild an der Tür davor. Alles, was Eisen enthält, kann in die Röhre fliegen, und Sauerstoffflaschen haben auf diese Weise schon Menschen getötet. Deshalb kontrolliert das Personal an der Tür alle Menschen und alles, was hineinkommt. Erwärmt sich der Draht an einer Stelle, verliert er die Supraleitung, und der Strom wird zu Wärme: ein Quench. Das Feld fällt in 2 bis 5 Sekunden auf null, und das kochende Helium donnert durch das Quenchrohr aus dem Gebäude." + "p2": "Damit die Spulen supraleitend bleiben, liegen sie in flüssigem Helium bei 4,2 Kelvin (K), etwa −269 °C. Das ist nur etwa 1,5 K wärmer als das tiefe Weltall. Ein klassischer Magnet fasst etwa 1.500 Liter davon. Neuere versiegelte Magnete brauchen 7 Liter oder sogar weniger. Ein Wärmeschild und ein Vakuum um den Heliumbehälter wirken wie eine Thermoskanne. Eine kleine Kältemaschine obendrauf, der Kaltkopf, macht verdampftes Helium wieder flüssig. Er läuft Tag und Nacht und braucht etwa 6 bis 7 kW.", + "p3": "Das Feld ist mehrere zehntausend Mal stärker als das Erdfeld. Das Erdfeld selbst ist nicht überall gleich stark. Wählen Sie 3 T, um mit einem stärkeren Magneten zu vergleichen. Zusätzliche Abschirmspulen außen führen Strom in die Gegenrichtung und holen so das Feld zurück, das nach außen dringt. Trotzdem bleibt das Feld bis etwa 4 m entlang der Röhre über 0,5 mT, einem halben Tausendstel Tesla. Innerhalb dieser 0,5-mT-Linie arbeitet ein Herzschrittmacher womöglich nicht mehr sicher. Der gestrichelte Ring auf dem Boden zeigt sie.", + "p4": "Weil der Magnet nie abschaltet, warnt ein Leuchtschild an der Tür davor. Alles, was Eisen enthält, kann in die Röhre fliegen. Sauerstoffflaschen haben auf diese Weise schon Menschen getötet. Deshalb kontrolliert das Personal an der Tür jeden Menschen und jeden Gegenstand. Erwärmt sich der Draht an einer Stelle, verliert er die Supraleitung, und der Strom wird zu Wärme. Das ist ein Quench. Das Feld fällt in 2 bis 5 Sekunden auf null. Das verdampfende Helium schießt donnernd durch das Quenchrohr ins Freie." }, "spins": { "title": "Wasserstoff neigt sich und taumelt", "p1": "Etwa sechs von zehn Atomen in Ihrem Körper sind Wasserstoff, vor allem in Wasser und Fett. Das Zentrum eines Wasserstoffatoms, sein Kern, ist ein einzelnes Proton. Es verhält sich wie ein winziger Magnet. Die MRT lauscht auf Wasserstoff, weil er überall vorkommt und das stärkste Signal liefert.", - "p2": "Im Feld zeigt jeder dieser winzigen Magnete nicht einfach in Feldrichtung. Er präzediert: Er taumelt um die Feldrichtung wie ein schräg stehender Kreisel. Bei 1,5 T taumelt er etwa 64 Millionen Mal pro Sekunde, also mit 63,9 MHz. Dieser Takt heißt Larmor-Frequenz, und er wächst mit dem Feld, auf 127,7 MHz bei 3 T. Beide liegen nah am UKW-Radio, die eine knapp unterhalb seines Bandes, die andere knapp oberhalb.", - "p3": "Die Kerne richten sich nicht alle aus. Die Körperwärme schubst sie herum, deshalb zeigen sie in fast alle Richtungen. Auf eine Million Kerne neigen sich nur etwa 5 mehr mit dem Feld als dagegen. Dieser kleine Überschuss ergibt in einem Kubikmillimeter Wasser trotzdem etwa 3 × 10¹⁴ Kerne. Zusammen bilden sie einen winzigen Gesamtmagneten, den Pfeil, mit dem das Gerät arbeitet.", - "p4": "Die Pfeile im kleinen Fenster sind eine Zeichnung, vielfach vergrößert, keine echte Stelle im Kopf. Sie zeigen die Neigung auch viel stärker, als sie ist, damit Sie sie erkennen. Das gezeichnete Taumeln schafft eine halbe Drehung pro Sekunde, etwa 128 Millionen Mal langsamer als das echte bei 1,5 T. Schalten Sie auf 3 T und sehen Sie, wie sich Frequenz und Überschuss verdoppeln." + "p2": "Im Feld zeigt jeder dieser winzigen Magnete nicht einfach in Feldrichtung. Er präzediert: Er taumelt um die Feldrichtung wie ein schräg stehender Kreisel. Bei 1,5 T taumelt er etwa 64 Millionen Mal pro Sekunde, also mit 63,9 MHz. Dieser Takt, sozusagen die Tonhöhe des Taumelns, ist die Larmor-Frequenz. Sie wächst mit dem Feld, auf 127,7 MHz bei 3 T. Beide Frequenzen liegen nah am UKW-Band: die eine knapp darunter, die andere knapp darüber.", + "p3": "Die Kerne richten sich nicht alle aus. Die Körperwärme schubst sie herum, deshalb zeigen sie in fast alle Richtungen. Auf eine Million Kerne neigen sich nur etwa 5 mehr mit dem Feld als dagegen. Dieser kleine Überschuss ergibt in einem Kubikmillimeter Wasser trotzdem etwa 3,3 × 10¹⁴ Kerne. Zusammen bilden sie einen winzigen Gesamtmagneten, den Pfeil, mit dem das Gerät arbeitet.", + "p4": "Die Pfeile im kleinen Fenster sind eine Zeichnung, vielfach vergrößert, keine echte Stelle im Kopf. Auch die Neigung ist viel stärker gezeichnet, als sie wirklich ist, damit Sie sie erkennen. Drücken Sie auf Wiedergabe und sehen Sie zu, wie sich die Pfeile drehen. Die Zeichnung ist enorm verlangsamt, und der Messwert zeigt, um wie viel. Diese Zahl ändert sich auch mit dem Wiedergabetempo. Schalten Sie auf 3 T: Die echte Frequenz und der Überschuss verdoppeln sich, doch die Zeichnung behält ihr Tempo." }, "resonance": { "title": "Ein Funkimpuls und sein Echo", "p1": "Um den Gesamtmagneten zum Sprechen zu bringen, sendet das Gerät einen kurzen Funkimpuls genau mit der Larmor-Frequenz. Diesen Gleichtakt mit dem Taumeln nennt man Resonanz, so als würden Sie ein Kind auf der Schaukel genau im richtigen Moment anschubsen. Der Impuls kommt aus der Körperspule, einem Käfig aus Kupferstäben direkt innerhalb der Röhre. Er dauert höchstens ein paar Millisekunden.", - "p2": "Ein 90°-Impuls kippt den Gesamtmagneten um, bis er seitlich liegt. Dort taumelt er weiter. Wenn er dabei an der Kopfspule vorbeistreicht, erzeugt er eine winzige Spannung von wenigen Mikrovolt. Das ist das Signal. Bewegen Sie den Regler für den Kippwinkel: Nur der seitliche Anteil liefert Signal, der Anteil entlang des Feldes keins.", - "p3": "Bald geraten die Spins aus dem Takt, weil manche etwas schneller taumeln als andere, und das Signal verblasst. Auf halbem Weg zum Echo klappt ein 180°-Impuls sie um. Die schnellen liegen dann hinten und holen auf. So kommen die Spins wieder in den Takt und liefern ein Echo, und zwar zur Echozeit. Mit den Schaltflächen halten Sie an jedem dieser Zeitpunkte an.", - "p4": "Zwei Uhren bestimmen, wie hell ein Gewebe erscheint. T1 ist die Zeit, bis der Gesamtmagnet entlang des Feldes wieder zu etwa 63 % aufgebaut ist. T2 ist die Zeit, bis das seitliche Signal auf etwa 37 % abgeklungen ist, weil die Spins aus dem Takt geraten. Fett erholt sich schnell, Hirnwasser langsam. Wählen Sie ein Gewebe zum Vergleich. Die Impulse wärmen Sie auch leicht auf, deshalb hält das Gerät diese Erwärmung in sicheren Grenzen." + "p2": "Ein 90°-Impuls kippt den Gesamtmagneten um, bis er seitlich liegt. Dort taumelt er weiter. Wenn er dabei an der Kopfspule vorbeistreicht, erzeugt er eine winzige Spannung, gemessen in Mikrovolt. Das ist das Signal. Bewegen Sie den Regler für den Kippwinkel: Nur der seitliche Anteil liefert Signal, der Anteil entlang des Feldes keins.", + "p3": "Bald geraten die winzigen Magnete aus dem Takt, weil manche etwas schneller taumeln als andere. Das Signal klingt ab. Auf halbem Weg zum Echo klappt ein 180°-Impuls sie um. Die schnellen liegen dann hinten und holen auf. So kommen sie wieder in den Takt und liefern ein Echo. Die Zeit vom 90°-Impuls bis zum Echo ist die Echozeit. Halten Sie mit den Schaltflächen unten an jedem dieser Zeitpunkte an.", + "p4": "Zwei Uhren bestimmen, wie hell ein Gewebe erscheint. T1 ist die Zeit, bis der Gesamtmagnet entlang des Feldes wieder zu etwa 63 % aufgebaut ist. T2 ist die Zeit, bis das seitliche Signal auf etwa 37 % abgeklungen ist. Das Umklappen durch den 180°-Impuls gleicht nur gleichbleibende Unterschiede aus. Zufällige Stöße zwischen den winzigen Magneten bleiben, deshalb ist das Echo etwas schwächer. Dieser Verlust ist T2. Fett erholt sich schnell, Hirnwasser langsam. Wählen Sie ein Gewebe zum Vergleich. Die Werte der einzelnen Gewebe sind grobe Richtwerte für dieses Modell. Die Impulse wärmen Sie auch leicht auf, deshalb hält das Gerät diese Erwärmung in sicheren Grenzen." }, "gradients": { "title": "Spulen, die Orte unterscheiden", - "p1": "Bisher antwortet jede Stelle im Kopf mit derselben Frequenz, also verrät das Echo nicht, woher es kommt. Drei weitere Spulen im Magneten ändern das: die Gradientenspulen x, y und z. Ein Gradient ist ein Feld, das sich von einer Seite zur anderen gleichmäßig ändert. Jede Spule verstärkt das Hauptfeld auf einer Seite der Mitte ein wenig und schwächt es auf der anderen Seite ein wenig ab.", - "p2": "Die Änderung ist klein. Bei bis zu 45 mT pro Meter verschiebt sich das Feld am Rand des Kopfes um etwa 5,4 mT, ein Drittel Prozent des Hauptfeldes. Das genügt, damit jede Position ihre eigene Frequenz bekommt. Die Spulen schalten sich in etwa 0,2 ms ein.", + "p1": "Bisher antwortet jede Stelle im Kopf mit derselben Frequenz, also verrät das Echo nicht, woher es kommt. Drei weitere Spulen im Magneten ändern das: die Gradientenspulen x, y und z. Ein Gradient ist ein Feld, das sich von einer Seite zur anderen gleichmäßig ändert. Jede Spule verstärkt das Hauptfeld auf einer Seite der Mitte ein wenig. Auf der anderen Seite schwächt sie es ein wenig ab.", + "p2": "Die Änderung ist klein. Jede Spule ändert das Feld um bis zu 45 mT pro Meter. Am Rand des Kopfes ergibt das etwa 5,4 mT. Das ist ein Drittel Prozent eines Feldes von 1,5 T. Es reicht, damit jede Position ihre eigene Frequenz bekommt. Die Spulen schalten sich in etwa 0,2 ms ein.", "p3": "Jede Spule hat ihre eigene Aufgabe. Die z-Spule arbeitet mit dem Funkimpuls zusammen und weckt nur eine dünne Schicht: die Schichtauswahl. Die y-Spule gibt jeder Wiederholung eine andere Verdrehung von oben nach unten: die Phasenkodierung. Die x-Spule läuft, während das Gerät auf das Echo lauscht. So verrät die Frequenz, ob etwas links oder rechts liegt: die Frequenzkodierung. Wählen Sie eine Spule, um ihr Feld zu sehen, oder folgen Sie dem Scan.", "p4": "Schaltet das Gerät große Ströme in einem starken Magneten, zucken die Spulen wie ein Lautsprecher. Das ist das laute Klopfen, das Sie während eines Scans hören. Es kann über 100 Dezibel erreichen, etwa so laut wie ein Presslufthammer oder noch lauter. Deshalb bekommen Sie immer Ohrstöpsel oder Kopfhörer." }, "picture": { "title": "Von Echos zum Bild", "p1": "Jedes Echo wird als eine Zeile im k-Raum gespeichert. Das ist ein Raster, das die Echos nach Frequenz und Verdrehung ordnet statt nach Ort. Eine Wiederholung füllt eine Zeile. Die nächste ändert die Verdrehung und füllt eine weitere. In diesem Modell hat das Bild 64 × 64 Pixel, es braucht also 64 Wiederholungen. Echte Aufnahmen des Kopfes nutzen oft 256 Zeilen. Hier füllen sich die Zeilen von der Mitte nach außen, darum erscheint früh ein grobes Bild.", - "p2": "Ein Rechenschritt namens Fourier-Transformation macht aus diesem Raster das Bild. Er findet heraus, welche Mischung aus breiten und feinen Streifen zusammen das Bild ergibt. Die Mitte des k-Raums enthält die breiten Streifen, also das grobe Hell und Dunkel. Die Ränder enthalten die feinen Details. Probieren Sie 8, 16, 32 oder 64 Zeilen und sehen Sie, wie das Bild schärfer wird.", - "p3": "Das Timing entscheidet, welche Gewebe hell erscheinen. Ein T1-gewichteter Scan wiederholt sich alle 500 ms und lauscht früh, nach 15 ms. Die Wiederholung endet, bevor sich langsame Gewebe erholt haben, deshalb ist Fett hell und Hirnwasser dunkel. Ein T2-gewichteter Scan wartet 2.500 ms und lauscht nach 100 ms: Jetzt leuchtet das langsam verblassende Hirnwasser, und graue Substanz ist heller als weiße. In diesem Modell enthält jedes Gewebe gleich viel Wasserstoff, und Knochen und Luft bleiben schwarz.", - "p4": "Mit einer Zeile pro Wiederholung dauern 256 Zeilen etwa 2,1 Minuten beim T1-Scan und 10,7 beim T2-Scan. Echte Geräte arbeiten mit Turbo-Spin-Echo und lesen in jeder Wiederholung 4 bis 32 Zeilen. Mit 16 Zeilen dauert der T2-Scan etwa 40 Sekunden. Die Idee ist jung. Paul Lauterbur veröffentlichte 1973 das erste MR-Bild, und 2003 teilte er sich dafür mit Peter Mansfield den Nobelpreis." + "p2": "Eine Rechnung namens Fourier-Transformation macht aus diesem Raster das Bild. Sie findet heraus, welche Mischung aus breiten und feinen Streifen zusammen das Bild ergibt. Die Mitte des k-Raums enthält die breiten Streifen, also das grobe Hell und Dunkel. Die Ränder enthalten die feinen Details. Probieren Sie 8, 16, 32 oder 64 Zeilen und sehen Sie, wie das Bild schärfer wird.", + "p3": "Das Timing entscheidet, welche Gewebe hell erscheinen. Ein T1-gewichteter Scan wiederholt sich alle 500 ms und lauscht früh, nach 15 ms. Die Wiederholung endet, bevor sich langsame Gewebe erholt haben. Deshalb ist Fett hell und Hirnwasser dunkel. Ein T2-gewichteter Scan wartet 2.500 ms und lauscht nach 100 ms. Jetzt leuchtet Hirnwasser, dessen Signal nur langsam abklingt, und graue Substanz wirkt heller als weiße. Die Messwerte zeigen, wie hell jedes Gewebe erscheint. Das hellste steht bei 100 %. Der Kopf hier ist eine einfache Zeichnung aus Ovalen, keine echte Aufnahme. In diesem Modell enthält jedes Gewebe gleich viel Wasserstoff, und Knochen und Luft bleiben schwarz.", + "p4": "Mit einer Zeile pro Wiederholung dauern 256 Zeilen etwa 2,1 Minuten beim T1-Scan und 10,7 beim T2-Scan. Die meisten Aufnahmen des Gehirns nutzen heute Turbo-Spin-Echo. Dabei liest das Gerät in jeder Wiederholung 4 bis 32 Zeilen. Mit 16 Zeilen dauert der T2-Scan etwa 40 Sekunden. Dieses Modell nimmt eine einzige Schicht auf, eine echte Untersuchung des Kopfes dagegen viele. Paul Lauterbur veröffentlichte 1973 das erste MR-Bild. Er und Peter Mansfield teilten sich dafür 2003 den Nobelpreis." } } } From 5a1cb5a330d7c0ea6dd4164d7aa80f4814e75d8f Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:43:29 +0200 Subject: [PATCH 41/82] fix(mri-scanner): name the pacemaker zone in the magnet chapter --- explainers/mri-scanner/chapters.html | 2 +- explainers/mri-scanner/locales/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html index 5dd076fe..9571d35e 100644 --- a/explainers/mri-scanner/chapters.html +++ b/explainers/mri-scanner/chapters.html @@ -65,7 +65,7 @@

A magnet that never swit place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside carry current the other way and pull in the field that leaks out. Even so, the field stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that - line, a pacemaker may no longer work safely. The dashed ring on the floor marks it. + zone, a pacemaker may no longer work safely. The dashed ring on the floor marks its edge.

Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json index 8b9c33a5..f8c59776 100644 --- a/explainers/mri-scanner/locales/en.json +++ b/explainers/mri-scanner/locales/en.json @@ -215,7 +215,7 @@ "title": "A magnet that never switches off", "p1": "The biggest part is the main magnet: about 30 to 40 km of niobium-titanium wire wound into coils. Cooled far enough, this wire becomes superconducting, which means current flows through it with no resistance at all. Several hundred amperes go round and round in a closed loop. Once the loop is closed, holding the field takes no power. It fades by less than a tenth of a percent in a year.", "p2": "To stay superconducting, the coils sit in liquid helium at 4.2 kelvin (K), about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres of it, while newer sealed magnets need 7 litres or even less. A heat shield and a vacuum around the helium vessel work like a thermos flask. A small fridge on top, the cold head, turns boiled helium back into liquid. It runs day and night and draws about 6 to 7 kW.", - "p3": "The field is tens of thousands of times stronger than Earth's. Earth's own field differs from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside carry current the other way and pull in the field that leaks out. Even so, the field stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that line, a pacemaker may no longer work safely. The dashed ring on the floor marks it.", + "p3": "The field is tens of thousands of times stronger than Earth's. Earth's own field differs from place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on the outside carry current the other way and pull in the field that leaks out. Even so, the field stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that zone, a pacemaker may no longer work safely. The dashed ring on the floor marks its edge.", "p4": "Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting and the current turns into heat. This is a quench. The field drops to zero in 2 to 5 seconds. The boiling helium roars out of the building through the quench pipe." }, "spins": { From 1b0a303f2ce24d18772b7f47ab00ffedfb04ba75 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:44:04 +0200 Subject: [PATCH 42/82] fix(mri-scanner): apply the review to the Spanish copy --- explainers/mri-scanner/locales/es.json | 74 +++++++++++++------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/explainers/mri-scanner/locales/es.json b/explainers/mri-scanner/locales/es.json index 6e52c88e..609d5620 100644 --- a/explainers/mri-scanner/locales/es.json +++ b/explainers/mri-scanner/locales/es.json @@ -3,7 +3,7 @@ "title": "Cómo funciona un escáner de resonancia magnética", "eyebrow": "Imagen médica", "tagline": "Un imán gigante, un pulso de radio y el hidrógeno de tu cuerpo: así capta un escáner un eco muy débil y lo convierte en una imagen.", - "description": "Una resonancia magnética en 3D: el imán que nunca se apaga, el hidrógeno que responde a la radio, las bobinas que golpean y los ecos que forman una imagen.", + "description": "Resonancia magnética en 3D: un imán que nunca se apaga, hidrógeno que responde a un pulso de radio, bobinas que golpean y ecos que forman imágenes.", "summary": "El imán superconductor, el hidrógeno que se inclina, el pulso de radio y su eco, las bobinas de gradiente y el espacio k.", "socialAlt": "Un escáner de resonancia magnética abierto en corte, con las capas del imán encajadas alrededor del túnel, junto a la imagen de una sección de la cabeza y el título «Cómo funciona un escáner de resonancia magnética»" }, @@ -20,16 +20,16 @@ "speedValue": "{{value}}/min", "speedFormat": "{{value}} repeticiones por minuto", "phase": { - "excite": "Pulso de radio", + "excite": "Pulso de 90°", "encode": "Codificación", - "refocus": "Reenfoque", + "refocus": "Pulso de 180°", "echo": "Eco", "recover": "Recuperación" }, "jump": { - "excite": "Saltar al pulso de radio", + "excite": "Saltar al pulso de 90°", "encode": "Saltar a la codificación", - "refocus": "Saltar al pulso de reenfoque", + "refocus": "Saltar al pulso de 180°", "echo": "Saltar al eco", "recover": "Saltar a la recuperación" } @@ -97,7 +97,7 @@ } }, "readouts": { - "larmor": "Frecuencia de giro", + "larmor": "Frecuencia de Larmor", "time": "Tiempo en la repetición", "lines": "Líneas del espacio k" }, @@ -129,13 +129,13 @@ "fringeValue": "{{along}} m a lo largo del túnel, {{side}} m hacia los lados" }, "spins": { - "larmor": "Frecuencia del bamboleo", + "larmor": "Frecuencia de Larmor", "larmorBand": "{{value}}, {{band}}", "band": { "belowFm": "justo por debajo de la radio FM", "aboveFm": "justo por encima de la radio FM" }, - "surplus": "Núcleos de más inclinados a favor del campo", + "surplus": "Núcleos de sobra alineados con el campo", "surplusValue": "unos {{perMillion}} por millón", "count": "Excedente en 1 mm³ de agua", "countValue": "unos {{mantissa}} × 10{{exponent}}", @@ -152,26 +152,26 @@ "gradients": { "edge": "Cambio en el borde de la cabeza", "share": "Parte del campo principal", - "spread": "Abanico de frecuencias en la cabeza", + "spread": "Cambio de tono en el borde de la cabeza", "rise": "Tiempo de encendido", "role": "Tarea en esta exploración", "roleValue": { "x": "Frecuencia: de izquierda a derecha", - "y": "Fase: una torsión por línea", + "y": "Fase: una torsión nueva en cada repetición", "z": "Corte: elige una capa fina", - "sequence": "Sigue la secuencia" + "sequence": "Cambia en cada paso" } }, "picture": { "kspace": "Espacio k, los ecos guardados", "image": "La imagen", - "scanTime": "Una línea por repetición, 256 líneas", + "scanTime": "Una línea por repetición", "scanTimeValue": "{{minutes}} min para {{lines}} líneas", "turbo": "Turbo espín eco, 16 líneas por repetición", "turboValue": "unos {{seconds}} s", - "empty": "Pulsa reproducir para llenar la primera línea", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "Aún no hay líneas. El próximo eco llena la primera.", + "kspaceAlt": "La cuadrícula del espacio k, con {{lines}} de {{total}} líneas llenas", + "imageAlt": "El corte de la cabeza, reconstruido a partir de {{lines}} de {{total}} líneas" } }, "parts": { @@ -188,7 +188,7 @@ "gradientX": "Bobina de gradiente x", "gradientY": "Bobina de gradiente y", "gradientZ": "Bobina de gradiente z", - "bodyCoil": "Bobina de cuerpo", + "bodyCoil": "Bobina corporal", "bore": "Túnel", "table": "Camilla", "patient": "Paciente", @@ -206,45 +206,45 @@ "sections": { "overview": { "title": "Un túnel que escucha tu cuerpo", - "p1": "Esto es un escáner de resonancia magnética con un imán de 1,5 teslas. El tesla mide la fuerza de un campo magnético. Te tumbas en la camilla, que te desliza dentro del túnel, de unos 70 cm de ancho. La máquina que lo rodea mide entre unos 1,6 y 1,9 m de largo y pesa de 3 a 6 toneladas.", - "p2": "Para explorar el cerebro, una bobina de cabeza te envuelve la cabeza como una jaula. Es una antena con entre 20 y 64 receptores pequeños que escuchan un eco de radio muy débil que sale de tu cuerpo. La sala está forrada de chapa de cobre para que no entren las radios de fuera, que podrían tapar ese eco. Aquí no hay rayos X. Una onda de radio de esta frecuencia lleva unos 50 millones de veces menos energía de la que hace falta para arrancarle un electrón a un átomo.", - "p3": "Bajo la carcasa blanca hay capas, una dentro de otra, como anillos: el imán en su funda fría, tres bobinas de gradiente y una antena de radio llamada bobina de cuerpo. Activa la vista en corte (C) para verlas. Los capítulos siguientes las explican una a una.", - "p4": "El modelo sigue una repetición, el ciclo corto que el escáner ejecuta una y otra vez para formar una imagen. En este modelo, una repetición dura de 0,5 a 2,5 s de tiempo real, pero los pulsos de radio que contiene duran solo milisegundos. Por eso la línea de tiempo está estirada: los pulsos ocupan casi todo el ciclo y el largo reposo se comprime. La línea de tiempo indica el paso, y los datos en directo muestran el tiempo real. Un examen completo de la cabeza, con muchas imágenes, dura unos 30 a 45 minutos." + "p1": "Este es un escáner de resonancia magnética con un imán de 1,5 teslas. El tesla mide la fuerza de un campo magnético. Te tumbas en la camilla, que te lleva al interior del túnel, de unos 70 cm de ancho. La máquina que lo rodea mide entre 1,6 y 1,9 m de largo y pesa de 3 a 6 toneladas.", + "p2": "Para explorar el cerebro, una bobina de cabeza te envuelve la cabeza como una jaula. Es una antena con entre 20 y 64 receptores pequeños. Escuchan un eco de radio muy débil que sale de tu cuerpo. La sala está forrada de chapa de cobre. Así, las ondas de radio de fuera no pueden tapar ese eco. Aquí no hay rayos X. Las ondas de radio del escáner llevan unos 50 millones de veces menos energía de la necesaria para arrancarle un electrón a un átomo.", + "p3": "Bajo la carcasa blanca hay capas, una dentro de otra, como anillos. De fuera hacia dentro están el imán en su funda fría, tres bobinas de gradiente y la bobina corporal, una antena de radio. Activa la vista en corte (C) para verlas. Los capítulos siguientes las explican una a una.", + "p4": "El modelo sigue una repetición, el ciclo corto que el escáner ejecuta una y otra vez para formar una imagen. En este modelo, una repetición dura de 0,5 a 2,5 s de tiempo real, pero los pulsos de radio que contiene duran solo milisegundos. Por eso la línea de tiempo no está a escala. Los pocos milisegundos de pulsos y eco ocupan unos dos tercios de ella. El largo reposo se comprime en el último tercio. La línea de tiempo indica el paso, y los datos en directo muestran el tiempo real. Un examen completo de la cabeza, con muchas imágenes, dura unos 30 a 45 minutos." }, "magnet": { "title": "Un imán que nunca se apaga", "p1": "La pieza más grande es el imán principal: unos 30 a 40 km de hilo de niobio-titanio enrollado en bobinas. Si se enfría lo suficiente, este hilo se vuelve superconductor: la corriente circula por él sin ninguna resistencia. Varios cientos de amperios dan vueltas y más vueltas en un circuito cerrado. Una vez cerrado el circuito, mantener el campo no gasta energía. El campo pierde menos del 0,1 % al año.", - "p2": "Para seguir siendo superconductoras, las bobinas están sumergidas en helio líquido a 4,2 K, unos −269 °C. Solo está unos 1,5 K por encima del espacio profundo. Un imán clásico contiene unos 1500 litros de helio, mientras que los imanes sellados más nuevos solo necesitan 7. Un escudo térmico y un vacío alrededor del depósito de helio funcionan como un termo. Una pequeña nevera encima, el cabezal frío, vuelve a licuar el helio que se evapora; funciona día y noche y consume unos 6 o 7 kW.", - "p3": "El campo es decenas de miles de veces más fuerte que el de la Tierra, que varía de un sitio a otro. Elige 3 T para comparar con un imán más potente. Por fuera, unas bobinas de blindaje extra llevan corriente en sentido contrario y mantienen el campo disperso cerca del imán. Aun así, la línea de 0,5 mT, dentro de la cual un marcapasos puede dejar de ser seguro, llega a unos 4 m a lo largo del túnel. Activa las líneas de campo para verla.", - "p4": "Como el imán nunca se apaga, un cartel luminoso en la puerta lo avisa. Cualquier cosa con hierro puede salir volando hacia el túnel, y así han muerto personas golpeadas por bombonas de oxígeno. Por eso el personal revisa a todo el mundo y todo lo que entra por la puerta. Si el hilo se calienta en algún punto, deja de ser superconductor y la corriente se convierte en calor: es un quench. El campo cae a cero en un plazo de 2 a 5 segundos, y el helio, al hervir, sale rugiendo del edificio por el tubo de quench." + "p2": "Para seguir siendo superconductoras, las bobinas están sumergidas en helio líquido a 4,2 kelvin (K), unos −269 °C. Es solo unos 1,5 K más cálido que el espacio profundo. Un imán clásico contiene unos 1500 litros de helio, mientras que los imanes sellados más nuevos necesitan 7 litros o incluso menos. Un escudo térmico y un vacío alrededor del depósito de helio funcionan como un termo. Una pequeña nevera encima, el cabezal frío, vuelve a licuar el helio que se evapora. Funciona día y noche y consume unos 6 o 7 kW.", + "p3": "El campo es decenas de miles de veces más fuerte que el de la Tierra. El propio campo de la Tierra cambia de un sitio a otro. Elige 3 T para comparar con un imán más potente. Por fuera, unas bobinas de blindaje extra llevan corriente en sentido contrario y recogen hacia dentro el campo que se escapa. Aun así, el campo sigue por encima de 0,5 mT, media milésima de tesla, hasta unos 4 m a lo largo del túnel. Dentro de esa zona, un marcapasos puede dejar de funcionar de forma segura. El anillo discontinuo del suelo marca su borde.", + "p4": "Como el imán nunca se apaga, un cartel luminoso en la puerta lo recuerda. Cualquier cosa con hierro puede salir volando hacia el túnel, y así han muerto personas golpeadas por bombonas de oxígeno. Por eso el personal revisa a todo el mundo y todo lo que entra por la puerta. Si el hilo se calienta en algún punto, deja de ser superconductor y la corriente se convierte en calor. Esto es un quench. El campo cae a cero en un plazo de 2 a 5 segundos. El helio, al hervir, sale rugiendo del edificio por el tubo de quench." }, "spins": { "title": "El hidrógeno se inclina y se bambolea", "p1": "Unos seis de cada diez átomos de tu cuerpo son de hidrógeno, sobre todo en el agua y la grasa. El centro de un átomo de hidrógeno, su núcleo, es un solo protón. Se comporta como un imán diminuto. La resonancia magnética se fija en el hidrógeno porque está en todas partes y da la señal más fuerte.", - "p2": "Dentro del campo, cada imán diminuto no se limita a apuntar hacia donde marca el campo. Describe una precesión: se bambolea alrededor de esa dirección, como una peonza inclinada que gira. A 1,5 T se bambolea unos 64 millones de veces por segundo: 63,9 MHz. Este ritmo es la frecuencia de Larmor, y crece con el campo, hasta 127,7 MHz a 3 T. Las dos están cerca de la radio FM: una justo por debajo de su banda y la otra justo por encima.", - "p3": "Los núcleos no se alinean todos. El calor del cuerpo los zarandea, así que apuntan casi en cualquier dirección. Por cada millón, solo unos 5 más se inclinan a favor del campo que en contra. Aun así, ese pequeño excedente suma unos 3 × 10¹⁴ núcleos en un milímetro cúbico de agua. Juntos forman un diminuto imán neto, la flecha con la que trabaja el escáner.", - "p4": "Las flechas del recuadro son un dibujo ampliado muchas veces, no un punto real de la cabeza. Además exageran mucho la inclinación, para que puedas verla. El bamboleo dibujado da media vuelta por segundo, unos 128 millones de veces más despacio que el real a 1,5 T. Cambia a 3 T y verás cómo se duplican la frecuencia y el excedente." + "p2": "Dentro del campo, cada imán diminuto no se limita a apuntar hacia donde marca el campo. Describe una precesión: se bambolea alrededor de esa dirección, como una peonza inclinada que gira. A 1,5 T se bambolea unos 64 millones de veces por segundo: 63,9 MHz. Este ritmo, el tono del bamboleo, es la frecuencia de Larmor. Crece con el campo, hasta 127,7 MHz a 3 T. Las dos están cerca de la radio FM: una justo por debajo de su banda y la otra justo por encima.", + "p3": "Los núcleos no se alinean todos. El calor del cuerpo los zarandea, así que apuntan casi en cualquier dirección. Por cada millón, solo unos 5 más se inclinan a favor del campo que en contra. Aun así, ese pequeño excedente suma unos 3,3 × 10¹⁴ núcleos en un milímetro cúbico de agua. Juntos forman un diminuto imán neto, la flecha con la que trabaja el escáner.", + "p4": "Las flechas del recuadro son un dibujo ampliado muchas veces, no un punto real de la cabeza. Además exageran mucho la inclinación, para que puedas verla. Pulsa reproducir para ver cómo giran. El dibujo va muchísimo más despacio que la realidad, y el dato te dice cuánto. Ese número también cambia con la velocidad de reproducción. Cambia a 3 T: la frecuencia real y el excedente se duplican, pero el dibujo mantiene su ritmo." }, "resonance": { "title": "Un pulso de radio y su eco", - "p1": "Para hacer hablar al imán neto, el escáner envía un pulso de radio corto exactamente a la frecuencia de Larmor. Seguir así el ritmo del bamboleo se llama resonancia, como empujar a un niño en un columpio en el momento preciso. El pulso sale de la bobina de cuerpo, una jaula de barras de cobre justo por dentro del túnel. Dura unos pocos milisegundos como mucho.", - "p2": "Un pulso de 90° inclina el imán neto hasta dejarlo tumbado de lado. Ahí sigue bamboleándose, y al pasar por delante de la bobina de cabeza genera un voltaje diminuto, de unos pocos microvoltios. Esa es la señal. Mueve el control del ángulo de inclinación: solo la parte transversal da señal; la parte a lo largo del campo no da ninguna.", - "p3": "Los núcleos pronto pierden el compás, porque unos se bambolean un poco más rápido que otros, y la señal se apaga. A mitad de camino hacia el eco, un pulso de 180° les da la vuelta, así que los rápidos quedan detrás y alcanzan a los demás. Vuelven a ir al compás y dan un eco en el tiempo de eco. Usa los botones para parar en cada momento.", - "p4": "Dos relojes deciden lo brillante que se ve cada tejido. T1 es el tiempo que tarda el imán neto en recuperar alrededor del 63 % de su valor a lo largo del campo. T2 es el tiempo que tarda la señal transversal en bajar a alrededor del 37 %, a medida que los núcleos pierden el compás. La grasa se recupera rápido y el líquido cerebral, despacio; elige un tejido para comparar. Los pulsos también te calientan un poco, así que el escáner mantiene ese calor dentro de límites seguros." + "p1": "Para hacer hablar al imán neto, el escáner envía un pulso de radio corto exactamente a la frecuencia de Larmor. Seguir así el ritmo del bamboleo se llama resonancia, como empujar a un niño en un columpio en el momento preciso. El pulso sale de la bobina corporal, una jaula de barras de cobre justo por dentro del túnel. Dura unos pocos milisegundos como mucho.", + "p2": "Un pulso de 90° inclina el imán neto hasta dejarlo tumbado de lado. Ahí sigue bamboleándose. Al pasar por delante de la bobina de cabeza, genera un voltaje diminuto, que se mide en microvoltios. Esa es la señal. Mueve el control del ángulo de inclinación: solo la parte transversal da señal; la parte a lo largo del campo no da ninguna.", + "p3": "Los núcleos pronto pierden el compás, porque unos se bambolean un poco más rápido que otros. La señal se apaga. A mitad de camino hacia el eco, un pulso de 180° les da la vuelta. Así, los rápidos quedan detrás y alcanzan a los demás. Vuelven a ir al compás y producen un eco. El tiempo que pasa desde el pulso de 90° hasta el eco es el tiempo de eco. Usa los botones de abajo para parar en cada momento.", + "p4": "Dos relojes deciden lo brillante que se ve cada tejido. T1 es el tiempo que tarda el imán neto en recuperar alrededor del 63 % de su valor a lo largo del campo. T2 es el tiempo que tarda la señal transversal en bajar a alrededor del 37 %. El pulso de 180° solo corrige las diferencias fijas. Los empujones al azar entre núcleos vecinos no se corrigen, así que el eco sale algo más débil. Esa pérdida es T2. La grasa se recupera rápido y el líquido cerebral, despacio. Elige un tejido para comparar. Los valores de cada tejido son cifras aproximadas elegidas para este modelo. Los pulsos también te calientan un poco, así que el escáner mantiene ese calor dentro de límites seguros." }, "gradients": { "title": "Bobinas que distinguen lugares", - "p1": "Hasta ahora, cada punto de la cabeza responde con el mismo tono, así que el eco no puede decir de dónde viene. Tres bobinas más, dentro del imán, lo resuelven: las bobinas de gradiente x, y y z. Un gradiente es un campo que cambia poco a poco y de forma regular de un lado a otro. Cada bobina suma un poco al campo principal a un lado del centro y le resta un poco al otro lado.", - "p2": "El cambio es pequeño. Con hasta 45 mT por metro, el campo en el borde de la cabeza varía unos 5,4 mT, un tercio del 1 % del campo principal. Basta para que cada posición tenga su propia frecuencia. Las bobinas se encienden en unos 0,2 ms.", + "p1": "Hasta ahora, cada punto de la cabeza responde con el mismo tono, así que el eco no puede decir de dónde viene. Tres bobinas más, dentro del imán, lo resuelven. Son las bobinas de gradiente x, y y z. Un gradiente es un campo que cambia poco a poco y de forma regular de un lado a otro. Cada bobina suma un poco al campo principal a un lado del centro. Al otro lado, le resta un poco.", + "p2": "El cambio es pequeño. Cada bobina cambia el campo hasta 45 mT por metro. En el borde de la cabeza, eso suma unos 5,4 mT. Es un tercio del 1 % de un campo de 1,5 T. Basta para que cada posición tenga su propia frecuencia. Las bobinas se encienden en unos 0,2 ms.", "p3": "Cada bobina tiene su tarea. La bobina z trabaja con el pulso de radio para despertar solo una capa fina: es la selección de corte. La bobina y da a cada repetición una torsión distinta de arriba abajo: es la codificación de fase. La bobina x funciona mientras se escucha el eco, así que su tono distingue la izquierda de la derecha: es la codificación de frecuencia. Elige una bobina para ver su campo, o sigue la exploración.", - "p4": "Al conmutar corrientes grandes dentro de un imán potente, las bobinas dan sacudidas, como un altavoz. Ese es el golpeteo fuerte que oyes durante la exploración. Puede superar los 100 decibelios: tanto como un martillo neumático, o incluso más. Por eso siempre te dan tapones o auriculares." + "p4": "Al encender y apagar corrientes fuertes dentro de un imán potente, las bobinas dan sacudidas, como un altavoz. Ese es el golpeteo fuerte que oyes durante la exploración. Puede superar los 100 decibelios: tanto como un martillo neumático, o incluso más. Por eso siempre te dan tapones o auriculares." }, "picture": { "title": "De los ecos a la imagen", - "p1": "Cada eco se guarda como una línea del espacio k, una cuadrícula que ordena los ecos por tono y torsión en lugar de por posición. Una repetición llena una línea; la siguiente cambia la torsión y llena otra. En este modelo la imagen es de 64 × 64, así que hacen falta 64 repeticiones; las exploraciones reales de la cabeza suelen usar 256 líneas. Aquí las líneas se llenan desde el centro hacia fuera, así que pronto aparece una imagen aproximada.", - "p2": "Un paso matemático llamado transformada de Fourier convierte esa cuadrícula en la imagen. Busca qué mezcla de franjas, anchas y finas, forma la imagen al sumarlas. El centro del espacio k guarda las franjas anchas: las luces y sombras generales. Los bordes guardan el detalle fino. Prueba con 8, 16, 32 o 64 líneas y verás cómo se enfoca la imagen.", - "p3": "Los tiempos deciden qué tejidos se ven brillantes. Una exploración potenciada en T1 se repite cada 500 ms y escucha pronto, a los 15 ms. La repetición termina antes de que los tejidos lentos se recuperen, así que la grasa sale brillante y el líquido cerebral, oscuro. Una exploración potenciada en T2 espera 2500 ms y escucha a los 100 ms: ahora brilla el líquido, cuya señal se apaga despacio, y la sustancia gris brilla más que la blanca. En este modelo todos los tejidos tienen la misma cantidad de hidrógeno, y el hueso y el aire se quedan negros.", - "p4": "Con una línea por repetición, 256 líneas tardan unos 2,1 minutos en la exploración T1 y unos 10,7 en la T2. Los escáneres reales usan turbo espín eco, que lee de 4 a 32 líneas en cada repetición; con 16 líneas, la exploración T2 tarda unos 40 segundos. La idea es joven. Paul Lauterbur publicó la primera imagen por resonancia magnética en 1973, y él y Peter Mansfield compartieron el Premio Nobel de 2003 por ello." + "p1": "Cada eco se guarda como una línea del espacio k. Es una cuadrícula que ordena los ecos por tono y torsión, no por posición. Una repetición llena una línea. La siguiente cambia la torsión y llena otra. En este modelo la imagen es de 64 × 64, así que hacen falta 64 repeticiones. Las exploraciones reales de la cabeza suelen usar 256 líneas. Aquí las líneas se llenan desde el centro hacia fuera, así que pronto aparece una imagen aproximada.", + "p2": "Un cálculo llamado transformada de Fourier convierte esa cuadrícula en la imagen. Busca qué mezcla de franjas, anchas y finas, forma la imagen al sumarlas. El centro del espacio k guarda las franjas anchas: las luces y sombras generales. Los bordes guardan el detalle fino. Prueba con 8, 16, 32 o 64 líneas y verás cómo se enfoca la imagen.", + "p3": "Los tiempos deciden qué tejidos se ven brillantes. Una exploración potenciada en T1 se repite cada 500 ms y escucha pronto, a los 15 ms. La repetición termina antes de que los tejidos lentos se recuperen, así que la grasa sale brillante y el líquido cerebral, oscuro. Una exploración potenciada en T2 espera 2500 ms y escucha a los 100 ms. Ahora brilla el líquido, cuya señal se apaga despacio, y la sustancia gris se ve más brillante que la blanca. Los datos muestran el brillo de cada tejido, con el más brillante al 100 %. Aquí la cabeza es un dibujo sencillo hecho con óvalos, no una exploración real. En este modelo, todos los tejidos tienen la misma cantidad de hidrógeno, y el hueso y el aire se quedan negros.", + "p4": "Con una línea por repetición, 256 líneas tardan unos 2,1 minutos en la exploración T1 y unos 10,7 en la T2. Hoy, la mayoría de las exploraciones del cerebro usan turbo espín eco, que lee de 4 a 32 líneas en cada repetición. Con 16 líneas, la exploración T2 tarda unos 40 segundos. Este modelo capta un solo corte. Una exploración real de la cabeza capta muchos. Paul Lauterbur publicó la primera imagen por resonancia magnética en 1973. Él y Peter Mansfield compartieron el Premio Nobel de 2003 por ello." } } } From ccb21a0af49be336b3f7afb461e758a224408986 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:44:04 +0200 Subject: [PATCH 43/82] fix(mri-scanner): apply the review to the French copy --- explainers/mri-scanner/locales/fr.json | 110 ++++++++++++------------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json index 6fec8b64..24ccbf54 100644 --- a/explainers/mri-scanner/locales/fr.json +++ b/explainers/mri-scanner/locales/fr.json @@ -20,16 +20,16 @@ "speedValue": "{{value}}/min", "speedFormat": "{{value}} répétitions par minute", "phase": { - "excite": "Impulsion radio", + "excite": "Impulsion de 90°", "encode": "Codage", - "refocus": "Refocalisation", + "refocus": "Impulsion de 180°", "echo": "Écho", "recover": "Récupération" }, "jump": { - "excite": "Aller à l’impulsion radio", + "excite": "Aller à l’impulsion de 90°", "encode": "Aller au codage", - "refocus": "Aller à l’impulsion de refocalisation", + "refocus": "Aller à l’impulsion de 180°", "echo": "Aller à l’écho", "recover": "Aller à la récupération" } @@ -39,8 +39,8 @@ "field": "Intensité du champ", "weighting": "Type d’image", "fieldOptions": { - "field15": "1,5 T", - "field30": "3 T" + "field15": "1,5 T", + "field30": "3 T" }, "weightingOptions": { "t1": "Pondérée en T1", @@ -50,7 +50,7 @@ "fat": "Graisse", "whiteMatter": "Substance blanche", "greyMatter": "Substance grise", - "fluid": "Liquide cérébral" + "fluid": "Liquide céphalorachidien" }, "axisOptions": { "x": "Bobine x", @@ -97,22 +97,22 @@ } }, "readouts": { - "larmor": "Fréquence des spins", + "larmor": "Fréquence de Larmor", "time": "Temps dans la répétition", "lines": "Lignes de l’espace k" }, "units": { - "mhz": "{{value}} MHz", - "msOf": "{{value}} sur {{total}} ms", + "mhz": "{{value}} MHz", + "msOf": "{{value}} sur {{total}} ms", "linesOf": "{{value}} sur {{total}}", "degrees": "{{value}}°", "percent": "{{value}} %", - "ms": "{{value}} ms", - "tesla": "{{value}} T", - "kelvin": "{{kelvin}} K, {{celsius}} °C", - "kHz": "{{value}} kHz", - "mT": "{{value}} mT", - "cm": "{{value}} cm" + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" }, "chapters": { "overview": { @@ -125,11 +125,11 @@ "earth": "Comparé au champ terrestre", "earthValue": "{{min}} à {{max}} fois plus fort", "helium": "Hélium liquide", - "fringe": "Ligne de sécurité de 0,5 mT", - "fringeValue": "{{along}} m dans l’axe du tunnel, {{side}} m sur le côté" + "fringe": "Ligne de sécurité de 0,5 mT", + "fringeValue": "{{along}} m dans l’axe du tunnel, {{side}} m sur le côté" }, "spins": { - "larmor": "Fréquence de précession", + "larmor": "Fréquence de Larmor", "larmorBand": "{{value}}, {{band}}", "band": { "belowFm": "juste sous la bande FM", @@ -137,7 +137,7 @@ }, "surplus": "Noyaux en plus dans le sens du champ", "surplusValue": "environ {{perMillion}} sur un million", - "count": "Excédent dans 1 mm³ d’eau", + "count": "Excédent dans 1 mm³ d’eau", "countValue": "environ {{mantissa}} × 10{{exponent}}", "slowdown": "Dessin ralenti", "slowdownValue": "environ {{factor}} de fois" @@ -146,32 +146,32 @@ "tip": "Angle de bascule", "across": "Part transversale (signal)", "along": "Part dans l’axe du champ", - "t1": "T1, remontée", - "t2": "T2, déclin" + "t1": "T1, repousse", + "t2": "T2, décroissance" }, "gradients": { - "edge": "Écart au bord de la tête", + "edge": "Écart de champ au bord de la tête", "share": "Part du champ principal", - "spread": "Écart de fréquence sur la tête", + "spread": "Écart de fréquence au bord de la tête", "rise": "Temps de montée", "role": "Rôle dans cet examen", "roleValue": { "x": "Fréquence : de gauche à droite", - "y": "Phase : une torsion par ligne", + "y": "Phase : une nouvelle torsion à chaque répétition", "z": "Coupe : choisit une tranche fine", - "sequence": "Suit la séquence" + "sequence": "Change à chaque étape" } }, "picture": { "kspace": "Espace k, les échos stockés", "image": "L’image", - "scanTime": "Une ligne par répétition, 256 lignes", - "scanTimeValue": "{{minutes}} min pour {{lines}} lignes", + "scanTime": "Une ligne par répétition", + "scanTimeValue": "{{minutes}} min pour {{lines}} lignes", "turbo": "Turbo spin écho, 16 lignes par répétition", - "turboValue": "environ {{seconds}} s", - "empty": "Lancez la lecture pour remplir la première ligne", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "turboValue": "environ {{seconds}} s", + "empty": "Aucune ligne pour l’instant. Le prochain écho remplit la première.", + "kspaceAlt": "La grille de l’espace k, avec {{lines}} lignes remplies sur {{total}}", + "imageAlt": "La coupe de la tête, reconstruite à partir de {{lines}} lignes sur {{total}}" } }, "parts": { @@ -200,51 +200,51 @@ "rfWave": "Impulsion radio", "echoWave": "Écho", "fieldLinesGroup": "Lignes de champ", - "fringeLine": "Ligne de 0,5 mT", + "fringeLine": "Ligne de 0,5 mT", "screen": "Image" }, "sections": { "overview": { "title": "Un tunnel qui écoute votre corps", - "p1": "Voici un appareil d’IRM doté d’un aimant de 1,5 tesla. Le tesla mesure la force d’un champ magnétique. Vous vous allongez sur la table, qui vous fait glisser dans le tunnel, large d’environ 70 cm. La machine autour mesure environ 1,6 à 1,9 m de long et pèse de 3 à 6 tonnes.", - "p2": "Pour un examen du cerveau, une antenne tête entoure votre tête comme une cage. Cette antenne compte de 20 à 64 petits récepteurs qui guettent un faible écho radio venu de votre corps. La salle est tapissée de feuilles de cuivre : les ondes radio du dehors restent dehors et ne couvrent pas cet écho. Ici, pas de rayons X. Une onde radio de cette fréquence transporte environ 50 millions de fois trop peu d’énergie pour arracher un électron à un atome.", - "p3": "Sous le capot blanc, des couches s’emboîtent comme des anneaux : l’aimant dans son enveloppe froide, trois bobines de gradient et une antenne radio, l’antenne corps. Activez la vue en coupe (C) pour les voir. Les chapitres suivants les présentent une à une.", - "p4": "Le modèle suit une seule répétition, le court cycle qu’un examen reprend encore et encore pour construire une image. Dans ce modèle, une répétition dure de 0,5 à 2,5 s en temps réel, mais les impulsions radio qu’elle contient ne se comptent qu’en millisecondes. La barre de lecture n’est donc pas à l’échelle : les impulsions occupent l’essentiel de la boucle et la longue pause est tassée. La barre indique l’étape, et les mesures en direct donnent le temps réel. Un examen complet de la tête, avec de nombreuses images, dure environ 30 à 45 minutes." + "p1": "Voici un appareil d’IRM doté d’un aimant de 1,5 tesla. IRM signifie imagerie par résonance magnétique. Le tesla mesure la force d’un champ magnétique. Vous vous allongez sur la table, qui vous fait glisser dans le tunnel, large d’environ 70 cm. La machine autour mesure environ 1,6 à 1,9 m de long et pèse de 3 à 6 tonnes.", + "p2": "Pour un examen du cerveau, une antenne tête entoure votre tête comme une cage. Cette antenne compte de 20 à 64 petits récepteurs qui guettent un faible écho radio venu de votre corps. La salle est tapissée de feuilles de cuivre. Ce cuivre empêche les ondes radio du dehors de couvrir cet écho. Ici, pas de rayons X. Les ondes radio de l’appareil transportent environ 50 millions de fois moins d’énergie qu’il n’en faut pour arracher un électron à un atome.", + "p3": "Sous le capot blanc, des couches s’emboîtent comme des anneaux. De l’extérieur vers l’intérieur, vous trouvez l’aimant dans son enveloppe froide, trois bobines de gradient et l’antenne corps, une antenne radio. Activez la vue en coupe (C) pour les voir. Les chapitres suivants les présentent une à une.", + "p4": "Le modèle suit une seule répétition, le court cycle qu’un examen reprend encore et encore pour construire une image. Dans ce modèle, une répétition dure de 0,5 à 2,5 s en temps réel, mais les impulsions radio qu’elle contient ne se comptent qu’en millisecondes. La barre de lecture n’est donc pas à l’échelle. Les quelques millisecondes d’impulsions et d’écho en occupent environ les deux tiers. La longue pause est comprimée dans le dernier tiers. La barre indique l’étape, et les mesures en direct donnent le temps réel. Un examen complet de la tête, avec de nombreuses images, dure environ 30 à 45 minutes." }, "magnet": { "title": "Un aimant qui ne s’éteint jamais", - "p1": "La plus grosse pièce est l’aimant principal : environ 30 à 40 km de fil de niobium-titane enroulé en bobines. Assez refroidi, ce fil devient supraconducteur : le courant y circule sans aucune résistance. Plusieurs centaines d’ampères tournent sans fin dans une boucle fermée. Une fois la boucle fermée, maintenir le champ ne consomme aucune énergie. Le champ faiblit de moins d’un dixième de pour cent par an.", - "p2": "Pour rester supraconductrices, les bobines baignent dans de l’hélium liquide à 4,2 K, soit environ −269 °C. Le vide spatial n’est qu’environ 1,5 K plus froid. Un aimant classique en contient environ 1 500 litres ; les aimants scellés plus récents se contentent de 7. Un écran thermique et un vide autour de la cuve d’hélium jouent le rôle d’une bouteille isotherme. Sur le dessus, un petit réfrigérateur, la tête froide, retransforme en liquide l’hélium évaporé. Il tourne jour et nuit et consomme environ 6 à 7 kW.", - "p3": "Le champ est des dizaines de milliers de fois plus fort que celui de la Terre, qui varie d’un endroit à l’autre. Choisissez 3 T pour comparer avec un aimant plus puissant. À l’extérieur, des bobines de blindage font circuler du courant en sens inverse et ramènent le champ de fuite près de l’appareil. Malgré cela, la ligne de 0,5 mT, à l’intérieur de laquelle un pacemaker risque de ne plus être en sécurité, s’étend à environ 4 m dans l’axe du tunnel. Activez les lignes de champ pour la voir.", - "p4": "Comme l’aimant ne s’éteint jamais, un panneau lumineux le rappelle à la porte. Tout objet qui contient du fer peut être aspiré dans le tunnel, et des bouteilles d’oxygène ont déjà tué des personnes de cette façon. C’est pourquoi le personnel contrôle chaque personne et chaque objet à l’entrée. Si le fil se réchauffe quelque part, il cesse d’être supraconducteur et le courant se change en chaleur : c’est le quench. Le champ tombe à zéro en 2 à 5 secondes, et l’hélium en ébullition s’échappe du bâtiment dans un grondement, par le tuyau de quench." + "p1": "La plus grosse pièce est l’aimant principal : environ 30 à 40 km de fil de niobium-titane enroulé en bobines. Assez refroidi, ce fil devient supraconducteur : le courant y circule sans aucune résistance. Plusieurs centaines d’ampères tournent sans fin dans une boucle fermée. Une fois la boucle fermée, maintenir le champ ne consomme aucune énergie. Le champ faiblit de moins d’un dixième de pour cent par an.", + "p2": "Pour rester supraconductrices, les bobines baignent dans de l’hélium liquide à 4,2 kelvins (K), soit environ −269 °C. Le vide spatial n’est qu’environ 1,5 K plus froid. Un aimant classique en contient environ 1 500 litres ; les aimants scellés plus récents se contentent de 7 litres, voire moins. Un écran thermique et un vide autour de la cuve d’hélium jouent le rôle d’une bouteille isotherme. Sur le dessus, un petit réfrigérateur, la tête froide, retransforme en liquide l’hélium évaporé. Il tourne jour et nuit et consomme environ 6 à 7 kW.", + "p3": "Le champ est des dizaines de milliers de fois plus fort que celui de la Terre. Le champ terrestre lui-même varie d’un endroit à l’autre. Choisissez 3 T pour comparer avec un aimant plus puissant. À l’extérieur, des bobines de blindage font circuler du courant en sens inverse et ramènent vers l’appareil le champ qui s’en échappe. Malgré cela, la ligne de 0,5 mT, soit un demi-millième de tesla, s’étend à environ 4 m dans l’axe du tunnel. À l’intérieur, un pacemaker peut ne plus fonctionner de façon sûre. L’anneau en pointillés tracé au sol marque cette limite.", + "p4": "Comme l’aimant ne s’éteint jamais, un panneau lumineux le rappelle à la porte. Tout objet qui contient du fer peut être aspiré dans le tunnel, et des bouteilles d’oxygène ont déjà tué des personnes de cette façon. C’est pourquoi le personnel contrôle chaque personne et chaque objet à l’entrée. Si le fil se réchauffe quelque part, il cesse d’être supraconducteur et le courant se change en chaleur. C’est le quench. Le champ tombe à zéro en 2 à 5 secondes. L’hélium qui bout s’échappe en grondant par le tuyau de quench, hors du bâtiment." }, "spins": { "title": "L’hydrogène penche et tournoie", "p1": "Dans votre corps, environ six atomes sur dix sont de l’hydrogène, surtout dans l’eau et la graisse. Le cœur d’un atome d’hydrogène, son noyau, est un simple proton. Il se comporte comme un minuscule aimant. L’IRM écoute l’hydrogène parce qu’il est partout et qu’il donne le signal le plus fort.", - "p2": "Dans le champ, chaque petit aimant ne se contente pas de s’aligner. Il fait un mouvement de précession : il tourne autour de la direction du champ, comme une toupie penchée. À 1,5 T, il fait environ 64 millions de tours par seconde, soit 63,9 MHz. Cette cadence s’appelle la fréquence de Larmor. Elle augmente avec le champ et atteint 127,7 MHz à 3 T. Les deux sont proches de la radio FM, l’une juste sous la bande, l’autre juste au-dessus.", - "p3": "Les noyaux ne s’alignent pas tous. La chaleur du corps les bouscule, si bien qu’ils pointent presque dans tous les sens. Ceux qui penchent dans le sens du champ sont à peine plus nombreux que ceux qui penchent à l’inverse : environ 5 de plus sur un million. Ce petit excédent représente quand même environ 3 × 10¹⁴ noyaux dans un millimètre cube d’eau. Ensemble, ils forment un minuscule aimant résultant : c’est la flèche avec laquelle travaille l’appareil.", - "p4": "Les flèches de l’encart sont un dessin, fortement agrandi, et non un vrai point de la tête. Elles montrent aussi une inclinaison bien plus marquée qu’en réalité, pour que vous puissiez le voir. La précession dessinée fait un demi-tour par seconde, environ 128 millions de fois plus lentement que la vraie, à 1,5 T. Passez à 3 T et regardez la fréquence et l’excédent doubler." + "p2": "Dans le champ, chaque petit aimant ne se contente pas de s’aligner. Il fait un mouvement de précession : il tourne autour de la direction du champ, comme une toupie penchée. À 1,5 T, il fait environ 64 millions de tours par seconde, soit 63,9 MHz. Cette cadence, un peu comme la hauteur d’une note, s’appelle la fréquence de Larmor. Elle augmente avec le champ et atteint 127,7 MHz à 3 T. Ces deux fréquences encadrent la bande FM : l’une juste en dessous, l’autre juste au-dessus.", + "p3": "Les noyaux ne s’alignent pas tous. La chaleur du corps les bouscule, si bien qu’ils pointent presque dans tous les sens. Ceux qui penchent dans le sens du champ sont à peine plus nombreux que ceux qui penchent à l’inverse : environ 5 de plus sur un million. Ce petit excédent représente quand même environ 3,3 × 10¹⁴ noyaux dans un millimètre cube d’eau. Ensemble, ils forment un minuscule aimant résultant : c’est la flèche avec laquelle travaille l’appareil.", + "p4": "Les flèches de l’encart sont un dessin, fortement agrandi, et non un vrai point de la tête. Elles montrent aussi une inclinaison bien plus marquée qu’en réalité, pour que vous puissiez la voir. Lancez la lecture pour les voir tourner. Le dessin est énormément ralenti, et la mesure indique de combien. Ce chiffre change aussi avec la vitesse de lecture. Passez à 3 T : la vraie fréquence et l’excédent doublent, mais le dessin garde son rythme." }, "resonance": { "title": "Une impulsion radio et son écho", - "p1": "Pour faire parler l’aimant résultant, l’appareil envoie une brève impulsion radio, exactement à la fréquence de Larmor. Se caler ainsi sur la rotation s’appelle la résonance, comme quand on pousse un enfant sur une balançoire au bon moment. L’impulsion vient de l’antenne corps, une cage de barreaux de cuivre placée juste à l’intérieur du tunnel. Elle dure au plus quelques millisecondes.", - "p2": "Une impulsion de 90° fait basculer l’aimant résultant jusqu’à le coucher sur le côté. Là, il continue de tourner et, en passant devant l’antenne tête, il crée une minuscule tension de quelques microvolts. C’est le signal. Déplacez le curseur de l’angle de bascule : seule la part transversale, couchée sur le côté, crée du signal, la part dans l’axe du champ n’en donne aucun.", - "p3": "Les petits aimants se décalent vite les uns des autres, car certains tournent un peu plus vite que d’autres, et le signal s’éteint. À mi-chemin de l’écho, une impulsion de 180° les retourne : les plus rapides se retrouvent derrière et rattrapent les autres. Ils se remettent en phase et produisent un écho au temps d’écho. Utilisez les boutons pour vous arrêter à chaque moment.", - "p4": "Deux horloges décident de la luminosité de chaque tissu. T1 est le temps que met l’aimant résultant pour regagner environ 63 % de sa valeur dans l’axe du champ. T2 est le temps que met le signal transversal pour tomber à environ 37 %, à mesure que les petits aimants se décalent. La graisse récupère vite, le liquide cérébral lentement : choisissez un tissu pour comparer. Les impulsions vous réchauffent aussi un peu, alors l’appareil maintient cet échauffement dans des limites sûres." + "p1": "Pour faire parler l’aimant résultant, l’appareil envoie une brève impulsion radio, exactement à la fréquence de Larmor. Cet accord parfait avec la rotation s’appelle la résonance, comme lorsque vous poussez un enfant sur une balançoire au bon moment. L’impulsion vient de l’antenne corps, une cage de barreaux de cuivre placée juste à l’intérieur du tunnel. Elle dure au plus quelques millisecondes.", + "p2": "Une impulsion de 90° fait basculer l’aimant résultant jusqu’à le coucher sur le côté. Là, il continue de tourner. En passant devant l’antenne tête, il crée une minuscule tension, qui se mesure en microvolts. C’est le signal. Déplacez le curseur de l’angle de bascule : seule la part transversale crée du signal ; la part dans l’axe du champ n’en donne aucun.", + "p3": "Les petits aimants se décalent vite les uns des autres, car certains tournent un peu plus vite que d’autres. Le signal s’éteint. À mi-chemin de l’écho, une impulsion de 180° les retourne : les plus rapides se retrouvent derrière et rattrapent les autres. Ils se remettent en phase et produisent un écho. Le temps qui sépare l’impulsion de 90° de l’écho s’appelle le temps d’écho. Utilisez les boutons ci-dessous pour vous arrêter à chaque moment.", + "p4": "Deux horloges décident de la luminosité de chaque tissu. T1 est le temps que met l’aimant résultant pour regagner environ 63 % de sa valeur dans l’axe du champ. T2 est le temps que met le signal transversal pour tomber à environ 37 %. Le retournement de 180° n’efface que les écarts constants. Les petites bousculades au hasard entre voisins demeurent, si bien que l’écho est un peu plus faible. Cette perte, c’est T2. La graisse récupère vite. Le liquide céphalorachidien, qui baigne le cerveau, récupère lentement. Choisissez un tissu pour comparer. Les valeurs de chaque tissu sont approximatives et choisies pour ce modèle. Les impulsions vous réchauffent aussi un peu, alors l’appareil maintient cet échauffement dans des limites sûres." }, "gradients": { "title": "Des bobines pour situer chaque point", - "p1": "Jusqu’ici, chaque point de la tête répond à la même fréquence : l’écho ne peut donc pas dire d’où il vient. Trois autres bobines, à l’intérieur de l’aimant, règlent ce problème : les bobines de gradient x, y et z. Un gradient est un champ qui varie régulièrement d’un côté à l’autre. Chaque bobine renforce un peu le champ principal d’un côté du centre et l’affaiblit un peu de l’autre.", - "p2": "L’écart est faible. Le gradient monte jusqu’à 45 mT par mètre : au bord de la tête, le champ varie alors d’environ 5,4 mT, un tiers de pour cent du champ principal. Cela suffit à donner à chaque position sa propre fréquence. Les bobines s’enclenchent en 0,2 ms environ.", + "p1": "Jusqu’ici, chaque point de la tête répond à la même fréquence : l’écho ne peut donc pas dire d’où il vient. Trois autres bobines, à l’intérieur de l’aimant, règlent ce problème. Ce sont les bobines de gradient x, y et z. Un gradient est un champ qui varie régulièrement d’un côté à l’autre. Chaque bobine renforce un peu le champ principal d’un côté du centre. Elle l’affaiblit un peu de l’autre côté.", + "p2": "L’écart est faible. Chaque bobine fait varier le champ d’au plus 45 mT par mètre. Au bord de la tête, cela fait environ 5,4 mT. C’est un tiers de pour cent d’un champ de 1,5 T. Cela suffit à donner à chaque position sa propre fréquence. Les bobines s’enclenchent en 0,2 ms environ.", "p3": "Chaque bobine a son rôle. La bobine z agit avec l’impulsion radio pour ne réveiller qu’une tranche fine : c’est la sélection de coupe. La bobine y donne à chaque répétition une torsion différente, de haut en bas : c’est le codage de phase. La bobine x fonctionne pendant l’écoute de l’écho, si bien que la fréquence de l’écho indique la gauche ou la droite : c’est le codage en fréquence. Choisissez une bobine pour voir son champ, ou suivez l’examen.", - "p4": "Allumer et couper de forts courants dans un aimant puissant fait sursauter les bobines, comme la membrane d’un haut-parleur. C’est le fort martèlement que vous entendez pendant l’examen. Il peut dépasser 100 décibels, à peu près autant qu’un marteau-piqueur, voire plus. C’est pourquoi on vous donne toujours des bouchons d’oreilles ou un casque." + "p4": "Allumer et couper de forts courants dans un aimant puissant fait vibrer les bobines par à-coups, comme la membrane d’un haut-parleur. C’est le martèlement bruyant que vous entendez pendant l’examen. Il peut dépasser 100 décibels, à peu près autant qu’un marteau-piqueur, voire plus. C’est pourquoi on vous donne toujours des bouchons d’oreilles ou un casque." }, "picture": { "title": "Des échos à l’image", - "p1": "Chaque écho est stocké comme une ligne de l’espace k, une grille qui range les échos par fréquence et par torsion, et non par position. Une répétition remplit une ligne ; la suivante change la torsion et en remplit une autre. Dans ce modèle, l’image fait 64 × 64, il faut donc 64 répétitions. Les vrais examens de la tête utilisent souvent 256 lignes. Ici, les lignes se remplissent du centre vers les bords, si bien qu’une image grossière apparaît tôt.", - "p2": "Une étape de calcul, la transformée de Fourier, change cette grille en image. Elle trouve quel mélange de rayures, larges et fines, reconstitue l’image. Le centre de l’espace k contient les rayures larges, les grandes zones de clair et d’ombre. Les bords contiennent les détails fins. Essayez 8, 16, 32 ou 64 lignes et regardez l’image devenir nette.", - "p3": "Le minutage décide des tissus qui apparaissent clairs. Une séquence pondérée en T1 se répète toutes les 500 ms et écoute tôt, à 15 ms. La répétition s’achève avant que les tissus lents aient récupéré : la graisse est claire et le liquide cérébral sombre. Une séquence pondérée en T2 attend 2 500 ms et écoute à 100 ms. Le liquide, qui s’estompe lentement, brille alors, et la substance grise ressort plus que la blanche. Dans ce modèle, chaque tissu contient la même quantité d’hydrogène, et l’os comme l’air restent noirs.", - "p4": "Avec une ligne par répétition, 256 lignes prennent environ 2,1 minutes pour la séquence T1 et 10,7 pour la séquence T2. Les vrais appareils utilisent le turbo spin écho, qui lit de 4 à 32 lignes à chaque répétition ; avec 16 lignes, la séquence T2 dure environ 40 secondes. L’idée est récente. Paul Lauterbur a publié la première image par résonance magnétique en 1973, et il a partagé avec Peter Mansfield le prix Nobel 2003 pour cette découverte." + "p1": "Chaque écho est stocké comme une ligne de l’espace k. C’est une grille qui range les échos par fréquence et par torsion, et non par position. Une répétition remplit une ligne. La suivante change la torsion et en remplit une autre. Dans ce modèle, l’image fait 64 × 64, il faut donc 64 répétitions. Les vrais examens de la tête utilisent souvent 256 lignes. Ici, les lignes se remplissent du centre vers les bords, si bien qu’une image grossière apparaît tôt.", + "p2": "Un calcul appelé transformée de Fourier change cette grille en image. Il trouve quel mélange de rayures, larges et fines, reconstitue l’image. Le centre de l’espace k contient les rayures larges, les grandes zones de clair et d’ombre. Les bords contiennent les détails fins. Essayez 8, 16, 32 ou 64 lignes et regardez l’image devenir nette.", + "p3": "Le minutage décide des tissus qui apparaissent clairs. Une séquence pondérée en T1 se répète toutes les 500 ms et écoute tôt, à 15 ms. La répétition s’achève avant que les tissus lents aient récupéré : la graisse est claire et le liquide céphalorachidien sombre. Une séquence pondérée en T2 attend 2 500 ms et écoute à 100 ms. Le liquide, dont le signal s’estompe lentement, brille alors. La substance grise paraît plus claire que la substance blanche. Les mesures indiquent la luminosité de chaque tissu, le plus clair valant 100 %. Ici, la tête est un simple dessin fait d’ovales, et non un vrai examen. Dans ce modèle, chaque tissu contient la même quantité d’hydrogène, et l’os comme l’air restent noirs.", + "p4": "Avec une ligne par répétition, 256 lignes prennent environ 2,1 minutes pour la séquence T1 et 10,7 pour la séquence T2. Aujourd’hui, la plupart des examens du cerveau utilisent le turbo spin écho, qui lit de 4 à 32 lignes à chaque répétition. Avec 16 lignes, la séquence T2 dure environ 40 secondes. Ce modèle montre une seule coupe. Un vrai examen de la tête en compte beaucoup. Paul Lauterbur a publié la première image par résonance magnétique en 1973. Il a partagé avec Peter Mansfield le prix Nobel 2003 pour cette découverte." } } } From 9896586f2df02db42bf7866587cbf06680659eb5 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:44:04 +0200 Subject: [PATCH 44/82] fix(mri-scanner): apply the review to the Ukrainian copy --- explainers/mri-scanner/locales/uk.json | 74 +++++++++++++------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json index 0f468669..9748c512 100644 --- a/explainers/mri-scanner/locales/uk.json +++ b/explainers/mri-scanner/locales/uk.json @@ -20,16 +20,16 @@ "speedValue": "{{value}}/хв", "speedFormat": "Повторень за хвилину: {{value}}", "phase": { - "excite": "Радіоімпульс", + "excite": "Імпульс 90°", "encode": "Кодування", - "refocus": "Рефокусування", + "refocus": "Імпульс 180°", "echo": "Відлуння", "recover": "Відновлення" }, "jump": { - "excite": "Перейти до радіоімпульсу", + "excite": "Перейти до імпульсу 90°", "encode": "Перейти до кодування", - "refocus": "Перейти до рефокусувального імпульсу", + "refocus": "Перейти до імпульсу 180°", "echo": "Перейти до відлуння", "recover": "Перейти до відновлення" } @@ -97,7 +97,7 @@ } }, "readouts": { - "larmor": "Частота спінів", + "larmor": "Частота Лармора", "time": "Час у повторенні", "lines": "Рядки k-простору" }, @@ -129,13 +129,13 @@ "fringeValue": "{{along}} м уздовж тунелю, {{side}} м убік" }, "spins": { - "larmor": "Частота хитання", + "larmor": "Частота Лармора", "larmorBand": "{{value}}, {{band}}", "band": { "belowFm": "трохи нижче за FM-радіо", "aboveFm": "трохи вище за FM-радіо" }, - "surplus": "Зайві ядра, нахилені вздовж поля", + "surplus": "Надлишок ядер уздовж поля", "surplusValue": "близько {{perMillion}} на мільйон", "count": "Надлишок в 1 мм³ води", "countValue": "близько {{mantissa}} × 10{{exponent}}", @@ -152,26 +152,26 @@ "gradients": { "edge": "Зміна на краю голови", "share": "Частка основного поля", - "spread": "Розкид частот по голові", + "spread": "Зсув частоти на краю голови", "rise": "Час увімкнення", "role": "Роль у цьому скануванні", "roleValue": { "x": "Частота: зліва направо", - "y": "Фаза: одне закручування на рядок", + "y": "Фаза: нове закручування в кожному повторенні", "z": "Зріз: вибирає один тонкий шар", - "sequence": "Стежить за послідовністю" + "sequence": "Змінюється з кожним кроком" } }, "picture": { "kspace": "k-простір, збережені відлуння", "image": "Зображення", - "scanTime": "Один рядок за повторення, 256 рядків", + "scanTime": "Один рядок за повторення", "scanTimeValue": "{{minutes}} хв на {{lines}} рядків", "turbo": "Турбо спін-ехо, 16 рядків за повторення", "turboValue": "близько {{seconds}} с", - "empty": "Натисніть відтворення, щоб заповнити перший рядок", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "Рядків ще немає. Наступне відлуння заповнить перший.", + "kspaceAlt": "Сітка k-простору, заповнено рядків: {{lines}} з {{total}}", + "imageAlt": "Зріз голови, відновлений із рядків k-простору: {{lines}} з {{total}}" } }, "parts": { @@ -206,45 +206,45 @@ "sections": { "overview": { "title": "Тунель, що слухає ваше тіло", - "p1": "Перед вами апарат МРТ, магнітно-резонансний томограф із магнітом на 1,5 тесла. Одиниця тесла показує, наскільки сильне магнітне поле. Ви лягаєте на стіл, і він засуває вас у тунель завширшки близько 70 см. Сам апарат довкола тунелю має приблизно від 1,6 до 1,9 м завдовжки й важить від 3 до 6 тонн.", - "p2": "Для знімка мозку на вас надягають котушку для голови, схожу на клітку. Це антена: від 20 до 64 маленьких приймачів ловлять слабке радіовідлуння з вашого тіла. Стіни кімнати оббиті мідними листами. Вони не пускають сторонні радіохвилі, щоб ті не заглушили це відлуння. Рентгенівського проміння тут немає. Радіохвиля такої частоти несе приблизно в 50 мільйонів разів менше енергії, ніж треба, щоб вибити електрон з атома.", - "p3": "Під білим кожухом шари вкладені один в одного, як кільця: магніт у своїй холодній оболонці, три градієнтні котушки і радіоантена, яку називають котушкою для тіла. Увімкніть розріз (C), щоб їх побачити. У наступних розділах ми розберемо їх по черзі.", - "p4": "Модель показує одне повторення: короткий цикл, який апарат проходить знову й знову, щоб зібрати одне зображення. У цій моделі повторення триває від 0,5 до 2,5 с реального часу, а радіоімпульси в ньому тривають лише мілісекунди. Тому шкалу часу розтягнуто: імпульси займають більшу частину циклу, а довгу паузу стиснуто. Шкала часу називає крок, а поточні показники відображають справжній час. Повне обстеження голови з багатьма знімками триває приблизно від 30 до 45 хвилин." + "p1": "Перед вами апарат МРТ із магнітом на 1,5 тесла. МРТ розшифровується як «магнітно-резонансна томографія». Одиниця тесла показує, наскільки сильне магнітне поле. Ви лягаєте на стіл, і він засуває вас у тунель завширшки близько 70 см. Сам апарат довкола тунелю має приблизно від 1,6 до 1,9 м завдовжки й важить від 3 до 6 тонн.", + "p2": "Для знімка мозку на вас надягають котушку для голови, схожу на клітку. Це антена: від 20 до 64 маленьких приймачів ловлять слабке радіовідлуння з вашого тіла. Стіни кімнати оббиті мідними листами. Вони не пускають сторонні радіохвилі, щоб ті не заглушили це відлуння. Рентгенівського проміння тут немає. Радіохвилі апарата несуть приблизно в 50 мільйонів разів менше енергії, ніж треба, щоб вибити електрон з атома.", + "p3": "Під білим кожухом шари вкладені один в одного, як кільця. Якщо йти ззовні всередину, ви побачите магніт у холодній оболонці, три градієнтні котушки і котушку для тіла, тобто радіоантену. Увімкніть розріз (C), щоб їх побачити. У наступних розділах ми розберемо їх по черзі.", + "p4": "Модель показує одне повторення: короткий цикл, який апарат проходить знову й знову, щоб зібрати одне зображення. У цій моделі повторення триває від 0,5 до 2,5 с реального часу, а радіоімпульси в ньому тривають лише мілісекунди. Тому шкала часу не зберігає справжніх пропорцій. Кілька мілісекунд імпульсів і відлуння займають на ній приблизно дві третини. Довгу паузу стиснуто в останню третину. Шкала часу називає крок, а в поточних показниках видно справжній час. Повне обстеження голови з багатьма знімками триває приблизно від 30 до 45 хвилин." }, "magnet": { "title": "Магніт, що ніколи не вимикається", "p1": "Найбільша частина апарата, основний магніт, складається з ніобій-титанового дроту завдовжки приблизно від 30 до 40 км, намотаного в котушки. Якщо цей дріт достатньо охолодити, він стає надпровідним: струм тече ним зовсім без опору. Кілька сотень амперів біжать по колу в замкненому контурі. Щойно контур замкнули, на утримання поля вже не треба енергії. За рік поле слабшає менш ніж на десяту частку відсотка.", - "p2": "Щоб котушки лишалися надпровідними, вони лежать у рідкому гелії за температури 4,2 К, тобто близько −269 °C. Це лише приблизно на 1,5 К тепліше, ніж у глибокому космосі. У класичному магніті близько 1 500 літрів гелію, а новим герметичним магнітам вистачає лише 7. Тепловий екран і вакуум довкола резервуара з гелієм працюють як термос. Маленький холодильник згори, холодна головка, знову перетворює випарений гелій на рідину. Вона працює вдень і вночі та споживає приблизно від 6 до 7 кВт.", - "p3": "Поле в десятки тисяч разів сильніше за земне, а земне в різних місцях різне. Оберіть 3 Тл, щоб порівняти з потужнішим магнітом. Додаткові екранувальні котушки зовні пускають струм у зворотний бік і підтягують розсіяне поле ближче до апарата. Та навіть так лінія 0,5 мТл, всередині якої з кардіостимулятором уже може бути небезпечно, сягає приблизно 4 м уздовж тунелю. Увімкніть силові лінії, щоб її побачити.", - "p4": "Магніт ніколи не вимикається, тому біля дверей про це попереджає табло, що світиться. Будь-яку річ із залізом може затягнути в тунель, і через кисневі балони так уже гинули люди. Ось чому персонал біля дверей перевіряє всіх і все. Якщо дріт десь нагріється, він перестає бути надпровідним, і струм перетворюється на тепло: це квенч. Поле падає до нуля за кілька секунд, від 2 до 5, а гелій, що википає, з ревом виривається з будівлі через трубу квенчу." + "p2": "Щоб котушки лишалися надпровідними, вони лежать у рідкому гелії за температури 4,2 кельвіна (К), тобто близько −269 °C. Це лише приблизно на 1,5 К тепліше, ніж у глибокому космосі. У класичному магніті близько 1 500 літрів гелію, а новим герметичним магнітам вистачає 7 літрів або й менше. Тепловий екран і вакуум довкола резервуара з гелієм працюють як термос. Маленький холодильник згори, холодна головка, знову перетворює на рідину гелій, що випарувався. Вона працює вдень і вночі та споживає приблизно від 6 до 7 кВт.", + "p3": "Поле в десятки тисяч разів сильніше за земне. А саме поле Землі в різних місцях різне. Оберіть 3 Тл, щоб порівняти з потужнішим магнітом. Додаткові екранувальні котушки зовні пускають струм у зворотний бік і підтягують назад поле, що виходить назовні. І все ж поле лишається сильнішим за 0,5 мТл, тобто половину тисячної частки тесла, приблизно до 4 м уздовж тунелю. Усередині цієї межі кардіостимулятор може вже не працювати безпечно. Її позначає пунктирне кільце на підлозі.", + "p4": "Магніт ніколи не вимикається, тому біля дверей про це попереджає світлове табло. Будь-яка річ із залізом може полетіти в тунель. Так уже гинули люди, коли туди залітав кисневий балон. Ось чому персонал біля дверей перевіряє всіх і все. Якщо дріт десь нагріється, він перестане бути надпровідним, і струм перетвориться на тепло. Це квенч. Поле зникає за час від 2 до 5 секунд. Гелій, що википає, з ревом виривається з будівлі через трубу квенчу." }, "spins": { - "title": "Водень нахиляється й хитається", + "title": "Водень нахиляється й кружляє", "p1": "Водень становить приблизно шість із десяти атомів у вашому тілі, здебільшого у воді й жирі. Серцевина атома водню, його ядро, складається лише з одного протона. Воно поводиться як крихітний магніт. МРТ слухає саме водень, бо його всюди повно і він дає найсильніший сигнал.", - "p2": "Опинившись у полі, кожен такий крихітний магніт не просто вказує вздовж нього. Він прецесує: хитається довкола напрямку поля, як нахилена дзиґа. У полі 1,5 Тл він хитається приблизно 64 мільйони разів за секунду, тобто з частотою 63,9 МГц. Цю частоту називають частотою Лармора, і вона росте разом із полем: у полі 3 Тл вона сягає 127,7 МГц. Обидві близькі до FM-радіо: одна трохи нижча за його діапазон, друга трохи вища.", - "p3": "Ядра не вишиковуються всі в один бік. Тепло тіла їх розштовхує, тож вони дивляться майже куди завгодно. На кожен мільйон лише приблизно на 5 ядер більше нахиляються вздовж поля, ніж проти нього. Але навіть цей малий надлишок дає близько 3 × 10¹⁴ ядер в одному кубічному міліметрі води. Разом вони утворюють крихітний сумарний магніт, ту саму стрілку, з якою працює апарат.", - "p4": "Стрілки у вставці намальовано й збільшено в багато разів: це не справжня точка в голові. Нахил на них теж показано набагато сильнішим, ніж насправді, щоб його було видно. Намальоване хитання робить пів оберту за секунду. Це приблизно в 128 мільйонів разів повільніше, ніж справжнє у полі 1,5 Тл. Перемкніть на 3 Тл і подивіться, як частота й надлишок подвояться." + "p2": "У полі кожен такий крихітний магніт не просто напрямлений уздовж нього. Він прецесує: кружляє довкола напрямку поля, як нахилена дзиґа. У полі 1,5 Тл він робить приблизно 64 мільйони обертів за секунду, тобто 63,9 МГц. Цю частоту кружляння називають частотою Лармора. Вона зростає разом із полем і в полі 3 Тл сягає 127,7 МГц. Обидві частоти близькі до FM-радіо: одна трохи нижча за його діапазон, друга трохи вища.", + "p3": "Ядра не вишиковуються всі в один бік. Тепло тіла їх розштовхує, тож вони дивляться майже куди завгодно. З кожного мільйона ядер уздовж поля нахиляється лише приблизно на 5 більше, ніж проти нього. Але навіть цей малий надлишок дає близько 3,3 × 10¹⁴ ядер в одному кубічному міліметрі води. Разом вони утворюють крихітний сумарний магніт, ту саму стрілку, з якою працює апарат.", + "p4": "Стрілки у вставці намальовано й збільшено в багато разів: це не справжня точка в голові. Нахил на них теж показано набагато сильнішим, ніж насправді, щоб його було видно. Натисніть кнопку відтворення, і стрілки закружляють. Малюнок дуже сильно сповільнено, а в показниках видно, у скільки разів. Це число залежить і від швидкості відтворення. Перемкніть на 3 Тл: справжня частота й надлишок подвояться, а малюнок кружлятиме так само." }, "resonance": { "title": "Радіоімпульс і його відлуння", - "p1": "Щоб сумарний магніт заговорив, апарат посилає короткий радіоімпульс точно на частоті Лармора. Таке влучання в такт хитанню називають резонансом. Так само ви розгойдуєте дитину на гойдалці, коли підштовхуєте її саме вчасно. Імпульс посилає котушка для тіла, клітка з мідних перекладин одразу за стінкою тунелю. Він триває щонайбільше кілька мілісекунд.", - "p2": "Імпульс 90° нахиляє сумарний магніт, доки той не ляже набік. Там він хитається далі й, пролітаючи повз котушку для голови, створює в ній крихітну напругу в кілька мікровольт. Це і є сигнал. Посуньте повзунок кута нахилу: сигнал дає лише поперечна частина, а частина вздовж поля не дає нічого.", - "p3": "Спіни, тобто крихітні магніти ядер, швидко збиваються з такту, бо деякі хитаються трохи швидше за інші, і сигнал згасає. На півдорозі до відлуння імпульс 180° перевертає їх, тож швидкі опиняються позаду й наздоганяють решту. Спіни знову входять у такт і дають відлуння в мить, яку називають часом відлуння. Натискайте кнопки, щоб зупинитися на кожному моменті.", - "p4": "Яскравість кожної тканини визначають два «годинники». T1 означає час, за який сумарний магніт відновлюється вздовж поля приблизно на 63 %. T2 означає час, за який поперечний сигнал згасає приблизно до 37 %, бо спіни збиваються з такту. Жир відновлюється швидко, а мозкова рідина, ліквор, повільно. Оберіть тканину, щоб порівняти. Імпульси ще й трохи вас нагрівають, тож апарат тримає це нагрівання в безпечних межах." + "p1": "Щоб сумарний магніт заговорив, апарат посилає короткий радіоімпульс точно на частоті Лармора. Таке влучання в такт кружлянню називають резонансом. Так само ви розгойдуєте дитину на гойдалці, коли підштовхуєте її саме вчасно. Імпульс посилає котушка для тіла, клітка з мідних перекладин одразу за стінкою тунелю. Він триває щонайбільше кілька мілісекунд.", + "p2": "Імпульс 90° нахиляє сумарний магніт, доки той не ляже набік. Там він і далі кружляє. Щоразу, коли він проходить повз котушку для голови, у ній виникає крихітна напруга, яку вимірюють у мікровольтах. Це і є сигнал. Посуньте повзунок кута нахилу: сигнал дає лише поперечна частина, а частина вздовж поля не дає нічого.", + "p3": "Крихітні магніти ядер швидко збиваються з такту, бо деякі кружляють трохи швидше за інші. Сигнал згасає. На півдорозі до відлуння імпульс 180° перевертає їх, тож швидкі опиняються позаду й наздоганяють решту. Вони знову входять у такт і дають відлуння. Час від імпульсу 90° до відлуння називають часом відлуння. Натискайте кнопки нижче, щоб зупинити модель у кожну з цих митей.", + "p4": "Яскравість кожної тканини визначають два «годинники». T1 показує, за скільки часу сумарний магніт відновлюється вздовж поля приблизно на 63 %. T2 показує, за скільки часу поперечний сигнал згасає приблизно до 37 %. Імпульс 180° усуває лише постійні відмінності між ядрами. Випадкові поштовхи між сусідніми ядрами лишаються, тож відлуння трохи слабшає. Саме цю втрату й описує T2. Жир відновлюється швидко, а ліквор, тобто мозкова рідина, повільно. Оберіть тканину, щоб порівняти. Значення для кожної тканини приблизні, їх підібрано для цієї моделі. Імпульси ще й трохи вас нагрівають, тому апарат тримає це нагрівання в безпечних межах." }, "gradients": { "title": "Котушки, що розрізняють місця", - "p1": "Поки що кожна точка голови відповідає на одній і тій самій частоті, тож з відлуння не зрозуміти, звідки воно прийшло. Це виправляють ще три котушки всередині магніта: градієнтні котушки x, y і z. Градієнт означає поле, що рівномірно змінюється від одного боку до іншого. Кожна котушка трохи додає до основного поля з одного боку від центру й трохи віднімає з іншого.", - "p2": "Зміна невелика. За градієнта до 45 мТл на метр поле на краю голови зсувається приблизно на 5,4 мТл, це третина відсотка основного поля. Цього досить, щоб кожне місце отримало власну частоту. Котушки вмикаються приблизно за 0,2 мс.", - "p3": "У кожної котушки своя робота. Котушка z разом із радіоімпульсом будить лише один тонкий зріз: це вибір зрізу. Котушка y у кожному повторенні по-іншому закручує спіни згори донизу: це фазове кодування. Котушка x працює, поки звучить відлуння, тож за частотою можна відрізнити лівий бік від правого: це частотне кодування. Оберіть котушку, щоб побачити її поле, або стежте за скануванням.", - "p4": "Коли в сильному магніті перемикаються великі струми, котушки смикаються, наче динамік. Звідси той гучний стукіт, який ви чуєте під час сканування. Він може перевищувати 100 децибел, це приблизно як відбійний молоток або й гучніше. Тому вам завжди дають беруші чи навушники." + "p1": "Поки що кожна точка голови відповідає на одній і тій самій частоті, тож з відлуння не зрозуміти, звідки воно прийшло. Це виправляють ще три котушки всередині магніта, градієнтні котушки x, y і z. Градієнтом називають поле, що рівномірно змінюється від одного боку до іншого. Кожна котушка трохи додає до основного поля з одного боку від центру. З іншого боку вона трохи віднімає.", + "p2": "Зміна невелика. Якщо градієнт сягає 45 мТл на метр, поле на краю голови змінюється приблизно на 5,4 мТл. Це третина відсотка від поля 1,5 Тл. Цього досить, щоб кожне місце отримало власну частоту. Котушки вмикаються приблизно за 0,2 мс.", + "p3": "У кожної котушки своя робота. Котушка z разом із радіоімпульсом будить лише один тонкий зріз: це вибір зрізу. Котушка y у кожному повторенні по-новому закручує ядра згори донизу: це фазове кодування. Котушка x працює, поки звучить відлуння, тож за частотою можна відрізнити лівий бік від правого: це частотне кодування. Оберіть котушку, щоб побачити її поле, або стежте за скануванням.", + "p4": "Коли в сильному магніті перемикаються великі струми, котушки смикаються, наче динамік. Звідси той гучний стукіт, який ви чуєте під час сканування. Він може перевищувати 100 децибелів. Це гучно, як відбійний молоток, а то й гучніше. Тому вам завжди дають затички для вух або навушники." }, "picture": { "title": "Як відлуння стає зображенням", - "p1": "Кожне відлуння зберігається як один рядок k-простору. Це сітка, що впорядковує відлуння не за місцем, а за частотою й закручуванням. Одне повторення заповнює один рядок, а наступне змінює закручування й заповнює інший. У цій моделі зображення має розмір 64 × 64, тож потрібно 64 повторення. Справжні знімки голови часто мають 256 рядків. Тут рядки заповнюються від середини до країв, тож грубе зображення з’являється рано.", - "p2": "Математичний крок під назвою перетворення Фур’є робить із цієї сітки зображення. Воно шукає, яка суміш смуг, широких і тонких, складається в картинку. Середина k-простору містить широкі смуги, тобто загальне світло й тінь. Краї містять дрібні деталі. Спробуйте 8, 16, 32 або 64 рядки й подивіться, як зображення стає чіткішим.", - "p3": "Які тканини будуть яскравими, вирішує розклад імпульсів. T1-зважене сканування повторюється кожні 500 мс і слухає рано, на 15 мс. Повторення закінчується, перш ніж повільні тканини відновляться, тож жир яскравий, а ліквор темний. T2-зважене сканування чекає 2 500 мс і слухає на 100 мс. Тепер сяє рідина, що повільно згасає, а сіра речовина світліша за білу. У цій моделі в кожній тканині однаково водню, а кістки й повітря лишаються чорними.", - "p4": "Якщо читати один рядок за повторення, 256 рядків займуть приблизно 2,1 хвилини для T1-сканування і 10,7 для T2. Справжні апарати використовують турбо спін-ехо: за кожне повторення вони зчитують від 4 до 32 рядків. Із 16 рядками T2-сканування триває близько 40 секунд. Ідея молода. Пол Лаутербур опублікував перше МР-зображення 1973 року, а 2003 року він разом із Пітером Менсфілдом отримав за це Нобелівську премію." + "p1": "Кожне відлуння зберігається як один рядок k-простору. Це сітка, що впорядковує відлуння не за місцем, а за частотою й закручуванням. Одне повторення заповнює один рядок. Наступне змінює закручування й заповнює інший. У цій моделі зображення має розмір 64 × 64, тож потрібно 64 повторення. Справжні знімки голови часто мають 256 рядків. Тут рядки заповнюються від середини до країв, тож розмите зображення з’являється рано.", + "p2": "Із цієї сітки зображення отримують математичною операцією, яку називають перетворенням Фур’є. Вона шукає, яка суміш смуг, широких і тонких, складається в картинку. Середина k-простору містить широкі смуги, тобто загальне світло й тінь. Краї містять дрібні деталі. Спробуйте 8, 16, 32 або 64 рядки й подивіться, як зображення стає чіткішим.", + "p3": "Які тканини будуть яскравими, вирішує розклад імпульсів. T1-зважене сканування повторюється кожні 500 мс і слухає рано, на 15 мс. Повторення закінчується, перш ніж повільні тканини відновляться, тож жир яскравий, а ліквор темний. T2-зважене сканування чекає 2 500 мс і слухає на 100 мс. Тепер сяє рідина, сигнал якої згасає повільно, а сіра речовина здається світлішою за білу. У показниках видно яскравість кожної тканини, найяскравіша дорівнює 100 %. Голова тут лише простий малюнок з овалів, а не справжній знімок. У цій моделі в кожній тканині однаково водню, а кістки й повітря лишаються чорними.", + "p4": "Якщо читати один рядок за повторення, 256 рядків займуть приблизно 2,1 хвилини для T1-сканування і 10,7 для T2. Більшість сучасних знімків мозку роблять методом турбо спін-ехо: він зчитує від 4 до 32 рядків за кожне повторення. Із 16 рядками T2-сканування триває близько 40 секунд. Ця модель знімає один зріз. Справжнє сканування голови охоплює багато зрізів. Пол Лаутербур опублікував перше МР-зображення 1973 року. У 2003 році він разом із Пітером Менсфілдом отримав за це Нобелівську премію." } } } From eb17fd1914fc72f29171e20625e06bc94a71051b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:44:04 +0200 Subject: [PATCH 45/82] fix(mri-scanner): apply the review to the Japanese copy --- explainers/mri-scanner/locales/ja.json | 72 +++++++++++++------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json index deade457..c677b773 100644 --- a/explainers/mri-scanner/locales/ja.json +++ b/explainers/mri-scanner/locales/ja.json @@ -17,19 +17,19 @@ "speed": "再生速度", "value": "ステップ{{step}}/{{total}}", "during": "ステップ{{step}}/{{total}}:{{phase}}", - "speedValue": "{{value}}回/分", + "speedValue": "{{value}}回/分", "speedFormat": "1分あたり{{value}}回の繰り返し", "phase": { - "excite": "電波パルス", + "excite": "90°パルス", "encode": "エンコード", - "refocus": "再収束", + "refocus": "180°パルス", "echo": "エコー", "recover": "回復" }, "jump": { - "excite": "電波パルスへ移動", + "excite": "90°パルスへ移動", "encode": "エンコードへ移動", - "refocus": "再収束パルスへ移動", + "refocus": "180°パルスへ移動", "echo": "エコーへ移動", "recover": "回復へ移動" } @@ -97,7 +97,7 @@ } }, "readouts": { - "larmor": "スピンの周波数", + "larmor": "ラーモア周波数", "time": "繰り返しの中の時間", "lines": "k空間のライン" }, @@ -129,7 +129,7 @@ "fringeValue": "トンネルの方向に{{along}}m、横に{{side}}m" }, "spins": { - "larmor": "首振りの周波数", + "larmor": "ラーモア周波数", "larmorBand": "{{value}}、{{band}}", "band": { "belowFm": "FMラジオのすぐ下", @@ -139,8 +139,8 @@ "surplusValue": "100万個あたり約{{perMillion}}個", "count": "水1mm³の中の余分な原子核", "countValue": "約{{mantissa}} × 10{{exponent}}個", - "slowdown": "絵をゆっくりにした倍率", - "slowdownValue": "約{{factor}}倍" + "slowdown": "絵の首振りの遅さ", + "slowdownValue": "本物の約{{factor}}分の1" }, "resonance": { "tip": "傾き角度", @@ -152,26 +152,26 @@ "gradients": { "edge": "頭の端での変化", "share": "主磁場に対する割合", - "spread": "頭全体での周波数の幅", + "spread": "頭の端での周波数のずれ", "rise": "オンになるまでの時間", "role": "この撮影での役目", "roleValue": { "x": "周波数:左右を見分ける", - "y": "位相:1ラインごとにねじる", + "y": "位相:繰り返しごとに新しいねじれ", "z": "スライス:薄い1枚を選ぶ", - "sequence": "撮影の流れに合わせる" + "sequence": "ステップごとに変わる" } }, "picture": { "kspace": "k空間、記録したエコー", "image": "画像", - "scanTime": "1回の繰り返しで1ライン、256ライン", + "scanTime": "1回の繰り返しで1ライン", "scanTimeValue": "{{lines}}ラインで{{minutes}}分", "turbo": "ターボスピンエコー、1回の繰り返しで16ライン", "turboValue": "約{{seconds}}秒", - "empty": "再生を押すと、最初のラインが埋まります", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "まだラインはありません。次のエコーが最初のラインを埋めます。", + "kspaceAlt": "{{total}}ラインのうち{{lines}}ラインが埋まった、k空間の格子", + "imageAlt": "{{total}}ラインのうち{{lines}}ラインから組み立てた、頭のスライス画像" } }, "parts": { @@ -206,45 +206,45 @@ "sections": { "overview": { "title": "体の声に耳をすますトンネル", - "p1": "これは1.5テスラの磁石をもつMRI装置です。テスラは、磁場の強さを表す単位です。あなたが寝台に横になると、寝台はそのままボアへすべり込みます。ボアは幅約70cmのトンネルです。それを囲む装置は長さ約1.6mから1.9m、重さは3トンから6トンあります。", - "p2": "脳を撮るときは、頭部コイルがかごのように頭を囲みます。これはアンテナで、20個から64個の小さな受信器が、体から返ってくるかすかな電波のエコーを聞き取ります。部屋の壁には銅板が張ってあり、外の電波を締め出します。外の電波が入ると、エコーがかき消されてしまうからです。ここではX線は使いません。この周波数の電波がもつエネルギーは、原子から電子をはじき出すのに必要な量の約5000万分の1しかありません。", - "p3": "白いカバーの下には、いくつもの層が輪のように入れ子になっています。冷たい容器に包まれた磁石、3つの傾斜磁場コイル、そしてボディコイルと呼ばれる電波のアンテナです。断面表示(C)をオンにすると見えます。次の章から、ひとつずつ見ていきましょう。", - "p4": "モデルでは1回の繰り返しを追います。繰り返しとは、1枚の画像をつくるために、撮影中に何度も回す短いサイクルです。モデルの1回の繰り返しは、実際の時間で0.5秒から2.5秒です。ところが、その中の電波パルスはほんの数ミリ秒です。そこでタイムラインは時間を引き伸ばしています。パルスがループの大部分を占め、長い休みは縮めてあります。タイムラインにはステップの名前が、リアルタイムの計測値には本当の時間が出ます。何枚も画像を撮る頭の検査全体では、約30分から45分かかります。" + "p1": "これは1.5テスラの磁石をもつMRI装置です。MRI(磁気共鳴画像法)は、英語の頭文字をとった名前です。テスラは、磁場の強さを表す単位です。寝台に横になると、寝台ごとボアの中へ運ばれます。ボアは幅約70cmのトンネルです。それを囲む装置は長さ約1.6mから1.9m、重さは3トンから6トンあります。", + "p2": "脳を撮るときは、頭部コイルがかごのように頭を囲みます。これはアンテナで、20個から64個の小さな受信器が、体から返ってくるかすかな電波のエコーを聞き取ります。部屋の壁には銅板が張ってあります。これが外の電波を締め出し、エコーがかき消されないようにします。ここではX線は使いません。装置が使う電波のエネルギーは、原子から電子をはじき出すのに必要な量の約5000万分の1しかありません。", + "p3": "白いカバーの下には、いくつもの層が輪のように入れ子になっています。外側から順に、冷たい容器に包まれた磁石、3つの傾斜磁場コイル、そして電波のアンテナであるボディコイルです。断面表示(C)をオンにすると見えます。次の章から、ひとつずつ見ていきましょう。", + "p4": "このモデルでは、1回の繰り返しを追います。繰り返しとは、1枚の画像をつくるために、撮影中に何度も回す短いサイクルです。モデルの1回の繰り返しは、実際の時間で0.5秒から2.5秒です。ところが、その中の電波パルスはほんの数ミリ秒です。そのため、タイムラインの長さは実際の時間の比率どおりではありません。数ミリ秒のパルスとエコーが、タイムラインの約3分の2を占めます。長い休みは、残りの3分の1に縮めてあります。タイムラインにはステップの名前が、リアルタイムの計測値には本当の時間が出ます。何枚も画像を撮る頭の検査全体では、約30分から45分かかります。" }, "magnet": { "title": "ずっとオンのままの磁石", "p1": "いちばん大きな部品は主磁石です。ニオブチタンの線を約30kmから40km、コイルに巻いたものです。十分に冷やすと、この線は超伝導になります。電流がまったく抵抗なしに流れる状態です。数百アンペアの電流が、閉じたループの中をぐるぐる回り続けます。いったんループを閉じれば、磁場を保つのに電力はいりません。磁場の弱まりは、1年で0.1%にも届きません。", - "p2": "超伝導を保つため、コイルは4.2K、約−269°Cの液体ヘリウムに浸かっています。深宇宙より約1.5Kあたたかいだけです。昔ながらの磁石はこれを約1500リットル抱えていますが、新しい密閉型の磁石ならわずか7リットルですみます。ヘリウム容器を囲む熱シールドと真空が、魔法瓶のように熱をさえぎります。上に載った小さな冷凍機、コールドヘッドは、沸騰したヘリウムを液体に戻します。昼も夜も動き続け、約6kWから7kWの電力を使います。", - "p3": "磁場の強さは、地球の磁場の数万倍です。地球の磁場は、場所によって違います。3Tを選ぶと、もっと強い磁石と比べられます。外側にあるシールドコイルは逆向きに電流を流し、外へもれる磁場を近くに引き戻します。それでも、0.5mTのラインはトンネルの方向に約4m先まで届きます。このラインを越えて磁石に近づくと、ペースメーカーが安全とは限りません。磁力線をオンにすると見えます。", - "p4": "磁石は決してオフにならないので、入口にはそれを知らせる明かりのついた表示があります。鉄を含むものはトンネルへ飛び込むことがあり、酸素ボンベがこうして人の命を奪った事故もあります。だからスタッフは、入口で人も物もすべて確かめます。線のどこかが温まると、そこで超伝導が止まり、電流が熱に変わります。これがクエンチです。磁場は2秒から5秒でゼロになり、沸騰したヘリウムがクエンチ管を通って、ごうごうと建物の外へ噴き出します。" + "p2": "超伝導を保つため、コイルは4.2ケルビン(K)、約−269°Cの液体ヘリウムに浸かっています。深宇宙より約1.5Kあたたかいだけです。従来型の磁石には約1500リットル入っていますが、新しい密閉型の磁石なら7リットル、またはそれ以下ですみます。ヘリウム容器を囲む熱シールドと真空が、魔法瓶のように熱をさえぎります。上に載った小さな冷凍機、コールドヘッドは、気化したヘリウムを液体に戻します。昼も夜も動き続け、約6kWから7kWの電力を使います。", + "p3": "磁場の強さは、地球の磁場の数万倍です。地球の磁場の強さは、場所によって違います。3テスラを選ぶと、もっと強い磁石と比べられます。外側にあるシールドコイルは、逆向きに電流を流し、外へもれる磁場を引き戻します。それでも、トンネルの方向に約4m先までは、磁場が0.5ミリテスラ(mT)を超えています。0.5mTは、1テスラの2000分の1です。この境目より内側では、ペースメーカーが安全に動かなくなるおそれがあります。床の点線の輪が、この境目を示しています。", + "p4": "磁石は決してオフにならないので、入口には、磁場が出ていることを知らせる表示が光っています。鉄を含むものは、トンネルへ飛び込むことがあります。酸素ボンベが吸い込まれて、人が亡くなった事故もあります。だからスタッフは、入口で人も物もすべて確かめます。線のどこかが温まると、そこで超伝導が止まり、電流が熱に変わります。これがクエンチです。磁場は2秒から5秒でゼロになります。気化したヘリウムはクエンチ管を通って、ごうごうと建物の外へ噴き出します。" }, "spins": { "title": "水素は傾いて、首を振る", "p1": "体の原子のおよそ10個に6個は水素で、ほとんどは水と脂肪の中にあります。水素原子の中心、原子核は陽子1個だけです。これが小さな磁石のようにふるまいます。MRIが水素に耳をすますのは、水素がどこにでもあり、いちばん強い信号を出すからです。", - "p2": "磁場の中で、この小さな磁石は、ただ磁場の向きを指すわけではありません。歳差運動をします。傾いて回るこまのように、磁場の向きを軸にして首を振るのです。1.5Tでは1秒に約6400万回、63.9MHzで首を振ります。この速さがラーモア周波数です。磁場が強いほど高くなり、3Tでは127.7MHzになります。どちらもFMラジオに近く、一方はその帯域のすぐ下、もう一方はすぐ上です。", - "p3": "原子核がみんな同じ向きにそろうわけではありません。体温で揺さぶられ、ほとんどばらばらの向きを向いています。磁場と同じ向きに傾くものは、逆向きのものより100万個あたり約5個多いだけです。それでもこの小さな差は、水1立方ミリメートルの中で約3 × 10¹⁴個になります。これが集まって、小さな正味の磁石になります。装置が相手にするのは、この矢印です。", - "p4": "挿入図の矢印は、何倍にも拡大した絵です。頭の中の実際の一点ではありません。傾きも、見えるように本物よりずっと大きく描いています。絵の首振りは1秒に半回転で、1.5Tの本物より約1億2800万倍ゆっくりです。3Tに切り替えると、周波数も余分な原子核の数も2倍になるのがわかります。" + "p2": "磁場の中で、この小さな磁石は、ただ磁場の向きを指すわけではありません。歳差運動をします。傾いて回るこまのように、磁場の向きを軸にして首を振るのです。1.5テスラでは1秒に約6400万回、63.9MHzで首を振ります。この首振りの速さを、ラーモア周波数といいます。磁場が強いほど高くなり、3テスラでは127.7MHzになります。どちらもFMラジオに近く、一方はその帯域のすぐ下、もう一方はすぐ上です。", + "p3": "原子核がみんな同じ向きにそろうわけではありません。体温で揺さぶられ、ほとんどばらばらの向きを向いています。磁場と同じ向きに傾くものは、逆向きのものより100万個あたり約5個多いだけです。それでもこの小さな差は、水1立方ミリメートルの中で約3.3 × 10¹⁴個になります。これが集まって、小さな正味の磁石になります。装置が相手にするのは、この矢印です。", + "p4": "挿入図の矢印は、何倍にも拡大した絵です。頭の中の実際の一点ではありません。傾きも、見えるように本物よりずっと大きく描いています。再生を押すと、矢印が回るのが見られます。絵の動きは、ものすごくゆっくりにしてあります。どれだけ遅くしたかは、計測値に出ます。この数字は、再生速度によっても変わります。3テスラに切り替えてみてください。本物の周波数も余分な原子核の数も2倍になりますが、絵の速さは変わりません。" }, "resonance": { "title": "電波のパルスとそのエコー", - "p1": "正味の磁石に話をさせるため、装置はラーモア周波数ぴったりの短い電波パルスを送ります。このように首振りに合わせることを共鳴といいます。ブランコに乗った子どもを、ちょうどいいタイミングで押すのと同じです。パルスを出すのはボディコイルで、トンネルのすぐ内側にある、銅の棒を並べたかごです。パルスの長さは、長くても数ミリ秒です。", - "p2": "90°パルスは、正味の磁石を真横に寝るまで倒します。磁石はそこで首を振り続け、頭部コイルのそばを通るたびに、数マイクロボルトのごく小さな電圧を生みます。これが信号です。傾き角度のスライダーを動かしてみてください。信号を出すのは横向きの部分だけで、磁場に沿った部分は信号を出しません。", - "p3": "スピンはすぐに足並みが乱れます。ほかより少し速く首を振るものがあるからです。そのため信号は弱まっていきます。エコーまでの半分のところで、180°パルスがスピンをひっくり返します。すると速いものが後ろに回り、やがて追いつきます。スピンは再び足並みをそろえ、エコーを出します。そのタイミングがエコー時間です。チップを使うと、それぞれの瞬間で止められます。", - "p4": "それぞれの組織がどれくらい明るく見えるかは、2つの時計で決まります。T1は、正味の磁石が磁場に沿った向きに、元の約63%まで戻るのにかかる時間です。T2は、スピンの足並みが乱れて、横向きの信号が約37%まで弱まるのにかかる時間です。脂肪は早く戻り、脳脊髄液はゆっくり戻ります。組織を選んで比べてみましょう。パルスは体を少しあたためるので、装置はその発熱を安全な範囲に抑えています。" + "p1": "正味の磁石から信号を引き出すため、装置はラーモア周波数ぴったりの短い電波のパルス(RFパルス)を送ります。このように首振りに合わせることを共鳴といいます。ブランコに乗った子どもを、ちょうどいいタイミングで押すのと同じです。パルスを出すのはボディコイルで、トンネルのすぐ内側にある、銅の棒を並べたかごです。パルスの長さは、長くても数ミリ秒です。", + "p2": "90°パルスは、正味の磁石を真横に寝るまで倒します。磁石はそこで首を振り続けます。頭部コイルのそばを通るたびに、マイクロボルト単位のごく小さな電圧を生みます。これが信号です。傾き角度のスライダーを動かしてみてください。信号を出すのは横向きの部分だけで、磁場に沿った部分は信号を出しません。", + "p3": "小さな磁石たちは、すぐに足並みが乱れます。ほかより少し速く首を振るものがあるからです。そのため信号は弱まっていきます。エコーまでの半分のところで、180°パルスが磁石たちをひっくり返します。すると速いものが後ろに回り、やがて追いつきます。磁石たちは再び足並みをそろえ、エコーを出します。90°パルスからエコーまでの時間が、エコー時間です。下のボタンで、それぞれの瞬間に止められます。", + "p4": "パルスのあと、磁石たちは少しずつ元の状態に戻ります。これを緩和といいます。それぞれの組織がどれくらい明るく見えるかは、緩和を表す2つの時計で決まります。T1は、正味の磁石が磁場に沿った向きに、元の約63%まで戻るのにかかる時間です。T2は、横向きの信号が約37%まで弱まるのにかかる時間です。180°パルスで元に戻せるのは、いつも変わらない速さの違いだけです。となり合う磁石どうしのでたらめな押し合いによるずれは残るので、エコーは少し弱くなります。この弱まりがT2です。脂肪は早く戻り、脳脊髄液はゆっくり戻ります。組織を選んで比べてみましょう。組織ごとの値は、このモデル用に選んだおおよその数字です。パルスは体を少しあたためるので、装置はその発熱を安全な範囲に抑えています。" }, "gradients": { "title": "場所を見分けるコイル", - "p1": "ここまでは、頭のどの場所も同じ周波数で答えるので、エコーがどこから来たのかわかりません。それを解決するのが、磁石の内側にあるもう3つのコイル、x、y、zの傾斜磁場コイルです。傾斜磁場とは、片側からもう片側へ、強さが一定の割合で変わっていく磁場です。それぞれのコイルは、中心より片側では主磁場を少し強め、反対側では少し弱めます。", - "p2": "変化はわずかです。1mあたり最大45mTの傾きでも、頭の端での磁場の変化は約5.4mTで、主磁場の3分の1パーセントほどです。それでも、場所ごとに違う周波数をもたせるには十分です。コイルは約0.2ミリ秒でオンになります。", + "p1": "ここまでは、頭のどの場所も同じ周波数で答えるので、エコーがどこから来たのかわかりません。それを解決するのが、磁石の内側にあるもう3つのコイルです。x、y、zの傾斜磁場コイルです。傾斜磁場とは、片側からもう片側へ、強さが一定の割合で変わっていく磁場です。それぞれのコイルは、中心より片側で主磁場を少し強めます。反対側では少し弱めます。", + "p2": "変化はわずかです。どのコイルも、磁場を1mあたり最大45mT変えます。頭の端では、その変化は約5.4mTになります。1.5テスラの磁場の約0.3%です。それでも、場所ごとに違う周波数をもたせるには十分です。コイルは約0.2ミリ秒でオンになります。", "p3": "コイルにはそれぞれ役目があります。zコイルは電波パルスと組んで、薄い1枚の断面だけを目覚めさせます。これがスライス選択です。yコイルは繰り返しのたびに、上から下へ違うねじれを与えます。これが位相エンコードです。xコイルはエコーを聞いているあいだに働くので、周波数で左右がわかります。これが周波数エンコードです。コイルを選んでその磁場を見るか、撮影の流れを追ってみましょう。", "p4": "強い磁石の中で大きな電流を切り替えると、スピーカーのようにコイルががくんと揺れます。撮影中に聞こえる、あの大きなガンガンという音の正体です。100デシベルを超えることもあり、削岩機と同じか、それ以上の大きさです。だから撮影のときは、必ず耳栓かヘッドホンを渡されます。" }, "picture": { "title": "エコーから画像へ", - "p1": "エコーは1つずつ、k空間の1ラインとして記録されます。k空間とは、エコーを場所ではなく、周波数とねじれで並べた格子です。1回の繰り返しで1ラインが埋まり、次の繰り返しではねじれを変えて別のラインを埋めます。モデルの画像は64 × 64なので、64回の繰り返しが必要です。実際の頭の撮影では、256ラインを使うことがよくあります。ここではラインを真ん中から外へ向かって埋めるので、早いうちから大まかな画像が見えてきます。", - "p2": "フーリエ変換という数学の手順が、この格子を画像に変えます。太いものから細いものまで、どんな縞模様をどう混ぜれば画像になるかを見つけるのです。k空間の真ん中には太い縞、つまり全体の明るさと暗さが入っています。端には細かな部分が入っています。8、16、32、64ラインを試して、画像がくっきりしていくのを見てみましょう。", - "p3": "どの組織が明るく写るかは、タイミングで決まります。T1強調の撮影は500ミリ秒ごとに繰り返し、早めの15ミリ秒で聞き取ります。ゆっくりの組織が戻りきる前に繰り返しが終わるので、脂肪は明るく、脳脊髄液は暗く写ります。T2強調の撮影は2500ミリ秒待ち、100ミリ秒で聞き取ります。今度はゆっくり消える液体が光り、灰白質が白質より明るくなります。このモデルでは、どの組織も水素の量は同じで、骨と空気は黒いままです。", - "p4": "1回の繰り返しで1ラインずつだと、256ラインにかかる時間は、T1の撮影で約2.1分、T2の撮影で約10.7分です。実際の装置はターボスピンエコーを使い、1回の繰り返しで4ラインから32ラインを読み取ります。16ラインなら、T2の撮影は約40秒です。このアイデアはまだ新しいものです。ポール・ラウターバーが最初のMR画像を発表したのは1973年で、彼とピーター・マンスフィールドは、その功績で2003年のノーベル賞を共に受けました。" + "p1": "エコーは1つずつ、k空間の1ラインとして記録されます。k空間とは、エコーを場所ではなく、周波数とねじれで並べた格子です。1回の繰り返しで、1ラインが埋まります。次の繰り返しでは、ねじれを変えて別のラインを埋めます。モデルの画像は64 × 64なので、64回の繰り返しが必要です。実際の頭の撮影では、256ラインを使うことがよくあります。ここではラインを真ん中から外へ向かって埋めるので、早いうちから大まかな画像が見えてきます。", + "p2": "フーリエ変換という計算が、この格子を画像に変えます。太いものから細いものまで、どんな縞模様をどう混ぜれば画像になるかを見つけるのです。k空間の真ん中には太い縞、つまり全体の明るさと暗さが入っています。端には細かな部分が入っています。8、16、32、64ラインを試して、画像がくっきりしていくのを見てみましょう。", + "p3": "どの組織が明るく写るかは、タイミングで決まります。T1強調の撮影は500ミリ秒ごとに繰り返し、早めの15ミリ秒で聞き取ります。ゆっくりの組織が戻りきる前に繰り返しが終わるので、脂肪は明るく、脳脊髄液は暗く写ります。T2強調の撮影は2500ミリ秒待ち、100ミリ秒で聞き取ります。今度はゆっくり消える液体が光り、灰白質が白質より明るく見えます。計測値は、いちばん明るい組織を100%として、それぞれの明るさを示します。ここでの頭は、楕円を組み合わせた簡単な絵です。本物の画像ではありません。このモデルでは、どの組織も水素の量は同じで、骨と空気は黒いままです。", + "p4": "1回の繰り返しで1ラインずつだと、256ラインにかかる時間は、T1の撮影で約2.1分、T2の撮影で約10.7分です。いまの脳の撮影のほとんどはターボスピンエコーを使い、1回の繰り返しで4ラインから32ラインを読み取ります。16ラインなら、T2の撮影は約40秒です。このモデルが撮るのは、1枚のスライスだけです。実際の頭の撮影では、何枚も撮ります。ポール・ラウターバーは、1973年に最初のMR画像を発表しました。彼とピーター・マンスフィールドは、その功績で2003年のノーベル賞を共に受けました。" } } } From a1116e68167cd4744f272694aaf5e4cd8ec0abfd Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:44:04 +0200 Subject: [PATCH 46/82] fix(mri-scanner): apply the review to the Chinese copy --- explainers/mri-scanner/locales/zh.json | 86 +++++++++++++------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json index ab3837ef..69042a99 100644 --- a/explainers/mri-scanner/locales/zh.json +++ b/explainers/mri-scanner/locales/zh.json @@ -2,8 +2,8 @@ "meta": { "title": "MRI扫描仪如何工作", "eyebrow": "医学影像", - "tagline": "一块巨大的磁铁,一道无线电脉冲,再加上你体内的氢:扫描仪怎样听见微弱的回波,又怎样把它变成图像。", - "description": "用3D模型看MRI扫描仪:从不关闭的磁铁,氢怎样回应无线电脉冲,咚咚作响的线圈,还有回波怎样变成图像。", + "tagline": "一块巨大的磁体,一道无线电脉冲,再加上你体内的氢:扫描仪怎样听见微弱的回波,又怎样把它变成图像。", + "description": "用3D模型看MRI扫描仪:从不关闭的磁体,氢怎样回应无线电脉冲,咚咚作响的线圈,还有回波怎样变成图像。", "summary": "超导磁体、倾斜的氢原子核、无线电脉冲和它的回波、梯度线圈,还有k空间。", "socialAlt": "一台剖开的MRI扫描仪,隧道外面是一层套一层的磁体结构,旁边是一张头部断层图像和标题:MRI扫描仪如何工作。" }, @@ -17,19 +17,19 @@ "speed": "播放速度", "value": "第{{step}}步,共{{total}}步", "during": "第{{step}}步,共{{total}}步:{{phase}}", - "speedValue": "{{value}}次/分钟", + "speedValue": "{{value}}次/分钟", "speedFormat": "每分钟重复{{value}}次", "phase": { - "excite": "无线电脉冲", + "excite": "90°脉冲", "encode": "编码", - "refocus": "重聚焦", + "refocus": "180°脉冲", "echo": "回波", "recover": "恢复" }, "jump": { - "excite": "跳到无线电脉冲", + "excite": "跳到90°脉冲", "encode": "跳到编码", - "refocus": "跳到重聚焦脉冲", + "refocus": "跳到180°脉冲", "echo": "跳到回波", "recover": "跳到恢复阶段" } @@ -56,7 +56,7 @@ "x": "x线圈", "y": "y线圈", "z": "z线圈", - "sequence": "跟随扫描" + "sequence": "按扫描顺序" }, "momentOptions": { "pulse90": "90°脉冲", @@ -97,14 +97,14 @@ } }, "readouts": { - "larmor": "自旋频率", + "larmor": "拉莫尔频率", "time": "重复周期内的时间", "lines": "k空间行数" }, "units": { "mhz": "{{value}} MHz", - "msOf": "{{value}}/{{total}}毫秒", - "linesOf": "{{value}}/{{total}}", + "msOf": "{{value}}/{{total}}毫秒", + "linesOf": "{{value}}/{{total}}", "degrees": "{{value}}°", "percent": "{{value}}%", "ms": "{{value}}毫秒", @@ -129,7 +129,7 @@ "fringeValue": "沿隧道方向{{along}}米,侧面{{side}}米" }, "spins": { - "larmor": "摆动频率", + "larmor": "拉莫尔频率", "larmorBand": "{{value}},{{band}}", "band": { "belowFm": "略低于调频广播", @@ -152,26 +152,26 @@ "gradients": { "edge": "头部边缘的变化", "share": "占主磁场的比例", - "spread": "头部两侧的频率差", + "spread": "头部边缘的频率偏移", "rise": "开启时间", "role": "在这次扫描中的任务", "roleValue": { "x": "频率:分出左右", - "y": "相位:每行扭一次", + "y": "相位:每个重复周期换一种扭转", "z": "层面:选出薄薄一层", - "sequence": "跟随扫描序列" + "sequence": "每一步都在变" } }, "picture": { "kspace": "k空间,存下的回波", "image": "图像", - "scanTime": "每个重复周期一行,共256行", + "scanTime": "每个重复周期一行", "scanTimeValue": "{{lines}}行需{{minutes}}分钟", "turbo": "快速自旋回波,每个重复周期16行", "turboValue": "约{{seconds}}秒", - "empty": "按播放键,填入第一行", - "kspaceAlt": "The raw data grid with {{lines}} of {{total}} lines filled", - "imageAlt": "The head slice rebuilt from {{lines}} of {{total}} lines" + "empty": "还没有填入任何一行。下一个回波会填入第一行。", + "kspaceAlt": "k空间网格,已填入{{lines}}行,共{{total}}行", + "imageAlt": "头部断层图像,由{{total}}行中的{{lines}}行重建而成" } }, "parts": { @@ -206,45 +206,45 @@ "sections": { "overview": { "title": "一条倾听你身体的隧道", - "p1": "这是一台MRI扫描仪,磁铁强度为1.5特斯拉。特斯拉是衡量磁场强弱的单位。你躺在检查床上,床把你送进孔腔,也就是一条宽约70厘米的隧道。围着隧道的机器长约1.6到1.9米,重3到6吨。", - "p2": "做脑部扫描时,头线圈像笼子一样罩住你的头。它是一副天线,带有20到64个小接收器,专门听你身体发出的微弱无线电回波。房间四壁衬着铜板,把外面的无线电挡在门外,免得它盖过这个回波。这里没有X射线。这种频率的无线电波能量太小:要把一个电子从原子里打出来,它还差约5000万倍。", - "p3": "白色外壳下面,几层结构像套环一样一层套一层:裹在低温外套里的磁铁、三组梯度线圈,还有一副叫体线圈的无线电天线。打开剖面(C)就能看到它们。接下来几章会逐一介绍。", - "p4": "模型演示一个重复周期,也就是扫描为了拼出一张图像而一遍遍重复的短循环。在这个模型里,一个重复周期实际持续0.5到2.5秒,但其中的无线电脉冲只有几毫秒。所以时间轴被拉伸了:脉冲占了循环的大部分,漫长的休息被压缩。时间轴标出当前步骤,实时读数显示真实时间。一次完整的头部检查要拍很多张图像,大约需要30到45分钟。" + "p1": "这是一台MRI(磁共振成像)扫描仪,磁体强度为1.5特斯拉。特斯拉是衡量磁场强弱的单位。你躺在检查床上,床把你送进孔腔,也就是一条宽约70厘米的隧道。围着隧道的机器长约1.6到1.9米,重3到6吨。", + "p2": "做脑部扫描时,头线圈像笼子一样罩住你的头。它是一副天线,带有20到64个小接收器,专门听你身体发出的微弱无线电回波。房间四壁衬着铜板。铜板把外面的无线电波挡在门外,免得它们盖过这个回波。这里没有X射线。扫描仪的无线电波能量极小,只有把电子打出原子所需能量的约五千万分之一。", + "p3": "白色外壳下面,几层结构像套环一样一层套一层。从外往里依次是:装在低温容器里的磁体、三组梯度线圈,还有体线圈,也就是一副无线电天线。打开剖面(C)就能看到它们。接下来几章会逐一介绍。", + "p4": "模型演示一个重复周期,也就是扫描为了拼出一张图像而一遍遍重复的短循环。在这个模型里,一个重复周期实际持续0.5到2.5秒,但其中的无线电脉冲只有几毫秒。所以时间轴没有按真实比例来画。这短短几毫秒的脉冲和回波,却占了时间轴约三分之二。漫长的休息被压缩在最后三分之一里。时间轴标出当前步骤,实时读数显示真实时间。一次完整的头部检查要拍很多张图像,大约需要30到45分钟。" }, "magnet": { - "title": "一块从不关闭的磁铁", - "p1": "最大的部件是主磁体:约30到40千米长的铌钛导线绕成的线圈。冷却到足够低的温度,这种导线就会超导,也就是电流在里面流动时完全没有电阻。几百安培的电流在闭合回路里一圈圈地流。回路一旦闭合,维持磁场就不再需要电力。一年下来,磁场减弱不到千分之一。", - "p2": "为了保持超导,线圈泡在4.2 K的液氦里,约合−269 °C。这只比宇宙深空高约1.5 K。传统磁体要装约1500升液氦,较新的密封磁体只需7升。氦容器外面有隔热屏和真空层,原理就像保温瓶。顶上有一台小冰箱,叫冷头,它把沸腾汽化的氦重新变回液体;它日夜运转,耗电约6到7千瓦。", - "p3": "这个磁场比地球磁场强几万倍,而地球磁场的强弱因地而异。选3 T,看看更强的磁铁是什么样。外侧还有额外的屏蔽线圈,电流方向相反,把外泄的磁场收拢在近处。即便如此,0.5 mT线沿隧道方向仍延伸约4米;在这条线以内,心脏起搏器可能就不再安全。打开磁力线就能看到它。", - "p4": "磁铁从不关闭,所以门口有一块亮着的警示牌提醒这一点。任何含铁的东西都可能被吸进隧道,氧气瓶就曾这样夺去人命。所以工作人员会在门口检查每个人、每样东西。如果导线某处变热,它就不再超导,电流变成热量:这叫失超。磁场在2到5秒内降到零,沸腾的氦经由失超管呼啸着冲出大楼。" + "title": "一块从不关闭的磁体", + "p1": "最大的部件是主磁体:约30到40千米长的铌钛导线绕成的线圈。冷却到足够低的温度,这种导线就会进入超导状态,也就是电流在里面流动时完全没有电阻。几百安培的电流在闭合回路里一圈圈地流。回路一旦闭合,维持磁场就不再需要电力。一年下来,磁场减弱不到千分之一。", + "p2": "为了保持超导,线圈泡在4.2开尔文(K)的液氦里,约合−269 °C。这只比宇宙深空高约1.5 K。传统磁体要装约1500升液氦,较新的密封磁体只需7升,甚至更少。氦容器外面有隔热屏和真空层,原理就像保温瓶。顶上有一台小型制冷机,叫冷头,它把蒸发的氦重新变回液体。它日夜运转,耗电约6到7千瓦。", + "p3": "这个磁场比地球磁场强几万倍。地球自身的磁场强弱因地而异。选3特斯拉,比较一下更强的磁体。外侧还有额外的屏蔽线圈,电流方向相反,把向外泄漏的磁场拉回来。即便如此,沿隧道方向直到约4米远处,磁场仍高于0.5 mT,也就是两千分之一特斯拉。在这个范围内,心脏起搏器可能无法安全工作。地面上的虚线圈标出了它的边界。", + "p4": "磁体从不关闭,所以门口有一块亮着的警示牌提醒这一点。任何含铁的东西都可能被吸进隧道,氧气瓶就曾这样夺去人命。因此工作人员会在门口检查每个人、每样东西。如果导线某处变热,它就不再超导,电流变成热量。这叫失超。磁场在2到5秒内降到零。沸腾的氦经由失超管呼啸着冲出大楼。" }, "spins": { "title": "氢核会倾斜,也会摆动", "p1": "你体内约十分之六的原子是氢,大多在水和脂肪里。氢原子的核心叫原子核,只有一个质子。它就像一块小磁铁。MRI专听氢,因为氢到处都有,信号也最强。", - "p2": "在磁场里,每块小磁铁并不是简单地顺着磁场指向。它会进动:像一个倾斜旋转的陀螺,绕着磁场方向摆动。在1.5 T下,它每秒摆动约6400万次,也就是63.9 MHz。这个频率叫拉莫尔频率,磁场越强它越高,在3 T下达到127.7 MHz。两者都接近调频广播,一个略低于这个频段,一个略高于它。", - "p3": "这些原子核并没有全都排整齐。体温让它们不停晃动,所以它们几乎指向四面八方。顺着磁场倾斜的核,只比逆着磁场的多出约百万分之五。差别看似很小,可一立方毫米的水里,多出的核仍有约3 × 10¹⁴个。它们合在一起,形成一块微小的净磁体,也就是扫描仪要用的那个箭头。", - "p4": "插图里的箭头只是示意,放大了很多倍,并不是头部里真实的某个位置。为了让你看清,倾斜也画得比实际夸张得多。画出来的摆动每秒转半圈,比1.5 T下真实的摆动慢约1.28亿倍。切换到3 T,看频率和多出的核都翻一倍。" + "p2": "在磁场里,每块小磁铁并不是简单地顺着磁场指向。它会进动:像一个倾斜旋转的陀螺,绕着磁场方向摆动。在1.5特斯拉下,它每秒摆动约6400万次,也就是63.9 MHz。这个速率就是摆动的音高,叫拉莫尔频率。磁场越强,它越高,在3特斯拉下达到127.7 MHz。两者都接近调频广播,一个略低于这个频段,一个略高于它。", + "p3": "这些原子核并没有全都排整齐。体温让它们不停晃动,所以它们几乎指向四面八方。顺着磁场倾斜的核,只比逆着磁场的多出约百万分之五。差别看似很小,可一立方毫米的水里,多出的核仍有约3.3 × 10¹⁴个。它们合在一起,形成一块微小的净磁体,也就是扫描仪要用的那个箭头。", + "p4": "插图里的箭头只是示意,放大了很多倍,并不是头部里真实的某个位置。为了让你看清,倾斜也画得比实际夸张得多。按播放键,看它们转起来。画面被大大放慢了,读数会显示放慢了多少倍。这个倍数也会随播放速度变化。切换到3特斯拉:真实的频率和多出的核都会翻一倍,但画面的节奏不变。" }, "resonance": { "title": "无线电脉冲和它的回波", - "p1": "要让净磁体开口说话,扫描仪会发出一道短促的无线电脉冲,频率正好等于拉莫尔频率。像这样合上摆动的节拍,就叫共振,就像在恰好的时刻推一把荡秋千的孩子。脉冲来自体线圈,这是一个由铜条围成的笼子,紧贴在隧道内侧。它最多持续几毫秒。", - "p2": "一道90°脉冲把净磁体放倒,让它横躺下来。它在那里继续摆动,扫过头线圈时会产生几微伏的微小电压。这就是信号。拖动翻转角滑块:只有横向分量产生信号,沿磁场的分量不产生。", - "p3": "自旋很快就会乱了步调,因为有些摆得稍快,有些稍慢,信号随之减弱。走到回波的半路时,一道180°脉冲把它们翻过来,快的反而落在后面,于是又追了上来。它们重新同步,发出一个回波,这个时刻叫回波时间。用这些按钮可以停在每个时刻。", - "p4": "两个时钟决定每种组织看上去有多亮。T1是净磁体沿磁场方向恢复约63%所需的时间。T2是自旋失去同步、横向信号衰减到约37%所需的时间。脂肪恢复得快,脑脊液恢复得慢;选一种组织比较一下。脉冲还会让你身体微微发热,所以扫描仪会把升温控制在安全范围内。" + "p1": "要让净磁体开口说话,扫描仪会发出一道短促的无线电脉冲(射频脉冲),频率正好等于拉莫尔频率。像这样合上摆动的节拍,就叫共振,就像在恰好的时刻推一把荡秋千的孩子。脉冲来自体线圈,这是一个由铜条围成的笼子,紧贴在隧道内侧。它最多持续几毫秒。", + "p2": "一道90°脉冲把净磁体放倒,让它横躺下来。它在那里继续摆动。扫过头线圈时,它会产生一个微小的电压,只有微伏级别。这就是信号。拖动翻转角滑块:只有横向分量产生信号,沿磁场的分量不产生。", + "p3": "这些小磁铁很快就会乱了步调,因为有些摆得稍快,有些稍慢。信号随之减弱。到回波时间的一半时,一道180°脉冲把它们翻过来,快的反而落在后面,于是又追了上来。它们重新同步,发出一个回波。从90°脉冲到回波的这段时间,叫回波时间。用下面的按钮,可以停在每个时刻。", + "p4": "两个时钟决定每种组织看上去有多亮,它们叫弛豫时间。T1是净磁体沿磁场方向恢复约63%所需的时间。T2是横向信号衰减到约37%所需的时间。180°翻转只能抵消稳定不变的快慢差别。相邻小磁铁之间随机的扰动却还在,所以回波会弱一些。这部分损失就是T2。脂肪恢复得快,脑脊液恢复得慢。选一种组织比较一下。各组织的数值是为这个模型选的大致数字。脉冲还会让你身体微微发热,所以扫描仪会把升温控制在安全范围内。" }, "gradients": { "title": "分辨位置的线圈", - "p1": "到目前为止,头部每个位置都以同样的频率回应,所以回波分不出自己来自哪里。磁体里面另外三组线圈解决了这个问题:梯度线圈x、y和z。梯度是指从一侧到另一侧均匀变化的磁场。每组线圈在中心的一侧给主磁场加一点,在另一侧减一点。", - "p2": "这个变化很小。梯度最高可达每米45 mT,这时头部边缘的磁场只偏移约5.4 mT,相当于主磁场的三百分之一。这已足够让每个位置都有自己的频率。线圈约0.2毫秒就能开启。", - "p3": "每组线圈各有分工。z线圈和无线电脉冲配合,只唤醒薄薄的一层:这叫层面选择。y线圈让每个重复周期从上到下带上不同的扭转:这叫相位编码。x线圈在接收回波时工作,于是频率高低就能分出左右:这叫频率编码。选一组线圈看它的磁场,或者跟随扫描。", - "p4": "在强磁场里切换大电流,会让线圈像扬声器一样猛地震动。这就是扫描时你听到的响亮敲击声。它可能超过100分贝,和风镐差不多响,甚至更响。所以你总会拿到耳塞或耳机。" + "p1": "到目前为止,头部每个位置都以同样的频率回应,所以回波分不出自己来自哪里。磁体里面另外三组线圈解决了这个问题。它们就是梯度线圈x、y和z。梯度是指从一侧到另一侧均匀变化的磁场。每组线圈在中心的一侧给主磁场加一点。在另一侧,它减一点。", + "p2": "这个变化很小。每组线圈让磁场每米最多变化45 mT。到了头部边缘,加起来约5.4 mT。这相当于1.5特斯拉磁场的三百分之一。这已足够让每个位置都有自己的频率。线圈约0.2毫秒就能开启。", + "p3": "每组线圈各有分工。z线圈和无线电脉冲配合,只唤醒薄薄的一层:这叫层面选择。y线圈让每个重复周期从上到下带上不同的扭转:这叫相位编码。x线圈在接收回波时工作,于是频率高低就能分出左右:这叫频率编码。选一组线圈看它的磁场,或者按扫描顺序观看。", + "p4": "在强磁场里切换大电流,会让线圈像扬声器一样猛地震动。这就是扫描时你听到的响亮敲击声。它可能超过100分贝,和风镐差不多响,甚至更响。所以检查时一定会给你耳塞或耳机。" }, "picture": { "title": "从回波到图像", - "p1": "每个回波存成k空间里的一行。k空间是一张网格,按频率和扭转来排列回波,而不是按位置。一个重复周期填一行;下一个周期换一种扭转,再填一行。这个模型的图像是64 × 64,所以要64个重复周期;真实的头部扫描常用256行。这里的行从中间往外填,所以粗略的图像很早就会出现。", - "p2": "一个叫傅里叶变换的数学步骤,把这张网格变成图像。它找出哪些粗细不同的条纹叠在一起,正好拼成这张图。k空间的中间存着粗条纹,也就是整体的明暗。边缘存着细节。试试8、16、32或64行,看图像怎样变清晰。", - "p3": "时间安排决定哪些组织显得亮。T1加权扫描每500毫秒重复一次,早早地在15毫秒时就听回波。重复周期在慢组织恢复之前就结束了,所以脂肪亮,脑脊液暗。T2加权扫描要等2500毫秒,在100毫秒时听:衰减慢的液体这时发亮,灰质也比白质亮。在这个模型里,每种组织含的氢一样多,骨头和空气始终是黑的。", - "p4": "如果每个重复周期只读一行,256行的T1扫描约需2.1分钟,T2扫描约需10.7分钟。真实的扫描仪用快速自旋回波,每个重复周期读4到32行;读16行时,T2扫描只需约40秒。这个想法还很年轻。保罗·劳特伯在1973年发表了第一张磁共振图像,他和彼得·曼斯菲尔德因此共同获得2003年诺贝尔奖。" + "p1": "每个回波存成k空间里的一行。k空间是一张网格,按频率和扭转来排列回波,而不是按位置。一个重复周期填一行。下一个周期换一种扭转,再填一行。这个模型的图像是64 × 64,所以要64个重复周期。真实的头部扫描常用256行。这里的行从中间往外填,所以粗略的图像很早就会出现。", + "p2": "一种叫傅里叶变换的计算,把这张网格变成图像。它找出哪些粗细不同的条纹叠在一起,正好拼成这张图。k空间的中间存着粗条纹,也就是整体的明暗。边缘存着细节。试试8、16、32或64行,看图像怎样变清晰。", + "p3": "时间安排决定哪些组织显得亮。T1加权扫描每500毫秒重复一次,早早地在15毫秒时就听回波。恢复慢的组织还没回升,重复周期就结束了,所以脂肪亮,脑脊液暗。T2加权扫描要等2500毫秒,在100毫秒时听。这时衰减慢的液体发亮,灰质看起来也比白质亮。读数显示每种组织有多亮,最亮的记为100%。这里的头部是用椭圆拼成的简单示意图,不是真实的扫描。在这个模型里,每种组织含的氢一样多,骨头和空气始终是黑的。", + "p4": "如果每个重复周期只读一行,256行的T1扫描约需2.1分钟,T2扫描约需10.7分钟。如今大多数脑部扫描都用快速自旋回波,每个重复周期读4到32行。读16行时,T2扫描只需约40秒。这个模型只拍一层。真实的头部扫描要拍很多层。保罗·劳特伯在1973年发表了第一张磁共振图像。他和彼得·曼斯菲尔德因此共同获得2003年诺贝尔奖。" } } } From c16d108859b7ae6e200d02f91124150570aedd4a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:53 +0200 Subject: [PATCH 47/82] feat(mri-scanner): add the scene constants, finishes and sweep geometry --- explainers/mri-scanner/src/scene/constants.ts | 51 ++++++ explainers/mri-scanner/src/scene/finishes.ts | 108 ++++++++++++ .../mri-scanner/src/scene/geometry/builder.ts | 48 +++++ .../src/scene/geometry/geometry.test.ts | 130 ++++++++++++++ .../mri-scanner/src/scene/geometry/profile.ts | 87 ++++++++++ .../mri-scanner/src/scene/geometry/sweep.ts | 164 ++++++++++++++++++ .../mri-scanner/src/scene/parts/context.ts | 81 +++++++++ 7 files changed, 669 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/constants.ts create mode 100644 explainers/mri-scanner/src/scene/finishes.ts create mode 100644 explainers/mri-scanner/src/scene/geometry/builder.ts create mode 100644 explainers/mri-scanner/src/scene/geometry/geometry.test.ts create mode 100644 explainers/mri-scanner/src/scene/geometry/profile.ts create mode 100644 explainers/mri-scanner/src/scene/geometry/sweep.ts create mode 100644 explainers/mri-scanner/src/scene/parts/context.ts diff --git a/explainers/mri-scanner/src/scene/constants.ts b/explainers/mri-scanner/src/scene/constants.ts new file mode 100644 index 00000000..10a01c9a --- /dev/null +++ b/explainers/mri-scanner/src/scene/constants.ts @@ -0,0 +1,51 @@ +type Triple = [number, number, number]; + +export const HAZE = { colour: '#2b3442', near: 18, far: 60 } as const; + +export const HIGHLIGHT_DIM = { saturation: 0.55, brightness: 0.62, emissive: 0.4 } as const; + +export const SCENE_LIMITS = { + cameraNear: 0.02, + cameraFar: 80, + maxPolarAngle: Math.PI * 0.49, + cameraMinDistance: 0.4, + cameraMaxDistance: 14, +} as const; + +export const LIGHT_RIG = { + distance: 10, + key: { color: '#fff4e6', intensity: 1.6, direction: [0.2, 1, 0.55] as Triple }, + fill: { color: '#9fb8d6', intensity: 0.45, direction: [-0.7, 0.45, 0.35] as Triple }, + rim: { color: '#cfe6ff', intensity: 0.9, direction: [1, 0.3, 0.25] as Triple }, +} as const; + +export const ROOM_DETAIL = { + floorTile: 0.5, + textureSize: 256, + skirtingHeight: 0.12, + skirtingInset: 0.004, + windowDepth: 0.14, + frameWidth: 0.06, + glassInset: 0.07, + lightPanel: { size: 0.6, gap: 0.004, columns: [-1.6, 0, 1.6], rows: [-0.9, 1.0, 2.3] }, + controlRoom: { depth: 1.6, deskTop: 0.75, deskDepth: 0.6, deskHeight: 0.04 }, + monitor: { width: 0.5, height: 0.3, depth: 0.03, lift: 0.22, spread: 0.52, yaw: 0.35 }, + pictureSize: 64, +} as const; + +export const SUBJECT_DETAIL = { + pedestal: { z: [1.35, 2.75] as [number, number], bottomHalf: 0.26, topHalf: 0.2, top: 0.68 }, + foot: { halfWidth: 0.34, height: 0.05, z: [1.2, 2.9] as [number, number] }, + bed: { bottom: 0.7, corner: 0.05 }, + rail: { height: 0.025, depth: 0.012 }, + cradle: { sag: 0.015, segments: 24 }, + pad: { halfWidth: 0.12, length: 0.3, height: 0.035 }, + blanket: { startZ: 0.3, endZ: 1.92, halfWidth: 0.3, columns: 36, rows: 64, loft: 0.02 }, + coil: { + strut: 0.011, + ringTube: 0.014, + strutAngles: [0.75, 1.25, 1.85, 2.35], + window: { halfAngle: 0.5, z: [-0.04, 0.13] as [number, number] }, + baseArc: [Math.PI * 1.08, Math.PI * 0.84] as [number, number], + }, +} as const; diff --git a/explainers/mri-scanner/src/scene/finishes.ts b/explainers/mri-scanner/src/scene/finishes.ts new file mode 100644 index 00000000..b76ca38d --- /dev/null +++ b/explainers/mri-scanner/src/scene/finishes.ts @@ -0,0 +1,108 @@ +import { DoubleSide } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { THEME } from '../theme'; + +export const PAINT = { + floor: THEME.floor, + ceiling: THEME.ceiling, + copper: THEME.copper, + copperDark: THEME.copperDark, + windowGlass: THEME.windowGlass, + consoleGlow: THEME.consoleGlow, + screenFrame: THEME.screenFrame, + table: THEME.table, + cradle: THEME.cradle, + blanket: THEME.blanket, + skin: THEME.skin, + headCoil: THEME.headCoil, + copperSheet: '#9a5d3b', + vinyl: '#6f6c67', + shell: '#1d1f23', + skirting: '#2e241c', + frame: '#c9ced3', + lightPanel: '#fbfcff', + controlShell: '#1c2026', + desk: '#2a2f36', + bezel: '#121417', + pedestal: '#bfc4ca', + rail: '#5d646c', + pad: '#3f4a57', + gown: '#c9d6df', + coilTrim: '#6d7782', + picture: '#ffffff', +} as const; + +const SOFT_REFLECTION = 0.35; +const METAL_REFLECTION = 0.8; +const GLASS_REFLECTION = 1.6; +const GLASS_SHEEN = 0.16; + +function matte(color: string, roughness: number, metalness = 0): MaterialFinish { + return { color, roughness, metalness, envMapIntensity: SOFT_REFLECTION }; +} + +function metal(color: string, roughness: number, metalness = 0.85): MaterialFinish { + return { color, roughness, metalness, envMapIntensity: METAL_REFLECTION }; +} + +function glow(color: string, emissive: string, emissiveIntensity: number): MaterialFinish { + return { ...matte(color, 0.6), emissive, emissiveIntensity }; +} + +export const FINISHES = { + skirting: matte(PAINT.skirting, 0.7), + ceiling: matte(PAINT.ceiling, 0.9), + lightPanel: glow(PAINT.lightPanel, PAINT.lightPanel, 0.9), + frame: metal(PAINT.frame, 0.35, 0.6), + shell: matte(PAINT.shell, 0.85), + glass: { + ...matte(PAINT.windowGlass, 0.04, 0.3), + envMapIntensity: GLASS_REFLECTION, + transparent: true, + opacity: 0.36, + depthWrite: false, + }, + controlShell: matte(PAINT.controlShell, 0.95), + desk: matte(PAINT.desk, 0.6, 0.2), + bezel: matte(PAINT.bezel, 0.4, 0.3), + screenFrame: matte(PAINT.screenFrame, 0.45, 0.3), + pedestal: matte(PAINT.pedestal, 0.45, 0.1), + table: matte(PAINT.table, 0.4, 0.05), + rail: metal(PAINT.rail, 0.4), + cradle: matte(PAINT.cradle, 0.35), + pad: matte(PAINT.pad, 0.85), + skin: matte(PAINT.skin, 0.65), + gown: matte(PAINT.gown, 0.9), + blanket: { ...matte(PAINT.blanket, 0.95), side: DoubleSide }, + headCoil: matte(PAINT.headCoil, 0.3, 0.05), + coilTrim: matte(PAINT.coilTrim, 0.5, 0.2), +} as const satisfies Record; + +export function texturedFinish(base: MaterialFinish, map: MaterialFinish['map']): MaterialFinish { + return { ...base, color: '#ffffff', map }; +} + +export function glowingFinish(map: MaterialFinish['emissiveMap'], intensity = 1): MaterialFinish { + return { + ...matte(PAINT.bezel, 0.3), + emissive: PAINT.picture, + emissiveMap: map, + emissiveIntensity: intensity, + }; +} + +export function sheenedGlass(map: MaterialFinish['emissiveMap']): MaterialFinish { + return { + ...FINISHES.glass, + emissive: PAINT.picture, + emissiveMap: map, + emissiveIntensity: GLASS_SHEEN, + }; +} + +export const SURFACE_LOOK = { + floor: matte('#ffffff', 0.42), + wall: matte('#ffffff', 0.42, 0.55), + console: 0.85, + picture: 1, +} as const; diff --git a/explainers/mri-scanner/src/scene/geometry/builder.ts b/explainers/mri-scanner/src/scene/geometry/builder.ts new file mode 100644 index 00000000..e75068f6 --- /dev/null +++ b/explainers/mri-scanner/src/scene/geometry/builder.ts @@ -0,0 +1,48 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from 'three'; +import type { Vector2 } from 'three'; + +export interface Vertex { + position: Vector3; + normal: Vector3; + uv: Vector2; +} + +const edgeA = new Vector3(); +const edgeB = new Vector3(); +const facing = new Vector3(); + +export class MeshBuilder { + private readonly positions: number[] = []; + private readonly normals: number[] = []; + private readonly uvs: number[] = []; + + triangle(a: Vertex, b: Vertex, c: Vertex): void { + edgeA.subVectors(b.position, a.position); + edgeB.subVectors(c.position, a.position); + facing.copy(a.normal).add(b.normal).add(c.normal); + const ordered = edgeA.cross(edgeB).dot(facing) >= 0 ? [a, b, c] : [a, c, b]; + ordered.forEach((vertex) => { + this.positions.push(vertex.position.x, vertex.position.y, vertex.position.z); + this.normals.push(vertex.normal.x, vertex.normal.y, vertex.normal.z); + this.uvs.push(vertex.uv.x, vertex.uv.y); + }); + } + + quad(a: Vertex, b: Vertex, c: Vertex, d: Vertex): void { + this.triangle(a, b, c); + this.triangle(c, b, d); + } + + get triangleCount(): number { + return this.positions.length / 9; + } + + toGeometry(): BufferGeometry { + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(this.positions, 3)); + geometry.setAttribute('normal', new Float32BufferAttribute(this.normals, 3)); + geometry.setAttribute('uv', new Float32BufferAttribute(this.uvs, 2)); + geometry.computeBoundingSphere(); + return geometry; + } +} diff --git a/explainers/mri-scanner/src/scene/geometry/geometry.test.ts b/explainers/mri-scanner/src/scene/geometry/geometry.test.ts new file mode 100644 index 00000000..87228c2f --- /dev/null +++ b/explainers/mri-scanner/src/scene/geometry/geometry.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { Vector2, Vector3 } from 'three'; +import type { BufferGeometry } from 'three'; +import { hollowRect, oriented, rectLoop, roundCorners, signedArea, thickenPath } from './profile'; +import { arcAngles, FULL_TURN, isFullArc, sectionCap, sweepCaps, sweepProfile } from './sweep'; + +const TOLERANCE = 1e-6; +const QUARTER: { start: number; end: number } = { start: 0, end: FULL_TURN / 4 }; +const FULL = { start: 0, end: FULL_TURN }; + +function triangleArea(geometry: BufferGeometry): number { + const corners = points(geometry); + let area = 0; + for (let index = 0; index < corners.length; index += 3) { + const [a, b, c] = corners.slice(index, index + 3); + area += b.clone().sub(a).cross(c.clone().sub(a)).length() / 2; + } + return area; +} + +function points(geometry: BufferGeometry): Vector3[] { + const position = geometry.getAttribute('position'); + return Array.from({ length: position.count }, (_, index) => + new Vector3().fromBufferAttribute(position, index), + ); +} + +describe('profile helpers', () => { + it('winds a rectangle counter clockwise and flips it on request', () => { + const loop = rectLoop(1, 2, -1, 1); + expect(signedArea(loop)).toBeCloseTo(2); + expect(signedArea(oriented(loop, false))).toBeCloseTo(-2); + }); + + it('rounds every corner with the requested steps', () => { + const steps = 4; + expect(roundCorners(rectLoop(1, 2, -1, 1), 0.1, steps)).toHaveLength(4 * (steps + 1)); + }); + + it('thickens a straight path into a closed band of even width', () => { + const path = [new Vector2(1, 0), new Vector2(1, 1)]; + const band = thickenPath(path, 0.1); + expect(band).toHaveLength(4); + expect(band[2].x).toBeCloseTo(0.9); + expect(Math.abs(signedArea(band))).toBeCloseTo(0.1); + }); + + it('keeps the hole of a hollow vessel inside its walls', () => { + const { outer, holes } = hollowRect(1, 2, 1, 0.1, 0.2, 3); + expect(Math.abs(signedArea(holes[0]))).toBeLessThan(Math.abs(signedArea(outer))); + holes[0].forEach((point) => { + expect(point.x).toBeGreaterThanOrEqual(1.1 - TOLERANCE); + expect(point.x).toBeLessThanOrEqual(1.9 + TOLERANCE); + }); + }); +}); + +describe('sweeps', () => { + it('spans the arc with both ends included', () => { + const angles = arcAngles(QUARTER, 16); + expect(angles).toHaveLength(5); + expect(angles.at(-1)).toBeCloseTo(QUARTER.end); + expect(isFullArc(FULL)).toBe(true); + expect(isFullArc(QUARTER)).toBe(false); + }); + + it('keeps a swept ring inside its radii with unit normals', () => { + const profile = { outer: rectLoop(1, 2, -1, 1), holes: [] }; + const geometry = sweepProfile(profile, { arc: FULL, segmentsPerTurn: 32 }); + points(geometry).forEach((point) => { + const radius = Math.hypot(point.x, point.y); + expect(radius).toBeGreaterThanOrEqual(1 - TOLERANCE); + expect(radius).toBeLessThanOrEqual(2 + TOLERANCE); + }); + const normal = geometry.getAttribute('normal'); + expect(new Vector3().fromBufferAttribute(normal, 0).length()).toBeCloseTo(1); + }); + + it('caps a partial sweep and stays inside its arc', () => { + const profile = hollowRect(1, 2, 1, 0.1, 0, 1); + const open = sweepProfile(profile, { arc: QUARTER, segmentsPerTurn: 32 }); + points(open).forEach((point) => { + expect(point.x).toBeGreaterThanOrEqual(-TOLERANCE); + expect(point.y).toBeGreaterThanOrEqual(-TOLERANCE); + }); + const lateral = 4 * 2 * 2 * 8; + expect(open.getAttribute('position').count / 3).toBeGreaterThan(lateral); + }); + + it('fills both cut faces of a rounded profile even with repeated points', () => { + const loop = roundCorners(rectLoop(1, 1.2, 0, 0.2), 0.1, 3); + const caps = sweepCaps({ outer: loop, holes: [] }, { arc: QUARTER, segmentsPerTurn: 32 }); + const rounded = 0.2 * 0.2 - (4 - Math.PI) * 0.1 * 0.1; + expect(triangleArea(caps)).toBeGreaterThan(2 * rounded * 0.95); + expect(triangleArea(caps)).toBeLessThan(2 * 0.2 * 0.2); + }); + + it('leaves the cut faces off on request', () => { + const profile = { outer: rectLoop(1, 2, 0, 1), holes: [] }; + const open = sweepProfile(profile, { arc: QUARTER, segmentsPerTurn: 32, capped: false }); + expect(open.getAttribute('position').count / 3).toBe(4 * 2 * 8); + }); + + it('flattens the sides with a squash', () => { + const profile = { outer: rectLoop(1, 2, -1, 1), holes: [] }; + const geometry = sweepProfile(profile, { arc: FULL, segmentsPerTurn: 32, squash: () => 0.9 }); + geometry.computeBoundingBox(); + expect(geometry.boundingBox?.max.x).toBeCloseTo(1.8); + expect(geometry.boundingBox?.max.y).toBeCloseTo(2); + }); +}); + +describe('section cap', () => { + const profile = { outer: rectLoop(1, 2, -1, 1), holes: [] }; + const angle = Math.PI / 2; + + it('lies flat in the cut plane without lift', () => { + points(sectionCap(profile, angle, 1)).forEach((point) => expect(point.x).toBeCloseTo(0)); + }); + + it('lifts off the cut plane along its outward normal', () => { + const lift = 0.01; + points(sectionCap(profile, angle, 1, { lift })).forEach((point) => + expect(point.x).toBeCloseTo(-lift), + ); + points(sectionCap(profile, angle, -1, { lift })).forEach((point) => + expect(point.x).toBeCloseTo(lift), + ); + }); +}); diff --git a/explainers/mri-scanner/src/scene/geometry/profile.ts b/explainers/mri-scanner/src/scene/geometry/profile.ts new file mode 100644 index 00000000..3352860a --- /dev/null +++ b/explainers/mri-scanner/src/scene/geometry/profile.ts @@ -0,0 +1,87 @@ +import { Vector2 } from 'three'; + +export type Loop = readonly Vector2[]; + +export interface Profile { + outer: Loop; + holes: readonly Loop[]; +} + +export function quadratic(from: Vector2, control: Vector2, to: Vector2, steps: number): Vector2[] { + return Array.from({ length: steps }, (_, index) => { + const t = (index + 1) / steps; + const u = 1 - t; + return new Vector2( + u * u * from.x + 2 * u * t * control.x + t * t * to.x, + u * u * from.y + 2 * u * t * control.y + t * t * to.y, + ); + }); +} + +export function roundCorners(corners: Loop, radius: number, steps: number): Vector2[] { + return corners.flatMap((corner, index) => { + const previous = corners[(index + corners.length - 1) % corners.length]; + const next = corners[(index + 1) % corners.length]; + const reach = Math.min(radius, corner.distanceTo(previous) / 2, corner.distanceTo(next) / 2); + if (reach <= 0) return [corner.clone()]; + const start = previous.clone().sub(corner).setLength(reach).add(corner); + const end = next.clone().sub(corner).setLength(reach).add(corner); + return [start, ...quadratic(start, corner, end, steps)]; + }); +} + +export function rectLoop(inner: number, outer: number, from: number, to: number): Vector2[] { + return [ + new Vector2(inner, from), + new Vector2(outer, from), + new Vector2(outer, to), + new Vector2(inner, to), + ]; +} + +export function signedArea(loop: Loop): number { + return ( + loop.reduce((sum, point, index) => { + const next = loop[(index + 1) % loop.length]; + return sum + point.x * next.y - next.x * point.y; + }, 0) / 2 + ); +} + +export function oriented(loop: Loop, counterClockwise: boolean): Loop { + return signedArea(loop) > 0 === counterClockwise ? loop : [...loop].reverse(); +} + +function vertexNormal(path: Loop, index: number): Vector2 { + const previous = path[Math.max(index - 1, 0)]; + const next = path[Math.min(index + 1, path.length - 1)]; + const tangent = next.clone().sub(previous).normalize(); + return new Vector2(-tangent.y, tangent.x); +} + +export function thickenPath(path: Loop, thickness: number): Vector2[] { + const offset = path.map((point, index) => + vertexNormal(path, index).multiplyScalar(thickness).add(point), + ); + return [...path.map((point) => point.clone()), ...offset.reverse()]; +} + +export function hollowRect( + inner: number, + outer: number, + halfLength: number, + wall: number, + corner: number, + steps: number, +): Profile { + return { + outer: roundCorners(rectLoop(inner, outer, -halfLength, halfLength), corner, steps), + holes: [ + roundCorners( + rectLoop(inner + wall, outer - wall, wall - halfLength, halfLength - wall), + Math.max(corner - wall, 0), + steps, + ), + ], + }; +} diff --git a/explainers/mri-scanner/src/scene/geometry/sweep.ts b/explainers/mri-scanner/src/scene/geometry/sweep.ts new file mode 100644 index 00000000..6a2d80e3 --- /dev/null +++ b/explainers/mri-scanner/src/scene/geometry/sweep.ts @@ -0,0 +1,164 @@ +import { ShapeUtils, Vector2, Vector3 } from 'three'; +import type { BufferGeometry } from 'three'; +import { MeshBuilder } from './builder'; +import type { Vertex } from './builder'; +import { oriented } from './profile'; +import type { Loop, Profile } from './profile'; + +export interface Arc { + start: number; + end: number; +} + +export type Squash = (radius: number) => number; + +export type CapSide = -1 | 1; + +export interface CapOptions { + squash?: Squash; + lift?: number; +} + +export interface SweepOptions extends CapOptions { + arc: Arc; + segmentsPerTurn: number; + smoothAngle?: number; + capped?: boolean; +} + +export const FULL_TURN = 2 * Math.PI; +const FULL_ARC_EPSILON = 1e-6; +const DEFAULT_SMOOTH_ANGLE = Math.PI / 4; +const UNSQUASHED: Squash = () => 1; + +export function isFullArc(arc: Arc): boolean { + return arc.end - arc.start >= FULL_TURN - FULL_ARC_EPSILON; +} + +export function arcAngles(arc: Arc, segmentsPerTurn: number): number[] { + const span = arc.end - arc.start; + const steps = Math.max(1, Math.ceil((span / FULL_TURN) * segmentsPerTurn)); + return Array.from({ length: steps + 1 }, (_, index) => arc.start + (span * index) / steps); +} + +export function ringPoint(radius: number, z: number, angle: number, squash: Squash): Vector3 { + return new Vector3(radius * Math.cos(angle) * squash(radius), radius * Math.sin(angle), z); +} + +function ringNormal(normal: Vector2, angle: number, scale: number): Vector3 { + return new Vector3( + (normal.x * Math.cos(angle)) / scale, + normal.x * Math.sin(angle), + normal.y, + ).normalize(); +} + +function segmentNormal(from: Vector2, to: Vector2): Vector2 { + return new Vector2(to.y - from.y, from.x - to.x).normalize(); +} + +function endNormals(loop: Loop, index: number, smoothAngle: number): [Vector2, Vector2] { + const count = loop.length; + const at = (offset: number): Vector2 => loop[(index + offset + count) % count]; + const own = segmentNormal(at(0), at(1)); + const blend = (other: Vector2): Vector2 => + own.angleTo(other) < smoothAngle ? own.clone().add(other).normalize() : own; + return [blend(segmentNormal(at(-1), at(0))), blend(segmentNormal(at(1), at(2)))]; +} + +function sweepLoop(builder: MeshBuilder, loop: Loop, angles: number[], options: SweepOptions) { + const squash = options.squash ?? UNSQUASHED; + const smooth = options.smoothAngle ?? DEFAULT_SMOOTH_ANGLE; + let travelled = 0; + loop.forEach((from, index) => { + const to = loop[(index + 1) % loop.length]; + const [fromNormal, toNormal] = endNormals(loop, index, smooth); + const length = from.distanceTo(to); + const vertex = (point: Vector2, normal: Vector2, angle: number, v: number): Vertex => ({ + position: ringPoint(point.x, point.y, angle, squash), + normal: ringNormal(normal, angle, squash(point.x)), + uv: new Vector2(angle / FULL_TURN, v), + }); + for (let step = 0; step < angles.length - 1; step += 1) { + const [a, b] = [angles[step], angles[step + 1]]; + builder.quad( + vertex(from, fromNormal, a, travelled), + vertex(from, fromNormal, b, travelled), + vertex(to, toNormal, a, travelled + length), + vertex(to, toNormal, b, travelled + length), + ); + } + travelled += length; + }); +} + +const DUPLICATE_DISTANCE = 1e-7; + +function distinctPoints(loop: Loop): Vector2[] { + return loop.filter( + (point, index) => point.distanceTo(loop[(index + 1) % loop.length]) > DUPLICATE_DISTANCE, + ); +} + +function capAt( + builder: MeshBuilder, + profile: Profile, + angle: number, + side: CapSide, + options: CapOptions, +) { + const squash = options.squash ?? UNSQUASHED; + const lift = options.lift ?? 0; + const contour = distinctPoints(profile.outer); + const holes = profile.holes.map(distinctPoints); + const faces = ShapeUtils.triangulateShape(contour, holes); + const points = [contour, ...holes].flat(); + const normal = new Vector3(-Math.sin(angle), Math.cos(angle), 0).multiplyScalar(side); + const vertex = (point: Vector2): Vertex => ({ + position: ringPoint(point.x, point.y, angle, squash).addScaledVector(normal, lift), + normal, + uv: point.clone(), + }); + faces.forEach(([a, b, c]) => + builder.triangle(vertex(points[a]), vertex(points[b]), vertex(points[c])), + ); +} + +function solidLoops(profile: Profile): Profile { + return { + outer: oriented(profile.outer, true), + holes: profile.holes.map((hole) => oriented(hole, false)), + }; +} + +function addCaps(builder: MeshBuilder, solid: Profile, options: SweepOptions) { + if (isFullArc(options.arc)) return; + capAt(builder, solid, options.arc.start, -1, options); + capAt(builder, solid, options.arc.end, 1, options); +} + +export function sweepProfile(profile: Profile, options: SweepOptions): BufferGeometry { + const builder = new MeshBuilder(); + const angles = arcAngles(options.arc, options.segmentsPerTurn); + const solid = solidLoops(profile); + [solid.outer, ...solid.holes].forEach((loop) => sweepLoop(builder, loop, angles, options)); + if (options.capped ?? true) addCaps(builder, solid, options); + return builder.toGeometry(); +} + +export function sweepCaps(profile: Profile, options: SweepOptions): BufferGeometry { + const builder = new MeshBuilder(); + addCaps(builder, solidLoops(profile), options); + return builder.toGeometry(); +} + +export function sectionCap( + profile: Profile, + angle: number, + side: CapSide, + options: CapOptions = {}, +): BufferGeometry { + const builder = new MeshBuilder(); + capAt(builder, solidLoops(profile), angle, side, options); + return builder.toGeometry(); +} diff --git a/explainers/mri-scanner/src/scene/parts/context.ts b/explainers/mri-scanner/src/scene/parts/context.ts new file mode 100644 index 00000000..f2e24e40 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/context.ts @@ -0,0 +1,81 @@ +import { InstancedMesh, Mesh } from 'three'; +import type { BufferGeometry, Material, Matrix4, Object3D } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import type { + MaterialFinish, + MaterialLibrary, + STRUCTURE_GROUP, + UNDIMMED_GROUP, +} from '@core/scene/materials'; +import type { ResourceTracker } from '@core/scene/resources'; +import type { SceneTextures } from '@core/scene/textures'; +import type { AnchorId, AssemblyState, PartId } from '../../ids'; + +export type EmphasisGroup = PartId | typeof STRUCTURE_GROUP | typeof UNDIMMED_GROUP; + +export interface PartContext { + materials: MaterialLibrary; + textures: SceneTextures; + tracker: ResourceTracker; +} + +export interface SceneModule { + readonly root: Object3D; + readonly labels: ReadonlyMap; + readonly anchors: Partial>; + setState(state: AssemblyState): void; + update(deltaSeconds: number, cameraDistance: number): boolean; +} + +export function partMesh( + context: PartContext, + geometry: BufferGeometry, + group: EmphasisGroup, + finish: MaterialFinish, +): Mesh { + return new Mesh(context.tracker.track(geometry), context.materials.get(group, finish)); +} + +export function registered( + context: PartContext, + group: EmphasisGroup, + material: T, +): T { + context.materials.register(group, context.tracker.track(material)); + return material; +} + +export function instanced( + context: PartContext, + geometry: BufferGeometry, + group: EmphasisGroup, + finish: MaterialFinish, + matrices: readonly Matrix4[], +): InstancedMesh { + const mesh = new InstancedMesh( + context.tracker.track(geometry), + context.materials.get(group, finish), + matrices.length, + ); + matrices.forEach((matrix, index) => mesh.setMatrixAt(index, matrix)); + mesh.instanceMatrix.needsUpdate = true; + mesh.computeBoundingSphere(); + return mesh; +} + +export function mergeParts(parts: readonly BufferGeometry[]): BufferGeometry { + if (parts.length === 1) return parts[0]; + const prepared = parts.map((part) => (part.index ? part.toNonIndexed() : part)); + const names = Object.keys(prepared[0].attributes).filter((name) => + prepared.every((part) => part.getAttribute(name) !== undefined), + ); + prepared.forEach((part) => + Object.keys(part.attributes) + .filter((name) => !names.includes(name)) + .forEach((name) => part.deleteAttribute(name)), + ); + const merged = mergeGeometries(prepared); + new Set([...parts, ...prepared]).forEach((part) => part.dispose()); + if (!merged) throw new Error('Could not merge the part geometries'); + return merged; +} From bfed79260eb2a69d354275c631479cb2668fa68b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:53 +0200 Subject: [PATCH 48/82] feat(mri-scanner): add the magnet with its cutaway slice, gradient glow and cold head --- .../src/scene/parts/magnet/cover.ts | 129 +++++++++++ .../src/scene/parts/magnet/cryostat.ts | 210 ++++++++++++++++++ .../src/scene/parts/magnet/cutaway.ts | 40 ++++ .../src/scene/parts/magnet/gradients.ts | 200 +++++++++++++++++ .../src/scene/parts/magnet/looks.ts | 168 ++++++++++++++ .../src/scene/parts/magnet/magnet.dom.test.ts | 95 ++++++++ .../src/scene/parts/magnet/magnet.ts | 159 +++++++++++++ .../mri-scanner/src/scene/parts/magnet/rf.ts | 115 ++++++++++ .../src/scene/parts/magnet/slice.ts | 49 ++++ .../src/scene/parts/magnet/turret.ts | 151 +++++++++++++ 10 files changed, 1316 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/cover.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/cryostat.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/cutaway.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/gradients.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/looks.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/magnet.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/rf.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/slice.ts create mode 100644 explainers/mri-scanner/src/scene/parts/magnet/turret.ts diff --git a/explainers/mri-scanner/src/scene/parts/magnet/cover.ts b/explainers/mri-scanner/src/scene/parts/magnet/cover.ts new file mode 100644 index 00000000..70a9753f --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/cover.ts @@ -0,0 +1,129 @@ +import { MathUtils, Vector2 } from 'three'; +import type { BufferGeometry, Group } from 'three'; +import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js'; +import { BORE, ISOCENTRE, MAGNET } from '../../../model/layout'; +import type { Profile } from '../../geometry/profile'; +import { quadratic, rectLoop, roundCorners, thickenPath } from '../../geometry/profile'; +import { sweepProfile } from '../../geometry/sweep'; +import type { Arc, Squash, SweepOptions } from '../../geometry/sweep'; +import { mergeParts } from '../context'; +import type { PartContext } from '../context'; +import { addMeshes, boreGroup, REST_ARC, WEDGE_ARC } from './cutaway'; +import { ACCENT_FINISH, COVER_FINISH, STEEL_FINISH, TRIM_FINISH } from './looks'; +import { slice } from './slice'; + +const SEGMENTS = 128; +const CURVE_STEPS = 10; +const SKIN = 0.03; +const SIDE_FLATTEN = 0.035; +const FLATTEN_FROM = 0.93; +const FRONT = { joint: 0.58, mouth: 0.53, face: 0.85, rim: 0.86, corner: 0.66 } as const; +const BACK = { joint: -0.64, mouth: 0.43, face: -0.85, rim: 0.9, corner: -0.7 } as const; +const LIP = 0.004; +const BAND = { inner: 1.045, outer: 1.057, from: 0.4, to: 0.6, corner: 0.004 } as const; +const ACCENT = { inner: 0.585, outer: 0.6, depth: 0.007 } as const; +const PLINTH = { height: 0.32, inset: 0.05, segments: 3, radius: 0.03 } as const; + +export const BORE_JOINTS = { back: BACK.joint, front: FRONT.joint } as const; + +export const COVER_SQUASH: Squash = (radius) => + 1 - SIDE_FLATTEN * MathUtils.smoothstep(radius, FLATTEN_FROM, MAGNET.radius); + +function coverPath(): Vector2[] { + const { radius } = MAGNET; + const bore = BORE.radius + LIP; + const backJoint = new Vector2(bore, BACK.joint); + const backMouth = new Vector2(BACK.mouth, BACK.face); + const backRim = new Vector2(BACK.rim, BACK.face); + const frontCorner = new Vector2(radius, FRONT.corner); + const frontRim = new Vector2(FRONT.rim, FRONT.face); + const frontMouth = new Vector2(FRONT.mouth, FRONT.face); + return [ + backJoint, + ...quadratic(backJoint, new Vector2(bore, BACK.face), backMouth, CURVE_STEPS), + backRim, + ...quadratic( + backRim, + new Vector2(radius, BACK.face), + new Vector2(radius, BACK.corner), + CURVE_STEPS, + ), + frontCorner, + ...quadratic(frontCorner, new Vector2(radius, FRONT.face), frontRim, CURVE_STEPS), + frontMouth, + ...quadratic( + frontMouth, + new Vector2(bore, FRONT.face), + new Vector2(bore, FRONT.joint), + CURVE_STEPS, + ), + ]; +} + +function coverProfile(): Profile { + return { outer: thickenPath(coverPath(), SKIN), holes: [] }; +} + +function bandProfile(): Profile { + const loop = rectLoop(BAND.inner, BAND.outer, BAND.from, BAND.to); + return { outer: roundCorners(loop, BAND.corner, 2), holes: [] }; +} + +function accentProfile(): Profile { + const loop = rectLoop(ACCENT.inner, ACCENT.outer, FRONT.face, FRONT.face + ACCENT.depth); + return { outer: loop, holes: [] }; +} + +function plinthGeometry(): BufferGeometry { + const width = 2 * MAGNET.baseHalfWidth; + const depth = 2 * (MAGNET.halfLength - PLINTH.inset); + return new RoundedBoxGeometry( + width, + PLINTH.height, + depth, + PLINTH.segments, + PLINTH.radius, + ).translate(0, PLINTH.height / 2 - ISOCENTRE[1], 0); +} + +interface CoverPieces { + shell: BufferGeometry; + band: BufferGeometry; + accent: BufferGeometry; +} + +function sweepOptions(arc: Arc): SweepOptions { + return { arc, segmentsPerTurn: SEGMENTS, squash: COVER_SQUASH }; +} + +function pieces(arc: Arc): CoverPieces { + const options = sweepOptions(arc); + return { + shell: sweepProfile(coverProfile(), { ...options, capped: false }), + band: sweepProfile(bandProfile(), options), + accent: sweepProfile(accentProfile(), options), + }; +} + +export interface CoverObjects { + full: Group; + cut: Group; +} + +export function buildCover(context: PartContext): CoverObjects { + const rest = pieces(REST_ARC); + const wedge = pieces(WEDGE_ARC); + const plinth = plinthGeometry(); + const section = slice(coverProfile(), 'cover', COVER_SQUASH); + const backdrop = slice({ outer: coverPath(), holes: [] }, 'backdrop', COVER_SQUASH); + const full = boreGroup('cover'); + const cut = boreGroup('coverCut'); + addMeshes(context, full, 'cover', COVER_FINISH, [mergeParts([rest.shell, wedge.shell])]); + addMeshes(context, full, 'cover', TRIM_FINISH, [mergeParts([rest.band, wedge.band, plinth])]); + addMeshes(context, full, 'cover', ACCENT_FINISH, [mergeParts([rest.accent, wedge.accent])]); + addMeshes(context, cut, 'cover', COVER_FINISH, [mergeParts([rest.shell, section])]); + addMeshes(context, cut, 'cover', TRIM_FINISH, [mergeParts([rest.band, plinth])]); + addMeshes(context, cut, 'cover', ACCENT_FINISH, [rest.accent]); + addMeshes(context, cut, 'cover', STEEL_FINISH, [backdrop]); + return { full, cut }; +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/cryostat.ts b/explainers/mri-scanner/src/scene/parts/magnet/cryostat.ts new file mode 100644 index 00000000..99fe4c0c --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/cryostat.ts @@ -0,0 +1,210 @@ +import { BoxGeometry, Matrix4, Quaternion, Vector3 } from 'three'; +import type { BufferGeometry, Group } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import type { PartId } from '../../../ids'; +import { LAYERS, MAIN_COILS, SHIELD_COILS, SHIMS } from '../../../model/layout'; +import type { CoilRings, Shell } from '../../../model/layout'; +import { hollowRect, rectLoop, roundCorners } from '../../geometry/profile'; +import type { Profile } from '../../geometry/profile'; +import { sweepProfile } from '../../geometry/sweep'; +import { instanced, mergeParts } from '../context'; +import type { PartContext } from '../context'; +import { addMeshes, boreGroup, isInWedge, REST_ARC } from './cutaway'; +import { + ALUMINIUM_FINISH, + HELIUM_STEEL_FINISH, + HELIUM_VAPOUR_FINISH, + LIQUID_HELIUM_FINISH, + SHIM_FINISH, + SHIM_TRAY_FINISH, + STEEL_FINISH, + VACUUM_FINISH, +} from './looks'; +import type { MagnetLooks } from './looks'; +import { slice, sliceFaces } from './slice'; +import type { SliceLayer } from './slice'; + +const SEGMENTS = 112; +const CORNER_STEPS = 3; +const COIL_CORNER = 0.006; +const COIL_GROWTH = { radial: 0.035, width: 1.6 } as const; +const COIL_CLEARANCE = 0.008; +const LIQUID_FILL = 0.6; +const SHIM_POCKETS = 10; +const SHIM_POCKET = { width: 0.03, thickness: 0.006, length: 0.05 } as const; + +interface VesselSpec { + shell: Shell; + wall: number; + corner: number; +} + +export const VESSELS = { + vacuumVessel: { shell: LAYERS.vacuumVessel, wall: 0.014, corner: 0.05 }, + radiationShield: { shell: LAYERS.radiationShield, wall: 0.016, corner: 0.035 }, + heliumVessel: { shell: LAYERS.heliumVessel, wall: 0.016, corner: 0.03 }, +} as const satisfies Record; + +export type CryostatParts = Partial>; + +interface VesselLayers { + wall: SliceLayer; + inside: SliceLayer; +} + +interface VesselPieces { + body: BufferGeometry; + inside: BufferGeometry; +} + +function vesselProfile({ shell, wall, corner }: VesselSpec): Profile { + return hollowRect(shell.inner, shell.outer, shell.halfLength, wall, corner, CORNER_STEPS); +} + +function vesselPieces(spec: VesselSpec, layers: VesselLayers): VesselPieces { + const profile = vesselProfile(spec); + const sweep = sweepProfile(profile, { arc: REST_ARC, segmentsPerTurn: SEGMENTS, capped: false }); + return { + body: mergeParts([sweep, slice({ outer: profile.outer, holes: [] }, layers.wall)]), + inside: slice({ outer: profile.holes[0], holes: [] }, layers.inside), + }; +} + +function vesselPart( + context: PartContext, + id: keyof typeof VESSELS, + finish: MaterialFinish, + insideFinish: MaterialFinish, + layers: VesselLayers, +): Group { + const { body, inside } = vesselPieces(VESSELS[id], layers); + const group = boreGroup(id); + addMeshes(context, group, id, finish, [body]); + addMeshes(context, group, id, insideFinish, [inside]); + return group; +} + +function liquidSlice({ shell, wall, corner }: VesselSpec): BufferGeometry { + const inner = shell.inner + wall; + const outer = shell.outer - wall; + const halfLength = shell.halfLength - wall; + const level = inner + LIQUID_FILL * (outer - inner); + return sliceFaces('liquid', ({ angle }) => { + const rise = Math.sin(angle); + const surface = rise > 0 ? Math.min(outer, level / rise) : outer; + const loop = rectLoop(inner, surface, -halfLength, halfLength); + return { outer: roundCorners(loop, Math.max(corner - wall, 0), CORNER_STEPS), holes: [] }; + }); +} + +function drawnRings(rings: CoilRings): CoilRings { + const { shell, wall } = VESSELS.heliumVessel; + return { + ...rings, + inner: Math.max(rings.inner - COIL_GROWTH.radial, shell.inner + wall + COIL_CLEARANCE), + outer: Math.min(rings.outer + COIL_GROWTH.radial, shell.outer - wall - COIL_CLEARANCE), + width: rings.width * COIL_GROWTH.width, + }; +} + +interface CoilGeometry { + sides: BufferGeometry; + caps: BufferGeometry; +} + +function coilGeometry(rings: CoilRings): CoilGeometry { + const drawn = drawnRings(rings); + const profiles = drawn.z.map((z) => { + const loop = rectLoop(drawn.inner, drawn.outer, z - drawn.width / 2, z + drawn.width / 2); + return { outer: roundCorners(loop, COIL_CORNER, CORNER_STEPS), holes: [] }; + }); + return { + sides: mergeParts( + profiles.map((profile) => + sweepProfile(profile, { arc: REST_ARC, segmentsPerTurn: SEGMENTS, capped: false }), + ), + ), + caps: mergeParts(profiles.map((profile) => slice(profile, 'coil'))), + }; +} + +function coilPart( + context: PartContext, + id: PartId, + [finish, cutFinish]: readonly [MaterialFinish, MaterialFinish], + rings: CoilRings, +): Group { + const { sides, caps } = coilGeometry(rings); + const group = boreGroup(id); + addMeshes(context, group, id, finish, [sides]); + addMeshes(context, group, id, cutFinish, [caps]); + return group; +} + +function radialMatrix(angle: number, radius: number, z: number): Matrix4 { + const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), angle - Math.PI / 2); + const position = new Vector3(radius * Math.cos(angle), radius * Math.sin(angle), z); + return new Matrix4().compose(position, turn, new Vector3(1, 1, 1)); +} + +function shimAngles(): number[] { + const step = (2 * Math.PI) / SHIMS.count; + return Array.from({ length: SHIMS.count }, (_, index) => (index + 0.5) * step).filter( + (angle) => !isInWedge(angle), + ); +} + +function addShims(context: PartContext, group: Group): void { + const trayRadius = SHIMS.radius + VESSELS.vacuumVessel.wall; + const pocketRadius = trayRadius + (SHIMS.thickness + SHIM_POCKET.thickness) / 2; + const pitch = (2 * SHIMS.halfLength) / SHIM_POCKETS; + const angles = shimAngles(); + const tray = new BoxGeometry(SHIMS.width, SHIMS.thickness, 2 * SHIMS.halfLength); + const pocket = new BoxGeometry(SHIM_POCKET.width, SHIM_POCKET.thickness, SHIM_POCKET.length); + const pocketMatrices = angles.flatMap((angle) => + Array.from({ length: SHIM_POCKETS }, (_, index) => + radialMatrix(angle, pocketRadius, (index + 0.5) * pitch - SHIMS.halfLength), + ), + ); + group.add( + instanced( + context, + tray, + 'shims', + SHIM_TRAY_FINISH, + angles.map((angle) => radialMatrix(angle, trayRadius, 0)), + ), + instanced(context, pocket, 'shims', SHIM_FINISH, pocketMatrices), + ); +} + +export function buildCryostat(context: PartContext, looks: MagnetLooks): CryostatParts { + const helium = vesselPart(context, 'heliumVessel', HELIUM_STEEL_FINISH, HELIUM_VAPOUR_FINISH, { + wall: 'heliumWall', + inside: 'vapour', + }); + addMeshes(context, helium, 'heliumVessel', LIQUID_HELIUM_FINISH, [ + liquidSlice(VESSELS.heliumVessel), + ]); + const shims = boreGroup('shims'); + addShims(context, shims); + return { + vacuumVessel: vesselPart(context, 'vacuumVessel', STEEL_FINISH, VACUUM_FINISH, { + wall: 'vacuumWall', + inside: 'vacuumGap', + }), + radiationShield: vesselPart(context, 'radiationShield', ALUMINIUM_FINISH, VACUUM_FINISH, { + wall: 'shieldWall', + inside: 'shieldGap', + }), + heliumVessel: helium, + mainCoils: coilPart(context, 'mainCoils', [looks.winding, looks.windingCut], MAIN_COILS), + shieldCoils: coilPart( + context, + 'shieldCoils', + [looks.shieldWinding, looks.shieldWindingCut], + SHIELD_COILS, + ), + shims, + }; +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/cutaway.ts b/explainers/mri-scanner/src/scene/parts/magnet/cutaway.ts new file mode 100644 index 00000000..8e044ff2 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/cutaway.ts @@ -0,0 +1,40 @@ +import { Group } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import { ISOCENTRE } from '../../../model/layout'; +import type { Arc } from '../../geometry/sweep'; +import { FULL_TURN } from '../../geometry/sweep'; +import { partMesh } from '../context'; +import type { EmphasisGroup, PartContext } from '../context'; + +const WEDGE_CENTRE = Math.PI / 4; +const WEDGE_SPAN = (2 * Math.PI) / 3; + +export const WEDGE_ARC: Arc = { + start: WEDGE_CENTRE - WEDGE_SPAN / 2, + end: WEDGE_CENTRE + WEDGE_SPAN / 2, +}; +export const REST_ARC: Arc = { start: WEDGE_ARC.end, end: WEDGE_ARC.start + FULL_TURN }; +export const FULL_ARC: Arc = { start: 0, end: FULL_TURN }; + +export function isInWedge(angle: number): boolean { + const offset = Math.atan2(Math.sin(angle - WEDGE_CENTRE), Math.cos(angle - WEDGE_CENTRE)); + return Math.abs(offset) <= WEDGE_SPAN / 2; +} + +export function boreGroup(name: string): Group { + const group = new Group(); + group.name = name; + group.position.set(0, ISOCENTRE[1], 0); + return group; +} + +export function addMeshes( + context: PartContext, + parent: Object3D, + group: EmphasisGroup, + finish: MaterialFinish, + geometries: readonly BufferGeometry[], +): void { + geometries.forEach((geometry) => parent.add(partMesh(context, geometry, group, finish))); +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts b/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts new file mode 100644 index 00000000..126deff3 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts @@ -0,0 +1,200 @@ +import { AdditiveBlending, Color, Mesh, ShaderMaterial, Vector3 } from 'three'; +import type { BufferGeometry, Group, Material } from 'three'; +import type { GradientAxisId, PartId } from '../../../ids'; +import { GRADIENT_AXIS_IDS } from '../../../ids'; +import { gradientShell } from '../../../model/layout'; +import { GRADIENT_TONES } from '../../../theme'; +import { rectLoop, roundCorners } from '../../geometry/profile'; +import { sweepProfile } from '../../geometry/sweep'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { addMeshes, boreGroup, REST_ARC } from './cutaway'; +import { RESIN_FINISHES, RESIN_GLASS_FINISHES } from './looks'; + +export const GRADIENT_PARTS: Readonly> = { + x: 'gradientX', + y: 'gradientY', + z: 'gradientZ', +}; + +const SEGMENTS = 112; +const CORNER = 0.004; +const CORNER_STEPS = 2; +const EASE_RATE = 10; +const SETTLED = 1e-3; +const GLOW_RENDER_ORDER = 1; +const AXIS_VECTORS: Readonly> = { + x: new Vector3(1, 0, 0), + y: new Vector3(0, 1, 0), + z: new Vector3(0, 0, 1), +}; + +const GLOW = { + dimEnd: 0.02, + gain: 2.6, + curve: 2.2, + rimBoost: 0.6, + edgeBoost: 1.5, + heat: 0.15, + idleBand: 1.2, +} as const; +const POLYGON_PULL = -1; + +const VERTEX = ` +uniform vec3 axis; +uniform float extent; +varying float along; +varying float facing; +varying float cut; +void main() { + along = clamp(dot(position, axis) / extent, -1.0, 1.0); + vec2 radial = normalize(position.xy); + cut = clamp(1.0 - abs(dot(normal.xy, radial)) - abs(normal.z), 0.0, 1.0); + vec4 view = modelViewMatrix * vec4(position, 1.0); + facing = abs(dot(normalize(normalMatrix * normal), normalize(-view.xyz))); + gl_Position = projectionMatrix * view; +} +`; + +const FRAGMENT = ` +uniform vec3 tone; +uniform float level; +uniform float dimEnd; +uniform float gain; +uniform float curve; +uniform float rimBoost; +uniform float edgeBoost; +uniform float heat; +uniform float idleBand; +varying float along; +varying float facing; +varying float cut; +void main() { + float hot = clamp(0.5 + 0.5 * along * sign(level), 0.0, 1.0); + float ramp = mix(dimEnd, 1.0, pow(hot, curve)); + float rim = 1.0 + rimBoost * pow(1.0 - facing, 2.0); + float driven = abs(level) * gain * ramp * rim * (1.0 + edgeBoost * cut); + float strength = driven + idleBand * cut; + vec3 glow = tone * strength + vec3(heat * max(strength - 1.0, 0.0)); + gl_FragColor = vec4(glow, 1.0); + #include +} +`; + +function shellGeometry(axis: GradientAxisId): BufferGeometry { + const { inner, outer, halfLength } = gradientShell(axis); + const loop = roundCorners(rectLoop(inner, outer, -halfLength, halfLength), CORNER, CORNER_STEPS); + return sweepProfile({ outer: loop, holes: [] }, { arc: REST_ARC, segmentsPerTurn: SEGMENTS }); +} + +function glowMaterial(axis: GradientAxisId): ShaderMaterial { + const shell = gradientShell(axis); + return new ShaderMaterial({ + uniforms: { + axis: { value: AXIS_VECTORS[axis] }, + extent: { value: axis === 'z' ? shell.halfLength : shell.outer }, + tone: { value: new Color(GRADIENT_TONES[axis]) }, + level: { value: 0 }, + dimEnd: { value: GLOW.dimEnd }, + gain: { value: GLOW.gain }, + curve: { value: GLOW.curve }, + rimBoost: { value: GLOW.rimBoost }, + edgeBoost: { value: GLOW.edgeBoost }, + heat: { value: GLOW.heat }, + idleBand: { value: GLOW.idleBand }, + }, + vertexShader: VERTEX, + fragmentShader: FRAGMENT, + blending: AdditiveBlending, + transparent: true, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: POLYGON_PULL, + polygonOffsetUnits: POLYGON_PULL, + }); +} + +interface Glow { + material: ShaderMaterial; + current: number; + target: number; +} + +function isLit(glow: Glow): boolean { + return Math.abs(glow.current) > SETTLED; +} + +interface Resin { + meshes: Mesh[]; + solid: Material; + glass: Material; +} + +export class GradientCoils { + readonly parts: Readonly>; + private readonly glows: Record; + private readonly resins: Record; + + constructor(context: PartContext) { + const parts = {} as Record; + const glows = {} as Record; + const resins = {} as Record; + GRADIENT_AXIS_IDS.forEach((axis) => { + const id = GRADIENT_PARTS[axis]; + const geometry = shellGeometry(axis); + const group = boreGroup(id); + addMeshes(context, group, id, RESIN_FINISHES[axis], [geometry]); + const meshes = group.children.filter((child) => child instanceof Mesh); + resins[axis] = { + meshes, + solid: meshes[0].material as Material, + glass: context.materials.get(id, RESIN_GLASS_FINISHES[axis]), + }; + const material = registered(context, id, glowMaterial(axis)); + const mesh = new Mesh(geometry, material); + mesh.renderOrder = GLOW_RENDER_ORDER; + group.add(mesh); + parts[axis] = group; + glows[axis] = { material, current: 0, target: 0 }; + }); + this.parts = parts; + this.glows = glows; + this.resins = resins; + } + + setTarget(active: GradientAxisId | null, level: number): void { + GRADIENT_AXIS_IDS.forEach((axis) => { + this.glows[axis].target = axis === active ? level : 0; + }); + } + + get glowing(): boolean { + return GRADIENT_AXIS_IDS.some((axis) => isLit(this.glows[axis])); + } + + update(deltaSeconds: number): boolean { + const blend = 1 - Math.exp(-EASE_RATE * deltaSeconds); + const easing = GRADIENT_AXIS_IDS.map((axis) => this.ease(this.glows[axis], blend)); + this.clearOtherShells(); + return easing.some(Boolean); + } + + private clearOtherShells(): void { + const glowing = this.glowing; + GRADIENT_AXIS_IDS.forEach((axis) => { + const { meshes, solid, glass } = this.resins[axis]; + const clear = glowing && !isLit(this.glows[axis]); + meshes.forEach((mesh) => { + mesh.material = clear ? glass : solid; + }); + }); + } + + private ease(glow: Glow, blend: number): boolean { + const gap = glow.target - glow.current; + const settled = Math.abs(gap) < SETTLED; + glow.current = settled ? glow.target : glow.current + gap * blend; + glow.material.uniforms.level.value = glow.current; + return !settled; + } +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/looks.ts b/explainers/mri-scanner/src/scene/parts/magnet/looks.ts new file mode 100644 index 00000000..f8556eff --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/looks.ts @@ -0,0 +1,168 @@ +import { CanvasTexture, Color, RepeatWrapping, SRGBColorSpace } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import type { GradientAxisId } from '../../../ids'; +import { GRADIENT_AXIS_IDS } from '../../../ids'; +import { GRADIENT_TONES, THEME } from '../../../theme'; +import type { PartContext } from '../context'; + +const RESIN = '#262b31'; +const RESIN_SHARE = 0.6; +const WIRE_TURNS_PER_TILE = 8; +const WIRE_TILE = { width: 16, height: 64 } as const; +const WIRE_TILES_PER_METRE = 18; +const WIRE_SHADES = { edge: 0.35, core: 1 } as const; +const CUT_WIRE = { + roughness: 0.45, + metalness: 0.35, + bumpScale: 0.8, + emissiveIntensity: 0.35, +} as const; +const CUT_COPPER = { main: '#ec8a45', shield: '#d9733a', glow: '#7a3412' } as const; + +export const COVER_FINISH: MaterialFinish = { + color: THEME.cover, + roughness: 0.32, + metalness: 0.04, +}; +export const TRIM_FINISH: MaterialFinish = { + color: THEME.coverTrim, + roughness: 0.5, + metalness: 0.08, +}; +export const ACCENT_FINISH: MaterialFinish = { + color: '#d6f0ff', + emissive: '#7cc8ff', + emissiveIntensity: 1.4, + roughness: 0.3, +}; +export const STEEL_FINISH: MaterialFinish = { + color: '#646d78', + roughness: 0.4, + metalness: 0.45, +}; +export const ALUMINIUM_FINISH: MaterialFinish = { + color: '#f2f6fa', + emissive: THEME.radiationShield, + emissiveIntensity: 0.5, + roughness: 0.22, + metalness: 0.5, +}; +export const HELIUM_STEEL_FINISH: MaterialFinish = { + color: '#8796a8', + roughness: 0.35, + metalness: 0.5, +}; +export const VACUUM_FINISH: MaterialFinish = { color: '#0b0e12', roughness: 0.95, metalness: 0 }; +export const HELIUM_VAPOUR_FINISH: MaterialFinish = { + color: '#2c6688', + emissive: '#173f5a', + emissiveIntensity: 0.7, + roughness: 0.7, + metalness: 0, +}; +export const LIQUID_HELIUM_FINISH: MaterialFinish = { + color: '#4dbdf0', + emissive: '#1f8fd0', + emissiveIntensity: 0.45, + roughness: 0.25, + metalness: 0, +}; +export const SHIM_TRAY_FINISH: MaterialFinish = { + color: '#8a9099', + roughness: 0.5, + metalness: 0.6, +}; +export const SHIM_FINISH: MaterialFinish = { color: THEME.shim, roughness: 0.6, metalness: 0.55 }; +export const COPPER_FINISH: MaterialFinish = { + color: THEME.copper, + roughness: 0.28, + metalness: 0.9, +}; +export const BODY_COIL_CUT_FINISH: MaterialFinish = { + color: '#ffb35e', + emissive: '#ff7a1f', + emissiveIntensity: 0.8, + roughness: 0.4, + metalness: 0.2, +}; +export const CAPACITOR_FINISH: MaterialFinish = { color: '#3b3f46', roughness: 0.55 }; +export const LINER_FINISH: MaterialFinish = { color: THEME.boreLiner, roughness: 0.42 }; +export const LINER_GLASS_FINISH: MaterialFinish = { + ...LINER_FINISH, + transparent: true, + opacity: 0.25, + depthWrite: false, +}; +export const TURRET_FINISH: MaterialFinish = { color: '#aab2bb', roughness: 0.3, metalness: 0.85 }; +export const PIPE_FINISH: MaterialFinish = { color: '#7d858f', roughness: 0.4, metalness: 0.8 }; +export const HOSE_FINISH: MaterialFinish = { color: '#2b2f35', roughness: 0.75, metalness: 0.2 }; + +export const RESIN_FINISHES: Readonly> = Object.fromEntries( + GRADIENT_AXIS_IDS.map((axis) => [ + axis, + { + color: new Color(GRADIENT_TONES[axis]).lerp(new Color(RESIN), RESIN_SHARE), + roughness: 0.55, + metalness: 0.15, + }, + ]), +) as Record; + +const RESIN_GLASS_OPACITY = 0.22; + +export const RESIN_GLASS_FINISHES: Readonly> = + Object.fromEntries( + GRADIENT_AXIS_IDS.map((axis) => [ + axis, + { + ...RESIN_FINISHES[axis], + transparent: true, + opacity: RESIN_GLASS_OPACITY, + depthWrite: false, + }, + ]), + ) as Record; + +export interface MagnetLooks { + winding: MaterialFinish; + shieldWinding: MaterialFinish; + windingCut: MaterialFinish; + shieldWindingCut: MaterialFinish; +} + +function windingTexture(): CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = WIRE_TILE.width; + canvas.height = WIRE_TILE.height; + const context = canvas.getContext('2d'); + const turn = WIRE_TILE.height / WIRE_TURNS_PER_TILE; + for (let index = 0; context && index < WIRE_TURNS_PER_TILE; index += 1) { + const top = index * turn; + const shade = context.createLinearGradient(0, top, 0, top + turn); + const edge = Math.round(WIRE_SHADES.edge * 255); + const core = Math.round(WIRE_SHADES.core * 255); + shade.addColorStop(0, `rgb(${edge}, ${edge}, ${edge})`); + shade.addColorStop(0.5, `rgb(${core}, ${core}, ${core})`); + shade.addColorStop(1, `rgb(${edge}, ${edge}, ${edge})`); + context.fillStyle = shade; + context.fillRect(0, top, WIRE_TILE.width, turn); + } + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.repeat.set(1, WIRE_TILES_PER_METRE); + return texture; +} + +export function createMagnetLooks(context: PartContext): MagnetLooks { + const wire = context.tracker.track(windingTexture()); + const wound = { roughness: 0.32, metalness: 0.85, map: wire, bumpMap: wire, bumpScale: 1.5 }; + const cut = { ...wound, ...CUT_WIRE }; + return { + winding: { ...wound, color: THEME.winding }, + shieldWinding: { ...wound, color: THEME.shieldWinding }, + windingCut: { ...cut, color: CUT_COPPER.main, emissive: CUT_COPPER.glow }, + shieldWindingCut: { ...cut, color: CUT_COPPER.shield, emissive: CUT_COPPER.glow }, + }; +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts b/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts new file mode 100644 index 00000000..779857ca --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { InstancedMesh, Mesh } from 'three'; +import type { Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { ResourceTracker } from '@core/scene/resources'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState, PartId } from '../../../ids'; +import { createMagnetModule } from './magnet'; + +const OWNED: readonly PartId[] = [ + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'shims', + 'coldHead', + 'quenchPipe', + 'gradientX', + 'gradientY', + 'gradientZ', + 'bodyCoil', + 'bore', +]; +const TRIANGLE_BUDGET = 250_000; +const FRAME = 1 / 60; + +function moduleUnderTest() { + return createMagnetModule({ + materials: new MaterialLibrary(), + textures: createSceneTextures(), + tracker: new ResourceTracker(), + }); +} + +function stateWith(cutaway: boolean, level: number): AssemblyState { + return { + view: { cutaway, fieldLines: false, voxel: false, labels: true }, + gradientAxis: null, + sequence: { phase: 0, step: 'rest', rf: null, gradient: 'x', gradientLevel: level, echo: 0 }, + } as unknown as AssemblyState; +} + +function triangles(root: Object3D): number { + let total = 0; + root.traverse((node) => { + if (!(node instanceof Mesh)) return; + const count = node.geometry.index?.count ?? node.geometry.getAttribute('position').count; + total += (count / 3) * (node instanceof InstancedMesh ? node.count : 1); + }); + return total; +} + +describe('magnet module', () => { + it('names a part object and a label for every owned part', () => { + const magnet = moduleUnderTest(); + OWNED.forEach((id) => { + expect(magnet.root.getObjectByName(id)).toBeDefined(); + expect(magnet.labels.get(id)).toBeDefined(); + }); + expect(magnet.anchors.coldHead).toBeDefined(); + }); + + it('stays within the triangle budget', () => { + expect(triangles(moduleUnderTest().root)).toBeLessThan(TRIANGLE_BUDGET); + }); + + it('swaps the closed cover for the cut layers', () => { + const magnet = moduleUnderTest(); + const visible = (name: string) => magnet.root.getObjectByName(name)?.visible; + magnet.setState(stateWith(true, 0)); + expect([visible('cover'), visible('coverCut'), visible('mainCoils')]).toEqual([ + false, + true, + true, + ]); + magnet.setState(stateWith(false, 0)); + expect([visible('cover'), visible('coverCut'), visible('mainCoils')]).toEqual([ + true, + false, + false, + ]); + }); + + it('eases the gradient glow and then settles', () => { + const magnet = moduleUnderTest(); + magnet.setState(stateWith(true, 1)); + expect(magnet.update(FRAME, 1)).toBe(true); + let frames = 0; + while (magnet.update(FRAME, 1)) frames += 1; + expect(frames).toBeGreaterThan(0); + expect(magnet.update(FRAME, 1)).toBe(false); + }); +}); diff --git a/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts b/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts new file mode 100644 index 00000000..aac59f8a --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts @@ -0,0 +1,159 @@ +import { Group, Mesh } from 'three'; +import type { Material, Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import type { AnchorId, AssemblyState, PartId, Point } from '../../../ids'; +import { GRADIENT_AXIS_IDS } from '../../../ids'; +import { + BODY_COIL, + BORE, + COLD_HEAD, + gradientShell, + MAGNET, + MAIN_COILS, + QUENCH_PIPE, + SHIELD_COILS, + SHIMS, +} from '../../../model/layout'; +import type { PartContext, SceneModule } from '../context'; +import { buildCover } from './cover'; +import type { CoverObjects } from './cover'; +import { buildCryostat, VESSELS } from './cryostat'; +import { REST_ARC } from './cutaway'; +import { GRADIENT_PARTS, GradientCoils } from './gradients'; +import { createMagnetLooks, LINER_GLASS_FINISH } from './looks'; +import { buildBodyCoil, buildBoreLiner } from './rf'; +import type { BoreLiner } from './rf'; +import { buildColdHead, buildQuenchPipe } from './turret'; + +const DIAGONAL = Math.SQRT1_2; +const MAIN_COIL_LABEL = 3; +const SHIELD_COIL_LABEL = 1; +const SHIM_LABEL_TRAY = 4; + +function diagonal(radius: number, z = 0): Point { + return [radius * DIAGONAL, radius * DIAGONAL, z]; +} + +function onCutFace(inner: number, outer: number, z = 0): Point { + const radius = (inner + outer) / 2; + return [radius * Math.cos(REST_ARC.start), radius * Math.sin(REST_ARC.start), z]; +} + +function onShimTray(): Point { + const angle = (SHIM_LABEL_TRAY + 0.5) * ((2 * Math.PI) / SHIMS.count); + return [SHIMS.radius * Math.cos(angle), SHIMS.radius * Math.sin(angle), 0]; +} + +function vesselLabel(id: keyof typeof VESSELS): Point { + const { shell, wall } = VESSELS[id]; + return onCutFace(shell.outer - wall, shell.outer); +} + +function labelPoints(): Partial> { + const gradients = Object.fromEntries( + GRADIENT_AXIS_IDS.map((axis) => { + const { inner, outer } = gradientShell(axis); + return [GRADIENT_PARTS[axis], onCutFace(inner, outer)]; + }), + ); + return { + cover: diagonal(MAGNET.radius, MAGNET.halfLength), + vacuumVessel: vesselLabel('vacuumVessel'), + radiationShield: vesselLabel('radiationShield'), + heliumVessel: vesselLabel('heliumVessel'), + mainCoils: onCutFace(MAIN_COILS.inner, MAIN_COILS.outer, MAIN_COILS.z[MAIN_COIL_LABEL]), + shieldCoils: onCutFace( + SHIELD_COILS.inner, + SHIELD_COILS.outer, + SHIELD_COILS.z[SHIELD_COIL_LABEL], + ), + shims: onShimTray(), + ...gradients, + bodyCoil: [0, -BODY_COIL.radius, BODY_COIL.halfLength], + bore: [0, -BORE.radius, BORE.halfLength], + coldHead: [0, COLD_HEAD.top, 0], + quenchPipe: [0, QUENCH_PIPE.to, 0], + }; +} + +class MagnetModule implements SceneModule { + readonly root = new Group(); + readonly labels = new Map(); + readonly anchors: Partial> = {}; + private readonly cover: CoverObjects; + private readonly liner: BoreLiner; + private readonly inner: Object3D[]; + private readonly gradients: GradientCoils; + private readonly linerMeshes: Mesh[]; + private readonly linerSolid: Material[]; + private readonly linerGlass: Material; + private cutaway = false; + + constructor(context: PartContext) { + this.root.name = 'magnet'; + const looks = createMagnetLooks(context); + this.cover = buildCover(context); + this.liner = buildBoreLiner(context); + this.gradients = new GradientCoils(context); + const cryostat = buildCryostat(context, looks); + const coldHead = buildColdHead(context); + this.inner = [ + ...Object.values(cryostat), + ...Object.values(this.gradients.parts), + buildBodyCoil(context), + ]; + const parts = [ + this.cover.full, + this.liner.group, + ...this.inner, + coldHead, + buildQuenchPipe(context), + ]; + this.root.add(this.cover.cut, ...parts); + const points = labelPoints(); + parts.forEach((part) => { + const id = part.name as PartId; + const point = points[id]; + if (point) this.labels.set(id, anchorAt(part, ...point)); + }); + this.anchors.coldHead = anchorAt(coldHead, 0, COLD_HEAD.top, 0); + this.linerMeshes = this.liner.group.children.filter((child) => child instanceof Mesh); + this.linerSolid = this.linerMeshes.map((mesh) => mesh.material as Material); + this.linerGlass = context.materials.get('bore', LINER_GLASS_FINISH); + this.setCutaway(false); + } + + setState(state: AssemblyState): void { + this.setCutaway(state.view.cutaway); + this.gradients.setTarget( + state.gradientAxis ?? state.sequence.gradient, + state.sequence.gradientLevel, + ); + } + + update(deltaSeconds: number): boolean { + const easing = this.gradients.update(deltaSeconds); + this.showLinerGlass(this.cutaway && this.gradients.glowing); + return easing; + } + + private showLinerGlass(glass: boolean): void { + this.linerMeshes.forEach((mesh, index) => { + mesh.material = glass ? this.linerGlass : this.linerSolid[index]; + }); + } + + private setCutaway(cut: boolean): void { + this.cutaway = cut; + this.cover.full.visible = !cut; + this.cover.cut.visible = cut; + this.liner.wedge.visible = !cut; + this.inner.forEach((part) => { + part.visible = cut; + }); + } +} + +export function createMagnetModule(context: PartContext): SceneModule { + return new MagnetModule(context); +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/rf.ts b/explainers/mri-scanner/src/scene/parts/magnet/rf.ts new file mode 100644 index 00000000..df08521a --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/rf.ts @@ -0,0 +1,115 @@ +import { BoxGeometry, CylinderGeometry, Matrix4, Quaternion, Vector3 } from 'three'; +import type { BufferGeometry, Group } from 'three'; +import { BODY_COIL, BORE } from '../../../model/layout'; +import { rectLoop } from '../../geometry/profile'; +import { sweepProfile } from '../../geometry/sweep'; +import type { Arc } from '../../geometry/sweep'; +import { instanced, mergeParts } from '../context'; +import type { PartContext } from '../context'; +import { BORE_JOINTS } from './cover'; +import { addMeshes, boreGroup, isInWedge, REST_ARC, WEDGE_ARC } from './cutaway'; +import { BODY_COIL_CUT_FINISH, CAPACITOR_FINISH, COPPER_FINISH, LINER_FINISH } from './looks'; +import { slice } from './slice'; + +const SEGMENTS = 112; +const RUNG_SIDES = 10; +const END_RING = { width: 0.025, thickness: 0.008 } as const; +const CAPACITOR = { width: 0.022, height: 0.012, length: 0.016 } as const; +const BODY_COIL_BAND = 0.016; +const LINER_WALL = 0.006; +const LINER_TUCK = 0.01; +const UNIT = new Vector3(1, 1, 1); + +function rungAngles(offset: number): number[] { + const step = (2 * Math.PI) / BODY_COIL.rungs; + return Array.from({ length: BODY_COIL.rungs }, (_, index) => (index + offset) * step).filter( + (angle) => !isInWedge(angle), + ); +} + +function placed(angle: number, radius: number, z: number): Matrix4 { + const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), angle - Math.PI / 2); + return new Matrix4().compose( + new Vector3(radius * Math.cos(angle), radius * Math.sin(angle), z), + turn, + UNIT, + ); +} + +function bodyCoilSlice(): BufferGeometry { + const { radius, halfLength } = BODY_COIL; + const half = BODY_COIL_BAND / 2; + const reach = halfLength + END_RING.width / 2; + return slice( + { outer: rectLoop(radius - half, radius + half, -reach, reach), holes: [] }, + 'bodyCoil', + ); +} + +function endRings(): BufferGeometry { + const { radius, halfLength } = BODY_COIL; + const half = END_RING.thickness / 2; + return mergeParts( + [-1, 1].map((side) => { + const edge = side * halfLength; + const loop = rectLoop( + radius - half, + radius + half, + edge - END_RING.width / 2, + edge + END_RING.width / 2, + ); + return sweepProfile({ outer: loop, holes: [] }, { arc: REST_ARC, segmentsPerTurn: SEGMENTS }); + }), + ); +} + +export function buildBodyCoil(context: PartContext): Group { + const group = boreGroup('bodyCoil'); + const { radius, halfLength, rungRadius } = BODY_COIL; + const rung = new CylinderGeometry(rungRadius, rungRadius, 2 * halfLength, RUNG_SIDES).rotateX( + Math.PI / 2, + ); + const capacitor = new BoxGeometry(CAPACITOR.width, CAPACITOR.height, CAPACITOR.length); + const capRadius = radius + (END_RING.thickness + CAPACITOR.height) / 2; + const capacitors = rungAngles(1).flatMap((angle) => + [-halfLength, halfLength].map((z) => placed(angle, capRadius, z)), + ); + addMeshes(context, group, 'bodyCoil', COPPER_FINISH, [endRings()]); + addMeshes(context, group, 'bodyCoil', BODY_COIL_CUT_FINISH, [bodyCoilSlice()]); + group.add( + instanced( + context, + rung, + 'bodyCoil', + COPPER_FINISH, + rungAngles(0.5).map((angle) => placed(angle, radius, 0)), + ), + instanced(context, capacitor, 'bodyCoil', CAPACITOR_FINISH, capacitors), + ); + return group; +} + +function linerGeometry(arc: Arc): BufferGeometry { + const loop = rectLoop( + BORE.radius - LINER_WALL, + BORE.radius, + BORE_JOINTS.back - LINER_TUCK, + BORE_JOINTS.front + LINER_TUCK, + ); + return sweepProfile({ outer: loop, holes: [] }, { arc, segmentsPerTurn: SEGMENTS }); +} + +export interface BoreLiner { + group: Group; + wedge: Group; +} + +export function buildBoreLiner(context: PartContext): BoreLiner { + const group = boreGroup('bore'); + addMeshes(context, group, 'bore', LINER_FINISH, [linerGeometry(REST_ARC)]); + const wedge = boreGroup('boreWedge'); + wedge.position.set(0, 0, 0); + addMeshes(context, wedge, 'bore', LINER_FINISH, [linerGeometry(WEDGE_ARC)]); + group.add(wedge); + return { group, wedge }; +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/slice.ts b/explainers/mri-scanner/src/scene/parts/magnet/slice.ts new file mode 100644 index 00000000..5b36806c --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/slice.ts @@ -0,0 +1,49 @@ +import type { BufferGeometry } from 'three'; +import type { Profile } from '../../geometry/profile'; +import { sectionCap } from '../../geometry/sweep'; +import type { CapSide, Squash } from '../../geometry/sweep'; +import { mergeParts } from '../context'; +import { REST_ARC } from './cutaway'; + +const LIFT_STEP = 0.001; + +export const SLICE_LAYERS = { + backdrop: -1, + cover: 0, + bodyCoil: 1, + vacuumWall: 1, + vacuumGap: 2, + shieldWall: 3, + shieldGap: 4, + heliumWall: 5, + vapour: 6, + liquid: 7, + coil: 8, +} as const; + +export type SliceLayer = keyof typeof SLICE_LAYERS; + +export interface CutFace { + angle: number; + side: CapSide; +} + +export const CUT_FACES: readonly CutFace[] = [ + { angle: REST_ARC.start, side: -1 }, + { angle: REST_ARC.end, side: 1 }, +]; + +export function sliceFaces( + layer: SliceLayer, + profileAt: (face: CutFace) => Profile, + squash?: Squash, +): BufferGeometry { + const lift = SLICE_LAYERS[layer] * LIFT_STEP; + return mergeParts( + CUT_FACES.map((face) => sectionCap(profileAt(face), face.angle, face.side, { squash, lift })), + ); +} + +export function slice(profile: Profile, layer: SliceLayer, squash?: Squash): BufferGeometry { + return sliceFaces(layer, () => profile, squash); +} diff --git a/explainers/mri-scanner/src/scene/parts/magnet/turret.ts b/explainers/mri-scanner/src/scene/parts/magnet/turret.ts new file mode 100644 index 00000000..378e0301 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/turret.ts @@ -0,0 +1,151 @@ +import { + CatmullRomCurve3, + CylinderGeometry, + Group, + LatheGeometry, + MathUtils, + TubeGeometry, + Vector2, + Vector3, +} from 'three'; +import type { BufferGeometry } from 'three'; +import { COLD_HEAD, FLOOR_Y, ISOCENTRE, MAGNET, QUENCH_PIPE } from '../../../model/layout'; +import { addMeshes } from './cutaway'; +import type { PartContext } from '../context'; +import { HOSE_FINISH, PIPE_FINISH, TURRET_FINISH } from './looks'; + +const LATHE_SEGMENTS = 48; +const COVER_TOP = ISOCENTRE[1] + MAGNET.radius; +const NECK_RADIUS = 0.1; +const COLLAR = { radius: 0.19, height: 0.04 } as const; +const RIB = { depth: 0.012, pitch: 0.025 } as const; +const CAP = { radius: 0.18, height: 0.03 } as const; +const MOTOR = { radius: 0.05, height: 0.16, offset: 0.115 } as const; +const HOSE = { radius: 0.018, spacing: 0.05, tubular: 64, radial: 8 } as const; +const HOSE_ROUTE = { + lift: 0.08, + pullBack: 0.15, + rimGap: 0.01, + backGap: 0.05, + rimDegrees: [100, 135, 165], + dropHeight: 0.6, + knee: { height: 0.14, reach: 0.1 }, + floorRun: 0.3, + sink: { depth: 0.06, reach: 0.12 }, +} as const; +const PIPE_FLANGE = { radius: 0.085, height: 0.02 } as const; +const BELLOWS = { from: 0.12, to: 0.28, depth: 0.012, pitch: 0.016 } as const; +const CEILING_FLANGE = { radius: 0.12, height: 0.02 } as const; + +function ribs(radius: number, depth: number, from: number, to: number, pitch: number): Vector2[] { + const count = Math.floor((to - from) / pitch); + return Array.from({ length: count }, (_, index) => { + const y = from + index * pitch; + return [ + new Vector2(radius, y), + new Vector2(radius + depth, y + pitch * 0.2), + new Vector2(radius + depth, y + pitch * 0.6), + new Vector2(radius, y + pitch * 0.8), + ]; + }).flat(); +} + +function turretProfile(): Vector2[] { + const foot = 2 * COLD_HEAD.centre[1] - COLD_HEAD.top; + const shoulder = COVER_TOP - COLLAR.height / 2; + const capFoot = COLD_HEAD.top - CAP.height; + return [ + new Vector2(0, foot), + new Vector2(NECK_RADIUS, foot), + new Vector2(NECK_RADIUS, shoulder), + new Vector2(COLLAR.radius, shoulder), + new Vector2(COLLAR.radius, shoulder + COLLAR.height), + new Vector2(COLD_HEAD.radius, shoulder + COLLAR.height), + ...ribs(COLD_HEAD.radius, RIB.depth, shoulder + COLLAR.height, capFoot, RIB.pitch), + new Vector2(CAP.radius, capFoot), + new Vector2(CAP.radius, COLD_HEAD.top), + new Vector2(0, COLD_HEAD.top), + ]; +} + +function pipeProfile(): Vector2[] { + const { from, to, radius } = QUENCH_PIPE; + return [ + new Vector2(0, from), + new Vector2(PIPE_FLANGE.radius, from), + new Vector2(PIPE_FLANGE.radius, from + PIPE_FLANGE.height), + new Vector2(radius, from + PIPE_FLANGE.height), + ...ribs(radius, BELLOWS.depth, from + BELLOWS.from, from + BELLOWS.to, BELLOWS.pitch), + new Vector2(radius, to - CEILING_FLANGE.height), + new Vector2(CEILING_FLANGE.radius, to - CEILING_FLANGE.height), + new Vector2(CEILING_FLANGE.radius, to), + new Vector2(0, to), + ]; +} + +function hoseRoute(offset: number): Vector3[] { + const start = new Vector3( + COLD_HEAD.centre[0] + MOTOR.offset + offset, + COLD_HEAD.top + MOTOR.height, + COLD_HEAD.centre[2], + ); + const rim = MAGNET.radius + HOSE.radius + HOSE_ROUTE.rimGap + offset; + const back = -(MAGNET.halfLength + HOSE.radius + HOSE_ROUTE.backGap); + const floor = FLOOR_Y + HOSE.radius; + const { knee, sink } = HOSE_ROUTE; + const kneeZ = back - knee.reach; + const runEnd = back - HOSE_ROUTE.floorRun; + const onRim = (degrees: number) => { + const angle = MathUtils.degToRad(degrees); + return new Vector3(rim * Math.cos(angle), ISOCENTRE[1] + rim * Math.sin(angle), back); + }; + return [ + start, + start.clone().add(new Vector3(0, HOSE_ROUTE.lift, -HOSE_ROUTE.pullBack)), + ...HOSE_ROUTE.rimDegrees.map(onRim), + new Vector3(-rim, HOSE_ROUTE.dropHeight, back), + new Vector3(-rim, floor + knee.height, kneeZ), + new Vector3(-rim, floor, kneeZ - knee.reach), + new Vector3(-rim, floor, runEnd), + new Vector3(-rim, FLOOR_Y - sink.depth, runEnd - sink.reach), + ]; +} + +function hoses(origin: Vector3): BufferGeometry[] { + return [-1, 1].map((side) => { + const route = hoseRoute((side * HOSE.spacing) / 2).map((point) => point.sub(origin)); + const curve = new CatmullRomCurve3(route, false, 'centripetal'); + return new TubeGeometry(curve, HOSE.tubular, HOSE.radius, HOSE.radial); + }); +} + +function placedGroup(name: string): Group { + const group = new Group(); + group.name = name; + group.position.set(COLD_HEAD.centre[0], 0, COLD_HEAD.centre[2]); + return group; +} + +export function buildColdHead(context: PartContext): Group { + const group = placedGroup('coldHead'); + const motor = new CylinderGeometry( + MOTOR.radius, + MOTOR.radius, + MOTOR.height, + LATHE_SEGMENTS / 2, + ).translate(MOTOR.offset, COLD_HEAD.top + MOTOR.height / 2, 0); + addMeshes(context, group, 'coldHead', TURRET_FINISH, [ + new LatheGeometry(turretProfile(), LATHE_SEGMENTS), + motor, + ]); + addMeshes(context, group, 'coldHead', HOSE_FINISH, hoses(group.position)); + return group; +} + +export function buildQuenchPipe(context: PartContext): Group { + const group = placedGroup('quenchPipe'); + addMeshes(context, group, 'quenchPipe', PIPE_FINISH, [ + new LatheGeometry(pipeProfile(), LATHE_SEGMENTS), + ]); + return group; +} From 26f1411de486fc08dba7db0e6371e8fe54a39fa8 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:54 +0200 Subject: [PATCH 49/82] feat(mri-scanner): add the shielded room, control window and monitor --- .../src/scene/parts/room/farSide.ts | 49 +++ .../src/scene/parts/room/monitor.ts | 102 ++++++ .../src/scene/parts/room/room.dom.test.ts | 146 ++++++++ .../mri-scanner/src/scene/parts/room/room.ts | 338 ++++++++++++++++++ .../mri-scanner/src/scene/parts/room/shell.ts | 57 +++ .../src/scene/parts/room/surfaces.ts | 173 +++++++++ 6 files changed, 865 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/parts/room/farSide.ts create mode 100644 explainers/mri-scanner/src/scene/parts/room/monitor.ts create mode 100644 explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/parts/room/room.ts create mode 100644 explainers/mri-scanner/src/scene/parts/room/shell.ts create mode 100644 explainers/mri-scanner/src/scene/parts/room/surfaces.ts diff --git a/explainers/mri-scanner/src/scene/parts/room/farSide.ts b/explainers/mri-scanner/src/scene/parts/room/farSide.ts new file mode 100644 index 00000000..b0a6fde5 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/farSide.ts @@ -0,0 +1,49 @@ +import { Mesh, Vector3 } from 'three'; +import type { BufferGeometry, MeshStandardMaterial, Raycaster } from 'three'; +import { createMaterial } from '@core/scene/materials'; +import type { MaterialFinish } from '@core/scene/materials'; +import type { EmphasisGroup, PartContext } from '../context'; +import { registered } from '../context'; + +export interface WallSide { + readonly normal: readonly [number, number, number]; + readonly offset: number; +} + +const DECLARATIONS = 'uniform vec3 farSideNormal;\nuniform float farSideOffset;\nvoid main() {'; +const DISCARD = '\n if ( dot( cameraPosition, farSideNormal ) > farSideOffset ) discard;'; +const CACHE_KEY = 'mriFarSide'; + +export function isBeyond(point: Vector3, side: WallSide): boolean { + return point.dot(new Vector3(...side.normal)) > side.offset; +} + +export function farSideMaterial( + context: PartContext, + group: EmphasisGroup, + finish: MaterialFinish, + side: WallSide, +): MeshStandardMaterial { + const material = createMaterial(finish); + material.onBeforeCompile = (shader) => { + shader.uniforms.farSideNormal = { value: new Vector3(...side.normal) }; + shader.uniforms.farSideOffset = { value: side.offset }; + shader.fragmentShader = shader.fragmentShader.replace('void main() {', DECLARATIONS + DISCARD); + }; + material.customProgramCacheKey = () => CACHE_KEY; + return registered(context, group, material); +} + +export function farSideMesh( + context: PartContext, + geometry: BufferGeometry, + material: MeshStandardMaterial, + side: WallSide, +): Mesh { + const mesh = new Mesh(context.tracker.track(geometry), material); + mesh.raycast = (raycaster: Raycaster, hits) => { + if (isBeyond(raycaster.ray.origin, side)) return; + Mesh.prototype.raycast.call(mesh, raycaster, hits); + }; + return mesh; +} diff --git a/explainers/mri-scanner/src/scene/parts/room/monitor.ts b/explainers/mri-scanner/src/scene/parts/room/monitor.ts new file mode 100644 index 00000000..7c5f8cd2 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/monitor.ts @@ -0,0 +1,102 @@ +import { + BoxGeometry, + DataTexture, + LinearFilter, + MathUtils, + Matrix4, + PlaneGeometry, + RGBAFormat, + SRGBColorSpace, + Vector3, +} from 'three'; +import type { BufferGeometry } from 'three'; +import { CONTROL_WINDOW, ROOM, SCREEN } from '../../../model/layout'; +import { ROOM_DETAIL } from '../../constants'; +import { mergeParts } from '../context'; + +const QUARTER = Math.PI / 2; +const CHANNELS = 4; +const FULL = 255; +const BAR_LEVEL = 7; +const SURFACE_LIFT = 0.002; +const WALL_X = ROOM.x[1]; +const MOUNT = { + standoff: 0.34, + windowGap: 0.6, + bezel: 0.018, + depth: 0.03, + hump: { width: 0.36, height: 0.26, depth: 0.03 }, + arm: 0.045, + plate: { thickness: 0.016, height: 0.22, width: 0.12 }, +} as const; + +export const MONITOR_CENTRE = [ + WALL_X - MOUNT.standoff, + SCREEN.centre[1], + CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2 - MOUNT.windowGap, +] as const; + +const PICTURE_ROWS = ROOM_DETAIL.pictureSize; +export const PICTURE_COLUMNS = Math.round((PICTURE_ROWS * SCREEN.width) / SCREEN.height); +const BAR_COLUMNS = Math.floor((PICTURE_COLUMNS - PICTURE_ROWS) / 2); + +function monitorMatrix(): Matrix4 { + return new Matrix4() + .makeRotationY(SCREEN.yawTowardTable - QUARTER) + .setPosition(...MONITOR_CENTRE); +} + +function wallArm(matrix: Matrix4): BufferGeometry[] { + const { depth, hump, arm, plate } = MOUNT; + const back = depth + hump.depth; + const reach = (WALL_X - MONITOR_CENTRE[0]) / Math.cos(SCREEN.yawTowardTable) - back; + const end = new Vector3(0, 0, -(back + reach)).applyMatrix4(matrix); + return [ + new BoxGeometry(arm, arm, reach).translate(0, 0, -(back + reach / 2)).applyMatrix4(matrix), + new BoxGeometry(plate.thickness, plate.height, plate.width).translate( + WALL_X - plate.thickness / 2, + end.y, + end.z, + ), + ]; +} + +export function monitorGeometry(): { housing: BufferGeometry; display: BufferGeometry } { + const { bezel, depth, hump } = MOUNT; + const matrix = monitorMatrix(); + const frame = new BoxGeometry(SCREEN.width + 2 * bezel, SCREEN.height + 2 * bezel, depth); + const back = new BoxGeometry(hump.width, hump.height, hump.depth); + return { + housing: mergeParts([ + frame.translate(0, 0, -depth / 2).applyMatrix4(matrix), + back.translate(0, 0, -depth - hump.depth / 2).applyMatrix4(matrix), + ...wallArm(matrix), + ]), + display: new PlaneGeometry(SCREEN.width, SCREEN.height) + .translate(0, 0, SURFACE_LIFT) + .applyMatrix4(matrix), + }; +} + +export function pictureTexture(): DataTexture { + const data = new Uint8Array(PICTURE_COLUMNS * PICTURE_ROWS * CHANNELS).fill(BAR_LEVEL); + for (let at = CHANNELS - 1; at < data.length; at += CHANNELS) data[at] = FULL; + const texture = new DataTexture(data, PICTURE_COLUMNS, PICTURE_ROWS, RGBAFormat); + texture.colorSpace = SRGBColorSpace; + texture.magFilter = LinearFilter; + texture.minFilter = LinearFilter; + texture.needsUpdate = true; + return texture; +} + +export function writePicture(texture: DataTexture, picture: Float32Array): void { + const data = texture.image.data as Uint8Array; + for (let row = 0; row < PICTURE_ROWS; row += 1) { + for (let column = 0; column < PICTURE_ROWS; column += 1) { + const value = MathUtils.clamp(picture[row * PICTURE_ROWS + column] ?? 0, 0, 1); + const pixel = (PICTURE_ROWS - 1 - row) * PICTURE_COLUMNS + BAR_COLUMNS + column; + data.fill(Math.round(value * FULL), pixel * CHANNELS, (pixel + 1) * CHANNELS - 1); + } + } + texture.needsUpdate = true; +} diff --git a/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts b/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts new file mode 100644 index 00000000..36e61c91 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { Box3, Mesh, Raycaster, Vector3 } from 'three'; +import type { DataTexture, MeshStandardMaterial, Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { ResourceTracker } from '@core/scene/resources'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState } from '../../../ids'; +import { MODEL_SIZE } from '../../../model/constants'; +import { CONTROL_WINDOW, ROOM, SCREEN } from '../../../model/layout'; +import { MONITOR_CENTRE } from './monitor'; +import { createRoomModule } from './room'; + +const TRIANGLE_BUDGET = 20000; +const CHANNELS = 4; + +function context() { + return { + materials: new MaterialLibrary(), + textures: createSceneTextures(), + tracker: new ResourceTracker(), + }; +} + +function pictureState(version: number, level: number): AssemblyState { + return { + picture: new Float32Array(MODEL_SIZE * MODEL_SIZE).fill(level), + pictureVersion: version, + } as AssemblyState; +} + +function worldPoint(object: Object3D): number[] { + object.updateWorldMatrix(true, false); + return object.getWorldPosition(new Vector3()).toArray(); +} + +function meshes(root: Object3D): Mesh[] { + const found: Mesh[] = []; + root.traverse((object) => { + if (object instanceof Mesh) found.push(object); + }); + return found; +} + +function triangles(root: Object3D): number { + return meshes(root).reduce((sum, mesh) => { + const { index, attributes } = mesh.geometry; + return sum + (index ? index.count : attributes.position.count) / 3; + }, 0); +} + +function screenTexture(root: Object3D): DataTexture { + const display = meshes(root.getObjectByName('screen') as Object3D).find( + (mesh) => (mesh.material as MeshStandardMaterial).emissiveMap, + ); + return (display?.material as MeshStandardMaterial).emissiveMap as DataTexture; +} + +describe('room module', () => { + it('names the room and the screen and anchors the screen at its centre', () => { + const room = createRoomModule(context()); + expect(room.root.getObjectByName('room')).toBeDefined(); + expect(room.root.getObjectByName('screen')).toBeDefined(); + expect(worldPoint(room.anchors.screen as Object3D)).toEqual([...MONITOR_CENTRE]); + expect([...room.labels.keys()]).toEqual(['room', 'screen']); + expect(room.update(0.016, 5)).toBe(false); + }); + + it('mounts the monitor beside the control window instead of over it', () => { + const screen = createRoomModule(context()).root.getObjectByName('screen') as Object3D; + const bounds = new Box3().setFromObject(screen); + const windowStart = CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2; + expect(bounds.max.z).toBeLessThan(windowStart); + expect(bounds.max.x).toBeLessThanOrEqual(ROOM.x[1] + 0.01); + }); + + it('uploads the picture only when its version changes', () => { + const room = createRoomModule(context()); + const texture = screenTexture(room.root); + const { width, height } = texture.image; + const centre = (Math.floor(height / 2) * width + Math.floor(width / 2)) * CHANNELS; + room.setState(pictureState(1, 1)); + const uploads = texture.version; + expect((texture.image.data as Uint8Array)[centre]).toBe(255); + room.setState(pictureState(1, 0)); + expect(texture.version).toBe(uploads); + room.setState(pictureState(2, 0)); + expect(texture.version).toBeGreaterThan(uploads); + expect((texture.image.data as Uint8Array)[centre]).toBe(0); + }); + + it('keeps the picture square with dark bars beside it on the wide screen', () => { + const room = createRoomModule(context()); + const texture = screenTexture(room.root); + room.setState(pictureState(1, 1)); + const { width, height, data } = texture.image as { + width: number; + height: number; + data: Uint8Array; + }; + expect(width / height).toBeCloseTo(SCREEN.width / SCREEN.height, 1); + const lit = Array.from({ length: width }, (_, column) => data[column * CHANNELS] === 255); + expect(lit.filter(Boolean)).toHaveLength(height); + expect(lit[0]).toBe(false); + expect(lit[width - 1]).toBe(false); + }); + + it('gives the walls and the floor a thickness that hides with the near walls', () => { + const shell = createRoomModule(context()).root.getObjectByName('roomShell') as Mesh; + expect(shell.geometry.getAttribute('shellSide').itemSize).toBe(4); + expect(new Box3().setFromObject(shell).min.y).toBeLessThan(0); + }); + + it('lets a camera outside the control window look straight through the console', () => { + const room = createRoomModule(context()); + room.root.updateMatrixWorld(true); + const blockers = (x: number, z: number) => + new Raycaster(new Vector3(x, MONITOR_CENTRE[1], z), new Vector3(-1, 0, 0)) + .intersectObject(room.root, true) + .filter((hit) => hit.point.x > 0); + expect(blockers(ROOM.x[1] + 4, CONTROL_WINDOW.centreZ)).toHaveLength(0); + expect(blockers(ROOM.x[1] + 4, MONITOR_CENTRE[2])).toHaveLength(0); + expect(blockers(ROOM.x[1] - 0.1, MONITOR_CENTRE[2]).length).toBeGreaterThan(0); + }); + + it('never blocks the scanner from above, behind or the far side', () => { + const room = createRoomModule(context()); + room.root.updateMatrixWorld(true); + const centre = new Vector3(0, 1, 0); + [ + new Vector3(0, 10, 0), + new Vector3(0, 2, -10), + new Vector3(-10, 2, 0), + new Vector3(0, 2, 10), + ].forEach((eye) => { + const hits = new Raycaster(eye, centre.clone().sub(eye).normalize()).intersectObject( + room.root, + true, + ); + expect(hits.filter((hit) => hit.distance < eye.distanceTo(centre))).toHaveLength(0); + }); + }); + + it('stays within a moderate triangle budget', () => { + expect(triangles(createRoomModule(context()).root)).toBeLessThan(TRIANGLE_BUDGET); + }); +}); diff --git a/explainers/mri-scanner/src/scene/parts/room/room.ts b/explainers/mri-scanner/src/scene/parts/room/room.ts new file mode 100644 index 00000000..238b8b0c --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/room.ts @@ -0,0 +1,338 @@ +import { + BackSide, + BoxGeometry, + ExtrudeGeometry, + Group, + Matrix4, + PlaneGeometry, + Shape, + ShapeGeometry, +} from 'three'; +import type { DataTexture } from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { UNDIMMED_GROUP } from '@core/scene/materials'; +import type { MaterialFinish } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import type { AssemblyState, PartId } from '../../../ids'; +import { CEILING_Y, CONTROL_WINDOW, FLOOR_Y, ROOM } from '../../../model/layout'; +import { ROOM_DETAIL } from '../../constants'; +import { + FINISHES, + SURFACE_LOOK, + glowingFinish, + sheenedGlass, + texturedFinish, +} from '../../finishes'; +import type { PartContext, SceneModule } from '../context'; +import { mergeParts, partMesh } from '../context'; +import { farSideMaterial, farSideMesh } from './farSide'; +import type { WallSide } from './farSide'; +import { MONITOR_CENTRE, monitorGeometry, pictureTexture, writePicture } from './monitor'; +import { ALWAYS_SHOWN, shellMesh } from './shell'; +import type { ShellPiece } from './shell'; +import { displayTexture, floorTexture, glassSheenTexture, wallTexture } from './surfaces'; + +const QUARTER = Math.PI / 2; +const SURFACE_LIFT = 0.002; +const DISPLAY_FILL = { width: 0.92, height: 0.86 } as const; +const DESK_SPAN = { top: 0.9, panel: 0.85 } as const; +const [X0, X1] = ROOM.x; +const [Z0, Z1] = ROOM.z; +const WINDOW_SIDE: WallSide = { normal: [1, 0, 0], offset: X1 }; +const WALL_SIDES: readonly WallSide[] = [ + { normal: [0, 0, -1], offset: -Z0 }, + { normal: [0, 0, 1], offset: Z1 }, + { normal: [-1, 0, 0], offset: -X0 }, + WINDOW_SIDE, +]; +const SHELL = { wall: ROOM_DETAIL.windowDepth, slab: 0.16, holeGrow: 0.004 } as const; +const WINDOW_Z = [ + CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2, + CONTROL_WINDOW.centreZ + CONTROL_WINDOW.width / 2, +] as const; +const WINDOW_HEIGHT = CONTROL_WINDOW.top - CONTROL_WINDOW.sill; +const WINDOW_MID_Y = (CONTROL_WINDOW.sill + CONTROL_WINDOW.top) / 2; + +interface Placement { + readonly yaw: number; + readonly pitch?: number; + readonly at: readonly [number, number, number]; +} + +function placed(geometry: BufferGeometry, placement: Placement): BufferGeometry { + const matrix = new Matrix4() + .makeRotationY(placement.yaw) + .multiply(new Matrix4().makeRotationX(placement.pitch ?? 0)) + .setPosition(...placement.at); + return geometry.applyMatrix4(matrix); +} + +function rect(u0: number, u1: number, v0: number, v1: number): Shape { + return new Shape().moveTo(u0, v0).lineTo(u1, v0).lineTo(u1, v1).lineTo(u0, v1).closePath(); +} + +function windowHole(grow: number): Shape { + return rect( + WINDOW_Z[0] - grow, + WINDOW_Z[1] + grow, + CONTROL_WINDOW.sill - grow, + CONTROL_WINDOW.top + grow, + ); +} + +function wallPlacements(inset: number): readonly Placement[] { + return [ + { yaw: 0, at: [0, 0, Z0 + inset] }, + { yaw: Math.PI, at: [0, 0, Z1 - inset] }, + { yaw: QUARTER, at: [X0 + inset, 0, 0] }, + { yaw: -QUARTER, at: [X1 - inset, 0, 0] }, + ]; +} + +function wallShapes(top: number, bottom = FLOOR_Y, grow = 0, holeGrow = 0): readonly Shape[] { + const plusX = rect(Z0 - grow, Z1 + grow, bottom, top); + if (top > CONTROL_WINDOW.sill) plusX.holes.push(windowHole(holeGrow)); + return [ + rect(X0 - grow, X1 + grow, bottom, top), + rect(X0 - grow, X1 + grow, bottom, top), + rect(-Z1 - grow, -Z0 + grow, bottom, top), + plusX, + ]; +} + +function walls(top: number, inset: number): BufferGeometry { + const placements = wallPlacements(inset); + return mergeParts( + wallShapes(top).map((shape, index) => placed(new ShapeGeometry(shape), placements[index])), + ); +} + +function shellPieces(): readonly ShellPiece[] { + const { wall, slab, holeGrow } = SHELL; + const bottom = FLOOR_Y - slab - SURFACE_LIFT; + const placements = wallPlacements(0); + const slabs = wallShapes(CEILING_Y, bottom, wall, holeGrow).map((shape, index) => ({ + geometry: placed( + new ExtrudeGeometry(shape, { depth: wall, bevelEnabled: false }).translate( + 0, + 0, + -wall - SURFACE_LIFT, + ), + placements[index], + ), + side: WALL_SIDES[index], + })); + const base = new BoxGeometry(X1 - X0 + 2 * wall, slab, Z1 - Z0 + 2 * wall).translate( + (X0 + X1) / 2, + FLOOR_Y - slab / 2 - SURFACE_LIFT, + (Z0 + Z1) / 2, + ); + return [...slabs, { geometry: base, side: ALWAYS_SHOWN }]; +} + +function floor(): BufferGeometry { + return placed(new ShapeGeometry(rect(X0, X1, -Z1, -Z0)), { + yaw: 0, + pitch: -QUARTER, + at: [0, FLOOR_Y, 0], + }); +} + +function ceiling(): BufferGeometry { + return placed(new ShapeGeometry(rect(X0, X1, Z0, Z1)), { + yaw: 0, + pitch: QUARTER, + at: [0, CEILING_Y, 0], + }); +} + +function lightPanels(): BufferGeometry { + const { size, gap, columns, rows } = ROOM_DETAIL.lightPanel; + return mergeParts( + columns.flatMap((x) => + rows.map((z) => + placed(new PlaneGeometry(size, size), { + yaw: 0, + pitch: QUARTER, + at: [x, CEILING_Y - gap, z], + }), + ), + ), + ); +} + +function windowFrame(): BufferGeometry { + const { windowDepth: depth, frameWidth } = ROOM_DETAIL; + const ring = windowHole(frameWidth); + ring.holes.push(windowHole(0)); + const midX = X1 + depth / 2; + const reveal = (width: number, height: number, placement: Placement) => + placed(new PlaneGeometry(width, height), placement); + return mergeParts([ + placed(new ShapeGeometry(ring), { yaw: -QUARTER, at: [X1 - ROOM_DETAIL.skirtingInset, 0, 0] }), + reveal(CONTROL_WINDOW.width, depth, { + yaw: QUARTER, + pitch: -QUARTER, + at: [midX, CONTROL_WINDOW.sill, CONTROL_WINDOW.centreZ], + }), + reveal(CONTROL_WINDOW.width, depth, { + yaw: QUARTER, + pitch: QUARTER, + at: [midX, CONTROL_WINDOW.top, CONTROL_WINDOW.centreZ], + }), + reveal(depth, WINDOW_HEIGHT, { yaw: 0, at: [midX, WINDOW_MID_Y, WINDOW_Z[0]] }), + reveal(depth, WINDOW_HEIGHT, { yaw: Math.PI, at: [midX, WINDOW_MID_Y, WINDOW_Z[1]] }), + ]); +} + +function glass(): BufferGeometry { + return placed(new PlaneGeometry(CONTROL_WINDOW.width, WINDOW_HEIGHT), { + yaw: -QUARTER, + at: [X1 + ROOM_DETAIL.glassInset, WINDOW_MID_Y, CONTROL_WINDOW.centreZ], + }); +} + +const CONTROL_FRONT = X1 + ROOM_DETAIL.windowDepth; +const CONTROL_WIDTH = CONTROL_WINDOW.width + 2 * ROOM_DETAIL.monitor.spread; + +function controlShell(): BufferGeometry { + const { depth } = ROOM_DETAIL.controlRoom; + return new BoxGeometry(depth, CEILING_Y, CONTROL_WIDTH).translate( + CONTROL_FRONT + depth / 2, + CEILING_Y / 2, + CONTROL_WINDOW.centreZ, + ); +} + +const DESK_X = CONTROL_FRONT + ROOM_DETAIL.controlRoom.deskDepth / 2; + +function desk(): BufferGeometry { + const { deskTop, deskDepth, deskHeight } = ROOM_DETAIL.controlRoom; + const top = new BoxGeometry(deskDepth, deskHeight, CONTROL_WIDTH * DESK_SPAN.top); + const panel = new BoxGeometry(deskHeight, deskTop, CONTROL_WIDTH * DESK_SPAN.panel); + return mergeParts([ + top.translate(DESK_X, deskTop - deskHeight / 2, CONTROL_WINDOW.centreZ), + panel.translate(DESK_X + deskDepth / 2 - deskHeight, deskTop / 2, CONTROL_WINDOW.centreZ), + ]); +} + +function monitorPlacements(): readonly Placement[] { + const { lift, spread, yaw } = ROOM_DETAIL.monitor; + const y = ROOM_DETAIL.controlRoom.deskTop + lift; + return [-1, 0, 1].map((side) => ({ + yaw: -QUARTER - side * yaw, + at: [DESK_X, y, CONTROL_WINDOW.centreZ + side * spread] as const, + })); +} + +function monitors(): { bezels: BufferGeometry; displays: BufferGeometry } { + const { width, height, depth } = ROOM_DETAIL.monitor; + const placements = monitorPlacements(); + const bezel = (placement: Placement) => + placed(new BoxGeometry(width, height, depth).translate(0, 0, -depth / 2), placement); + const display = (placement: Placement) => + placed( + new PlaneGeometry(width * DISPLAY_FILL.width, height * DISPLAY_FILL.height).translate( + 0, + 0, + SURFACE_LIFT, + ), + placement, + ); + return { + bezels: mergeParts(placements.map(bezel)), + displays: mergeParts(placements.map(display)), + }; +} + +class RoomModule implements SceneModule { + readonly root = new Group(); + readonly labels: ReadonlyMap; + readonly anchors: SceneModule['anchors']; + private readonly picture: DataTexture; + private pictureVersion = Number.NaN; + + private readonly context: PartContext; + + constructor(context: PartContext) { + this.context = context; + this.picture = context.tracker.track(pictureTexture()); + const room = this.buildRoom(); + const screen = this.buildScreen(); + this.root.add(room, screen); + const screenAnchor = anchorAt(screen, ...MONITOR_CENTRE); + this.labels = new Map([ + ['room', anchorAt(room, X0, FLOOR_Y, Z0)], + ['screen', screenAnchor], + ]); + this.anchors = { screen: screenAnchor }; + } + + setState(state: AssemblyState): void { + if (state.pictureVersion === this.pictureVersion) return; + this.pictureVersion = state.pictureVersion; + writePicture(this.picture, state.picture); + } + + update(): boolean { + return false; + } + + private buildRoom(): Group { + const { context } = this; + const { tracker } = context; + const room = new Group(); + room.name = 'room'; + const wallLook = texturedFinish(SURFACE_LOOK.wall, tracker.track(wallTexture())); + const floorLook = texturedFinish(SURFACE_LOOK.floor, tracker.track(floorTexture())); + const panel = (geometry: BufferGeometry, finish: MaterialFinish) => + partMesh(context, geometry, UNDIMMED_GROUP, finish); + room.add( + panel(walls(CEILING_Y, 0), wallLook), + panel(floor(), floorLook), + panel(walls(ROOM_DETAIL.skirtingHeight, ROOM_DETAIL.skirtingInset), FINISHES.skirting), + panel(ceiling(), FINISHES.ceiling), + panel(lightPanels(), FINISHES.lightPanel), + shellMesh(context, shellPieces(), UNDIMMED_GROUP, FINISHES.shell), + ); + const behind = (geometry: BufferGeometry, finish: MaterialFinish) => + farSideMesh( + context, + geometry, + farSideMaterial(context, UNDIMMED_GROUP, finish, WINDOW_SIDE), + WINDOW_SIDE, + ); + const consoleLook = glowingFinish(tracker.track(displayTexture()), SURFACE_LOOK.console); + const { bezels, displays } = monitors(); + room.add( + behind(windowFrame(), FINISHES.frame), + behind(glass(), sheenedGlass(tracker.track(glassSheenTexture()))), + behind(controlShell(), { ...FINISHES.controlShell, side: BackSide }), + behind(desk(), FINISHES.desk), + behind(bezels, FINISHES.bezel), + behind(displays, consoleLook), + ); + return room; + } + + private buildScreen(): Group { + const { context } = this; + const screen = new Group(); + screen.name = 'screen'; + const { housing, display } = monitorGeometry(); + const look = glowingFinish(this.picture, SURFACE_LOOK.picture); + const mounted = (geometry: BufferGeometry, finish: MaterialFinish) => + farSideMesh( + context, + geometry, + farSideMaterial(context, 'screen', finish, WINDOW_SIDE), + WINDOW_SIDE, + ); + screen.add(mounted(housing, FINISHES.screenFrame), mounted(display, look)); + return screen; + } +} + +export function createRoomModule(context: PartContext): SceneModule { + return new RoomModule(context); +} diff --git a/explainers/mri-scanner/src/scene/parts/room/shell.ts b/explainers/mri-scanner/src/scene/parts/room/shell.ts new file mode 100644 index 00000000..423e6594 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/shell.ts @@ -0,0 +1,57 @@ +import { Float32BufferAttribute, Mesh } from 'three'; +import type { BufferGeometry, MeshStandardMaterial } from 'three'; +import { createMaterial } from '@core/scene/materials'; +import type { MaterialFinish } from '@core/scene/materials'; +import type { EmphasisGroup, PartContext } from '../context'; +import { mergeParts, registered } from '../context'; +import type { WallSide } from './farSide'; + +export interface ShellPiece { + readonly geometry: BufferGeometry; + readonly side: WallSide; +} + +const ATTRIBUTE = 'shellSide'; +const PLANE_SIZE = 4; +const CACHE_KEY = 'mriRoomShell'; +const VERTEX_DECLARATIONS = `attribute vec4 ${ATTRIBUTE};\nvarying float shellHidden;\nvoid main() {\n shellHidden = step( ${ATTRIBUTE}.w, dot( cameraPosition, ${ATTRIBUTE}.xyz ) );`; +const FRAGMENT_DECLARATIONS = + 'varying float shellHidden;\nvoid main() {\n if ( shellHidden > 0.5 ) discard;'; + +export const ALWAYS_SHOWN: WallSide = { normal: [0, 1, 0], offset: Number.MAX_SAFE_INTEGER }; + +function tagged({ geometry, side }: ShellPiece): BufferGeometry { + const prepared = geometry.index ? geometry.toNonIndexed() : geometry; + const plane = [...side.normal, side.offset]; + const values = new Float32Array(prepared.attributes.position.count * PLANE_SIZE); + for (let at = 0; at < values.length; at += PLANE_SIZE) values.set(plane, at); + prepared.setAttribute(ATTRIBUTE, new Float32BufferAttribute(values, PLANE_SIZE)); + return prepared; +} + +function shellMaterial( + context: PartContext, + group: EmphasisGroup, + finish: MaterialFinish, +): MeshStandardMaterial { + const material = createMaterial(finish); + material.onBeforeCompile = (shader) => { + shader.vertexShader = shader.vertexShader.replace('void main() {', VERTEX_DECLARATIONS); + shader.fragmentShader = shader.fragmentShader.replace('void main() {', FRAGMENT_DECLARATIONS); + }; + material.customProgramCacheKey = () => CACHE_KEY; + return registered(context, group, material); +} + +export function shellMesh( + context: PartContext, + pieces: readonly ShellPiece[], + group: EmphasisGroup, + finish: MaterialFinish, +): Mesh { + const geometry = mergeParts(pieces.map(tagged)); + const mesh = new Mesh(context.tracker.track(geometry), shellMaterial(context, group, finish)); + mesh.name = 'roomShell'; + mesh.raycast = () => undefined; + return mesh; +} diff --git a/explainers/mri-scanner/src/scene/parts/room/surfaces.ts b/explainers/mri-scanner/src/scene/parts/room/surfaces.ts new file mode 100644 index 00000000..a0772817 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/surfaces.ts @@ -0,0 +1,173 @@ +import { CanvasTexture, RepeatWrapping, SRGBColorSpace } from 'three'; +import { PAINT } from '../../finishes'; +import { ROOM_DETAIL } from '../../constants'; + +type Paint = (context: CanvasRenderingContext2D, size: number) => void; + +const ANISOTROPY = 8; +const WALL_SHEET_TILE = 2.4; +const SEAM_ALPHA = 0.18; +const GROUT = 2; +const SHEET_COLUMNS = 2; +const SHEET_TONE_SPREAD = 0.12; +const SHEET_SEAM_DARK = 'rgba(16, 9, 5, 0.6)'; +const SHEET_SEAM_LIGHT = 'rgba(255, 214, 180, 0.08)'; +const BRUSH_STROKES = 120; +const BRUSH_ALPHA = 0.014; +const SHEET_SHEEN = 'rgba(255, 196, 150, 0.1)'; +const SCREW_STEP = 6; +const SCREW_RADIUS = 1; +const SCREW_INSET = 4; +const SCREW_FILL = 'rgba(20, 12, 8, 0.35)'; +const SHEEN_BANDS = [ + { at: 0.3, width: 0.16, alpha: 0.55 }, + { at: 0.52, width: 0.05, alpha: 0.35 }, +] as const; +const SHEEN_SLANT = 0.45; +const SPECKLES = 900; +const SPECKLE_ALPHA = 0.06; +const DISPLAY_ROWS = 6; +const TRACE_POINTS = 48; +const TRACE_SWING = 0.3; +const TRACE_TURNS = 5; + +function canvasTexture(paint: Paint, repeat: number): CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = ROOM_DETAIL.textureSize; + canvas.height = ROOM_DETAIL.textureSize; + const context = canvas.getContext('2d'); + if (context) paint(context, ROOM_DETAIL.textureSize); + const texture = new CanvasTexture(canvas); + texture.colorSpace = SRGBColorSpace; + texture.anisotropy = ANISOTROPY; + texture.wrapS = RepeatWrapping; + texture.wrapT = RepeatWrapping; + texture.repeat.set(repeat, repeat); + return texture; +} + +function seededRandom(seed: number): () => number { + let state = seed; + return () => { + state = (state * 16807) % 2147483647; + return state / 2147483647; + }; +} + +function brushStreaks(context: CanvasRenderingContext2D, size: number): void { + const random = seededRandom(11); + for (let index = 0; index < BRUSH_STROKES; index += 1) { + const shade = random() > 0.5 ? 255 : 0; + context.fillStyle = `rgba(${shade}, ${shade}, ${shade}, ${BRUSH_ALPHA})`; + context.fillRect(random() * size, 0, 1, size); + } +} + +function screwRow(context: CanvasRenderingContext2D, x: number, y: number, length: number): void { + context.fillStyle = SCREW_FILL; + const step = length / SCREW_STEP; + for (let index = 0; index < SCREW_STEP; index += 1) { + context.beginPath(); + context.arc(x + (index + 0.5) * step, y, SCREW_RADIUS, 0, Math.PI * 2); + context.fill(); + } +} + +function seam(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void { + context.fillStyle = SHEET_SEAM_DARK; + context.fillRect(x, y, w, h); + context.fillStyle = SHEET_SEAM_LIGHT; + context.fillRect(x + (h > w ? 1 : 0), y + (w > h ? 1 : 0), w, h); +} + +function sheetSheen(context: CanvasRenderingContext2D, x: number, column: number, size: number) { + const gradient = context.createLinearGradient(x, 0, x + column, size); + gradient.addColorStop(0, SHEET_SHEEN); + gradient.addColorStop(1, 'rgba(255, 196, 150, 0)'); + context.fillStyle = gradient; + context.fillRect(x, 0, column, size); +} + +function copperSheets(context: CanvasRenderingContext2D, size: number): void { + const column = size / SHEET_COLUMNS; + const random = seededRandom(3); + context.fillStyle = PAINT.copperSheet; + context.fillRect(0, 0, size, size); + for (let index = 0; index < SHEET_COLUMNS; index += 1) { + const tone = (random() - 0.5) * SHEET_TONE_SPREAD; + context.fillStyle = tone > 0 ? `rgba(255, 220, 190, ${tone})` : `rgba(0, 0, 0, ${-tone})`; + context.fillRect(index * column, 0, column, size); + } + brushStreaks(context, size); + for (let index = 0; index < SHEET_COLUMNS; index += 1) { + const x = index * column; + sheetSheen(context, x, column, size); + const y = (index * size) / SHEET_COLUMNS; + seam(context, x, 0, 1, size); + seam(context, x, y, column, 1); + screwRow(context, x, y + SCREW_INSET, column); + } +} + +function glassSheen(context: CanvasRenderingContext2D, size: number): void { + context.fillStyle = '#000000'; + context.fillRect(0, 0, size, size); + SHEEN_BANDS.forEach(({ at, width, alpha }) => { + const gradient = context.createLinearGradient(0, 0, size, size * SHEEN_SLANT); + gradient.addColorStop(Math.max(0, at - width), 'rgba(255, 255, 255, 0)'); + gradient.addColorStop(at, `rgba(255, 255, 255, ${alpha})`); + gradient.addColorStop(Math.min(1, at + width), 'rgba(255, 255, 255, 0)'); + context.fillStyle = gradient; + context.fillRect(0, 0, size, size); + }); +} + +function vinylTile(context: CanvasRenderingContext2D, size: number): void { + context.fillStyle = PAINT.vinyl; + context.fillRect(0, 0, size, size); + const random = seededRandom(7); + for (let index = 0; index < SPECKLES; index += 1) { + const shade = random() > 0.5 ? 255 : 0; + context.fillStyle = `rgba(${shade}, ${shade}, ${shade}, ${SPECKLE_ALPHA})`; + context.fillRect(random() * size, random() * size, 2, 2); + } + context.strokeStyle = `rgba(90, 84, 74, ${SEAM_ALPHA})`; + context.lineWidth = GROUT; + context.strokeRect(0, 0, size, size); +} + +function consoleDisplay(context: CanvasRenderingContext2D, size: number): void { + context.fillStyle = '#081420'; + context.fillRect(0, 0, size, size); + const row = size / DISPLAY_ROWS; + context.fillStyle = PAINT.consoleGlow; + for (let index = 1; index < DISPLAY_ROWS; index += 2) { + context.fillRect(row / 2, index * row, size / 2, row / 4); + } + context.strokeStyle = '#7fc4ff'; + context.lineWidth = GROUT; + context.beginPath(); + for (let index = 0; index <= TRACE_POINTS; index += 1) { + const t = index / TRACE_POINTS; + const y = size * (0.5 + TRACE_SWING * Math.sin(t * Math.PI * TRACE_TURNS) * Math.exp(-2 * t)); + if (index === 0) context.moveTo(size * 0.55, y); + else context.lineTo(size * (0.55 + 0.4 * t), y); + } + context.stroke(); +} + +export function wallTexture(): CanvasTexture { + return canvasTexture(copperSheets, 1 / WALL_SHEET_TILE); +} + +export function floorTexture(): CanvasTexture { + return canvasTexture(vinylTile, 1 / ROOM_DETAIL.floorTile); +} + +export function glassSheenTexture(): CanvasTexture { + return canvasTexture(glassSheen, 1); +} + +export function displayTexture(): CanvasTexture { + return canvasTexture(consoleDisplay, 1); +} From c2228bded556c6d329d3b59fd462e4ce78e5a0c2 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:54 +0200 Subject: [PATCH 50/82] feat(mri-scanner): add the patient, table and head coil --- .../scene/parts/subject/subject.dom.test.ts | 52 +++ .../src/scene/parts/subject/subject.ts | 317 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/parts/subject/subject.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/parts/subject/subject.ts diff --git a/explainers/mri-scanner/src/scene/parts/subject/subject.dom.test.ts b/explainers/mri-scanner/src/scene/parts/subject/subject.dom.test.ts new file mode 100644 index 00000000..ef9c3f81 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/subject/subject.dom.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { Mesh, Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { ResourceTracker } from '@core/scene/resources'; +import { createSceneTextures } from '@core/scene/textures'; +import { HEAD_COIL, PATIENT, TABLE } from '../../../model/layout'; +import { blanketHeight, createSubjectModule } from './subject'; + +const TRIANGLE_BUDGET = 40000; + +function subject() { + return createSubjectModule({ + materials: new MaterialLibrary(), + textures: createSceneTextures(), + tracker: new ResourceTracker(), + }); +} + +function triangles(root: Object3D): number { + let sum = 0; + root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const { index, attributes } = object.geometry; + sum += (index ? index.count : attributes.position.count) / 3; + }); + return sum; +} + +describe('subject module', () => { + it('names the table, the patient and the head coil and anchors the coil at its centre', () => { + const module = subject(); + ['table', 'patient', 'headCoil'].forEach((name) => + expect(module.root.getObjectByName(name)).toBeDefined(), + ); + const anchor = module.anchors.headCoil as Object3D; + anchor.updateWorldMatrix(true, false); + expect(anchor.getWorldPosition(new Vector3()).toArray()).toEqual([...HEAD_COIL.centre]); + expect([...module.labels.keys()]).toEqual(['table', 'patient', 'headCoil']); + expect(module.update(0.016, 5)).toBe(false); + }); + + it('lays the blanket over the body and lets it fall past the cradle edge', () => { + expect(blanketHeight(0, 0.5)).toBeGreaterThan(TABLE.top + 0.15); + expect(blanketHeight(0, PATIENT.feetZ)).toBeGreaterThan(TABLE.top + 0.1); + expect(blanketHeight(0.29, 1)).toBeLessThan(TABLE.top); + }); + + it('stays within a moderate triangle budget', () => { + expect(triangles(subject().root)).toBeLessThan(TRIANGLE_BUDGET); + }); +}); diff --git a/explainers/mri-scanner/src/scene/parts/subject/subject.ts b/explainers/mri-scanner/src/scene/parts/subject/subject.ts new file mode 100644 index 00000000..10ffee89 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/subject/subject.ts @@ -0,0 +1,317 @@ +import { + BoxGeometry, + CapsuleGeometry, + CylinderGeometry, + ExtrudeGeometry, + Group, + PlaneGeometry, + Shape, + SphereGeometry, + TorusGeometry, +} from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import type { PartId } from '../../../ids'; +import { HEAD_COIL, ISOCENTRE, PATIENT, TABLE } from '../../../model/layout'; +import { SUBJECT_DETAIL } from '../../constants'; +import { FINISHES } from '../../finishes'; +import type { PartContext, SceneModule } from '../context'; +import { mergeParts, partMesh } from '../context'; + +const QUARTER = Math.PI / 2; +const CRADLE_BASE = TABLE.top - TABLE.cradleThickness; +const [CX, CY, CZ] = ISOCENTRE; +const ROUND = 6; +const STRUT_SIDES = 8; +const RING_SEGMENTS = 48; +const ARC_SEGMENTS = 16; +const LIMB_SIDES = 16; +const HEAD_SEGMENTS = { width: 32, height: 20 } as const; +const WINDOW_STRUT_SCALE = 1.3; +const HEAD_SCALE = [0.82, 1, 1.12] as const; +const NECK = { radius: 0.05, z: [0.08, 0.2] as const, drop: 0.04 }; +const TORSO = { radius: 0.1, length: 0.5, widen: 1.8, z: 0.45 }; + +type Profile = readonly (readonly [number, number])[]; + +const BODY_HEIGHT: Profile = [ + [0.3, 0.17], + [0.5, 0.2], + [0.8, 0.16], + [1.0, 0.13], + [1.55, 0.09], + [1.68, 0.1], + [1.76, 0.17], + [1.84, 0.06], + [1.92, 0], +]; +const BODY_HALF_WIDTH: Profile = [ + [0.3, 0.23], + [0.9, 0.22], + [1.1, 0.17], + [1.7, 0.11], + [1.92, 0.1], +]; +const LEG_GROOVE = { from: 1.0, depth: 0.025, width: 0.03 }; +const DOME_POWER = 0.6; +const DRAPE_DROP = 0.07; + +function sample(profile: Profile, z: number): number { + const next = profile.findIndex(([at]) => at >= z); + if (next <= 0) return profile[next === 0 ? 0 : profile.length - 1][1]; + const [z0, v0] = profile[next - 1]; + const [z1, v1] = profile[next]; + const t = (z - z0) / (z1 - z0); + return v0 + (v1 - v0) * t * t * (3 - 2 * t); +} + +function smoothstep(value: number): number { + const t = Math.min(1, Math.max(0, value)); + return t * t * (3 - 2 * t); +} + +export function blanketHeight(x: number, z: number): number { + const { loft } = SUBJECT_DETAIL.blanket; + const width = sample(BODY_HALF_WIDTH, z); + const side = Math.abs(x); + if (side <= width) { + const dome = (1 - (side / width) ** 2) ** DOME_POWER; + const groove = + z > LEG_GROOVE.from ? LEG_GROOVE.depth * Math.exp(-((x / LEG_GROOVE.width) ** 2)) : 0; + return TABLE.top + loft + sample(BODY_HEIGHT, z) * dome - groove; + } + const overhang = + (side - TABLE.cradleHalfWidth) / (SUBJECT_DETAIL.blanket.halfWidth - TABLE.cradleHalfWidth); + return ( + TABLE.top + loft * (1 - smoothstep((side - width) / loft)) - DRAPE_DROP * smoothstep(overhang) + ); +} + +function blanket(): BufferGeometry { + const { startZ, endZ, halfWidth, columns, rows } = SUBJECT_DETAIL.blanket; + const geometry = new PlaneGeometry(1, 1, columns, rows); + const position = geometry.getAttribute('position'); + for (let index = 0; index < position.count; index += 1) { + const x = position.getX(index) * 2 * halfWidth; + const z = startZ + (position.getY(index) + 0.5) * (endZ - startZ); + position.setXYZ(index, x, blanketHeight(x, z), z); + } + geometry.computeVertexNormals(); + return geometry; +} + +function roundedSection(halfWidth: number, bottom: number, top: number, corner: number): Shape { + return new Shape() + .moveTo(-halfWidth, bottom) + .lineTo(halfWidth, bottom) + .lineTo(halfWidth, top - corner) + .quadraticCurveTo(halfWidth, top, halfWidth - corner, top) + .lineTo(-halfWidth + corner, top) + .quadraticCurveTo(-halfWidth, top, -halfWidth, top - corner) + .closePath(); +} + +function alongZ(shape: Shape, [from, to]: readonly [number, number]): BufferGeometry { + return new ExtrudeGeometry(shape, { + depth: to - from, + bevelEnabled: false, + curveSegments: ROUND, + }).translate(0, 0, from); +} + +function pedestal(): BufferGeometry { + const { pedestal: column, foot } = SUBJECT_DETAIL; + const section = new Shape() + .moveTo(-column.bottomHalf, 0) + .lineTo(column.bottomHalf, 0) + .lineTo(column.topHalf, column.top) + .lineTo(-column.topHalf, column.top) + .closePath(); + const footLength = foot.z[1] - foot.z[0]; + return mergeParts([ + alongZ(section, column.z), + new BoxGeometry(2 * foot.halfWidth, foot.height, footLength).translate( + 0, + foot.height / 2, + (foot.z[0] + foot.z[1]) / 2, + ), + ]); +} + +function bed(): BufferGeometry { + const { bed: housing } = SUBJECT_DETAIL; + return alongZ( + roundedSection(TABLE.halfWidth, housing.bottom, CRADLE_BASE, housing.corner), + TABLE.z, + ); +} + +function rails(): BufferGeometry { + const { rail } = SUBJECT_DETAIL; + const length = TABLE.z[1] - TABLE.z[0]; + const y = (SUBJECT_DETAIL.bed.bottom + CRADLE_BASE) / 2; + return mergeParts( + [-1, 1].map((side) => + new BoxGeometry(rail.depth, rail.height, length).translate( + side * (TABLE.halfWidth + rail.depth / 2), + y, + (TABLE.z[0] + TABLE.z[1]) / 2, + ), + ), + ); +} + +function cradle(): BufferGeometry { + const half = TABLE.cradleHalfWidth; + const section = new Shape() + .moveTo(-half, CRADLE_BASE) + .lineTo(half, CRADLE_BASE) + .lineTo(half, TABLE.top) + .quadraticCurveTo(0, TABLE.top - 2 * SUBJECT_DETAIL.cradle.sag, -half, TABLE.top) + .closePath(); + return alongZ(section, TABLE.cradleZ); +} + +function headPad(): BufferGeometry { + const { pad } = SUBJECT_DETAIL; + return new BoxGeometry(2 * pad.halfWidth, pad.height, pad.length).translate( + CX, + TABLE.top + pad.height / 2 - SUBJECT_DETAIL.cradle.sag, + CZ, + ); +} + +function skin(): BufferGeometry { + const head = new SphereGeometry(PATIENT.headRadius, HEAD_SEGMENTS.width, HEAD_SEGMENTS.height) + .scale(...HEAD_SCALE) + .translate(CX, CY, CZ); + const neckLength = NECK.z[1] - NECK.z[0]; + const neck = new CylinderGeometry(NECK.radius, NECK.radius, neckLength, LIMB_SIDES) + .rotateX(QUARTER) + .translate(CX, CY - NECK.drop, (NECK.z[0] + NECK.z[1]) / 2); + return mergeParts([head, neck]); +} + +function gown(): BufferGeometry { + return new CapsuleGeometry(TORSO.radius, TORSO.length, STRUT_SIDES, LIMB_SIDES) + .rotateX(QUARTER) + .scale(TORSO.widen, 1, 1) + .translate(CX, TABLE.top + TORSO.radius, TORSO.z); +} + +function strut(angle: number, from: number, to: number, radius: number): BufferGeometry { + return new CylinderGeometry(radius, radius, to - from, STRUT_SIDES) + .rotateX(QUARTER) + .translate( + CX + HEAD_COIL.radius * Math.cos(angle), + CY + HEAD_COIL.radius * Math.sin(angle), + CZ + (from + to) / 2, + ); +} + +function ring(z: number, start: number, arc: number, segments: number): BufferGeometry { + const { ringTube } = SUBJECT_DETAIL.coil; + return new TorusGeometry(HEAD_COIL.radius, ringTube, STRUT_SIDES, segments, arc) + .rotateZ(start) + .translate(CX, CY, CZ + z); +} + +function coilBase(): BufferGeometry { + const [start, sweep] = SUBJECT_DETAIL.coil.baseArc; + const { ringTube } = SUBJECT_DETAIL.coil; + const outer = HEAD_COIL.radius + ringTube; + const inner = HEAD_COIL.radius - ringTube; + const section = new Shape() + .absarc(0, 0, outer, start, start + sweep, false) + .absarc(0, 0, inner, start + sweep, start, true); + const half = HEAD_COIL.length / 2; + return new ExtrudeGeometry(section, { + depth: HEAD_COIL.length, + bevelEnabled: false, + curveSegments: RING_SEGMENTS / 2, + }).translate(CX, CY, CZ - half); +} + +function coilShell(): BufferGeometry { + const { strut: radius, strutAngles, window } = SUBJECT_DETAIL.coil; + const half = HEAD_COIL.length / 2; + const top = QUARTER; + return mergeParts([ + coilBase(), + ring(-half, 0, Math.PI * 2, RING_SEGMENTS), + ring(half, 0, Math.PI * 2, RING_SEGMENTS), + ...strutAngles.map((angle) => strut(angle, -half, half, radius)), + strut(top, -half, window.z[0], radius), + strut(top, window.z[1], half, radius), + ]); +} + +function coilWindow(): BufferGeometry { + const { strut: radius, window } = SUBJECT_DETAIL.coil; + const from = QUARTER - window.halfAngle; + const sweep = 2 * window.halfAngle; + const half = HEAD_COIL.length / 2; + return mergeParts([ + ring(window.z[0], from, sweep, ARC_SEGMENTS), + ring(window.z[1], from, sweep, ARC_SEGMENTS), + strut(from, -half, half, radius * WINDOW_STRUT_SCALE), + strut(from + sweep, -half, half, radius * WINDOW_STRUT_SCALE), + ]); +} + +function part(name: PartId, ...children: Object3D[]): Group { + const group = new Group(); + group.name = name; + group.add(...children); + return group; +} + +class SubjectModule implements SceneModule { + readonly root = new Group(); + readonly labels: ReadonlyMap; + readonly anchors: SceneModule['anchors']; + + constructor(context: PartContext) { + const mesh = ( + id: PartId, + geometry: BufferGeometry, + finish: (typeof FINISHES)[keyof typeof FINISHES], + ) => partMesh(context, geometry, id, finish); + const table = part( + 'table', + mesh('table', pedestal(), FINISHES.pedestal), + mesh('table', bed(), FINISHES.table), + mesh('table', rails(), FINISHES.rail), + mesh('table', cradle(), FINISHES.cradle), + ); + const patient = part( + 'patient', + mesh('patient', headPad(), FINISHES.pad), + mesh('patient', skin(), FINISHES.skin), + mesh('patient', gown(), FINISHES.gown), + mesh('patient', blanket(), FINISHES.blanket), + ); + const headCoil = part( + 'headCoil', + mesh('headCoil', coilShell(), FINISHES.headCoil), + mesh('headCoil', coilWindow(), FINISHES.coilTrim), + ); + this.root.add(table, patient, headCoil); + this.labels = new Map([ + ['table', anchorAt(table, TABLE.halfWidth, TABLE.top, TABLE.z[1])], + ['patient', anchorAt(patient, 0, TABLE.top, PATIENT.feetZ)], + ['headCoil', anchorAt(headCoil, CX, CY + HEAD_COIL.radius, CZ)], + ]); + this.anchors = { headCoil: anchorAt(headCoil, ...HEAD_COIL.centre) }; + } + + setState(): void {} + + update(): boolean { + return false; + } +} + +export function createSubjectModule(context: PartContext): SceneModule { + return new SubjectModule(context); +} From 6d01f662421eb8ace59952654ad00ff813d06b9c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:54 +0200 Subject: [PATCH 51/82] feat(mri-scanner): add the field lines, voxel, radio and echo effects --- .../scene/parts/effects/effects.dom.test.ts | 146 ++++++++ .../src/scene/parts/effects/effects.ts | 68 ++++ .../src/scene/parts/effects/fieldLines.ts | 136 +++++++ .../src/scene/parts/effects/glow.ts | 210 +++++++++++ .../src/scene/parts/effects/looks.ts | 114 ++++++ .../src/scene/parts/effects/paths.test.ts | 100 +++++ .../src/scene/parts/effects/paths.ts | 104 ++++++ .../src/scene/parts/effects/pulses.ts | 180 +++++++++ .../src/scene/parts/effects/voxel.ts | 350 ++++++++++++++++++ 9 files changed, 1408 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/effects.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/fieldLines.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/glow.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/looks.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/paths.test.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/paths.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/pulses.ts create mode 100644 explainers/mri-scanner/src/scene/parts/effects/voxel.ts diff --git a/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts b/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts new file mode 100644 index 00000000..0dad0f80 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts @@ -0,0 +1,146 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { InstancedMesh, Mesh, Vector3 } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { ResourceTracker } from '@core/scene/resources'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState, PartId } from '../../../ids'; +import { FIELDS } from '../../../model/field'; +import { MODEL_SIZE } from '../../../model/constants'; +import { VOXEL, VOXEL_ARROWS } from '../../../model/layout'; +import type { SceneModule } from '../context'; +import { createEffectsModule } from './effects'; + +const EFFECT_IDS: readonly PartId[] = [ + 'sliceSlab', + 'spinArrows', + 'netMagnet', + 'mainField', + 'rfWave', + 'echoWave', + 'fieldLinesGroup', + 'fringeLine', +]; +const FRAME = 1 / 60; +const SETTLE_FRAMES = 240; +const TRIANGLE_BUDGET = 80_000; + +function stateWith(overrides: Partial = {}): AssemblyState { + return { + phase: 500, + playing: false, + field: 'field15', + weighting: 't2', + sequence: { phase: 500, step: 'echo', rf: null, gradient: 'x', gradientLevel: 1, echo: 0 }, + spins: { + net: [0, 0, -1], + arrows: Array.from({ length: VOXEL_ARROWS }, () => [0, 0, -1] as const), + precession: 0, + }, + tissue: 'whiteMatter', + gradientAxis: null, + picture: new Float32Array(MODEL_SIZE * MODEL_SIZE), + pictureVersion: 0, + fringe: FIELDS.field15.fringe, + view: { cutaway: false, fieldLines: false, voxel: false, labels: true }, + ...overrides, + }; +} + +function settle(module: SceneModule): boolean { + let moving = true; + for (let frame = 0; frame < SETTLE_FRAMES && moving; frame += 1) { + moving = module.update(FRAME, 1); + } + return moving; +} + +function triangles(module: SceneModule): number { + let total = 0; + module.root.traverse((object) => { + if (!(object instanceof Mesh)) return; + const geometry = object.geometry; + const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count; + total += (count / 3) * (object instanceof InstancedMesh ? object.count : 1); + }); + return total; +} + +let textures: ReturnType; +let tracker: ResourceTracker; +let module: SceneModule; + +beforeAll(() => { + textures = createSceneTextures(); + tracker = new ResourceTracker(); + module = createEffectsModule({ materials: new MaterialLibrary(), textures, tracker }); + module.setState(stateWith()); +}); + +afterAll(() => { + tracker.dispose(); + textures.dispose(); +}); + +describe('effects module', () => { + it('names each part object and gives it a label', () => { + EFFECT_IDS.forEach((id) => { + expect(module.root.getObjectByName(id)).toBeDefined(); + expect(module.labels.get(id)).toBeDefined(); + }); + }); + + it('anchors the voxel at its layout centre', () => { + const anchor = module.anchors.voxel; + anchor?.updateWorldMatrix(true, false); + expect(anchor?.getWorldPosition(new Vector3()).toArray()).toEqual([...VOXEL.centre]); + }); + + it('stays within the triangle budget', () => { + expect(triangles(module)).toBeLessThan(TRIANGLE_BUDGET); + }); + + it('rests when nothing moves on its own', () => { + module.setState(stateWith()); + expect(settle(module)).toBe(false); + }); + + it('flows while the field lines show', () => { + module.setState(stateWith({ view: { ...stateWith().view, fieldLines: true } })); + expect(module.update(FRAME, 1)).toBe(true); + }); + + it('precesses only while playing with the voxel shown', () => { + const view = { ...stateWith().view, voxel: true }; + module.setState(stateWith({ view, playing: false })); + expect(settle(module)).toBe(false); + module.setState(stateWith({ view, playing: true })); + expect(module.update(FRAME, 1)).toBe(true); + }); + + it('keeps the pulse rings travelling and eases the slab in during excite', () => { + module.setState( + stateWith({ + sequence: { phase: 75, step: 'excite', rf: 90, gradient: 'z', gradientLevel: 1, echo: 0 }, + }), + ); + const ring = module.root.getObjectByName('rfWave')?.children[0]; + expect(ring?.parent?.visible).toBe(true); + const before = ring?.scale.x; + expect(settle(module)).toBe(true); + expect(ring?.scale.x).not.toBeCloseTo(before ?? 0); + expect(module.root.getObjectByName('sliceSlab')?.visible).toBe(true); + }); + + it('keeps the echo rings travelling only while the echo sounds', () => { + module.setState( + stateWith({ + sequence: { phase: 535, step: 'echo', rf: null, gradient: 'x', gradientLevel: 1, echo: 1 }, + }), + ); + expect(module.root.getObjectByName('echoWave')?.visible).toBe(true); + expect(module.update(FRAME, 1)).toBe(true); + module.setState(stateWith()); + expect(module.root.getObjectByName('echoWave')?.visible).toBe(false); + expect(settle(module)).toBe(false); + }); +}); diff --git a/explainers/mri-scanner/src/scene/parts/effects/effects.ts b/explainers/mri-scanner/src/scene/parts/effects/effects.ts new file mode 100644 index 00000000..54fe5811 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/effects.ts @@ -0,0 +1,68 @@ +import { Group } from 'three'; +import type { Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import type { AssemblyState, PartId } from '../../../ids'; +import { VOXEL } from '../../../model/layout'; +import type { PartContext, SceneModule } from '../context'; +import { FieldLines } from './fieldLines'; +import { Pulses } from './pulses'; +import { VoxelInset } from './voxel'; + +class EffectsModule implements SceneModule { + readonly root = new Group(); + readonly labels: ReadonlyMap; + readonly anchors: SceneModule['anchors']; + private readonly fieldLines: FieldLines; + private readonly voxel: VoxelInset; + private readonly pulses: Pulses; + private playing = false; + + constructor(context: PartContext) { + this.root.name = 'effects'; + this.fieldLines = new FieldLines(context); + this.voxel = new VoxelInset(context); + this.pulses = new Pulses(context); + this.root.add( + this.fieldLines.lines, + this.fieldLines.fringe, + this.voxel.spins, + this.voxel.net, + this.voxel.field, + this.pulses.rf.group, + this.pulses.echo.group, + this.pulses.slab, + ); + this.labels = new Map([ + ['fieldLinesGroup', this.fieldLines.linesLabel], + ['fringeLine', this.fieldLines.fringeLabel], + ['spinArrows', this.voxel.labels.spinArrows], + ['netMagnet', this.voxel.labels.netMagnet], + ['mainField', this.voxel.labels.mainField], + ['rfWave', this.pulses.labels.rfWave], + ['echoWave', this.pulses.labels.echoWave], + ['sliceSlab', this.pulses.labels.sliceSlab], + ]); + this.anchors = { voxel: anchorAt(this.root, ...VOXEL.centre) }; + } + + setState(state: AssemblyState): void { + const { view, fringe, spins, tissue, sequence } = state; + this.playing = state.playing; + this.fieldLines.show(view.fieldLines); + this.fieldLines.setFringe(fringe); + this.voxel.show(view.voxel); + this.voxel.setState(spins, tissue); + this.pulses.setState(sequence); + } + + update(deltaSeconds: number): boolean { + const flowing = this.fieldLines.advance(deltaSeconds); + const turning = this.voxel.advance(deltaSeconds, this.playing); + const easing = this.pulses.advance(deltaSeconds); + return flowing || turning || easing; + } +} + +export function createEffectsModule(context: PartContext): SceneModule { + return new EffectsModule(context); +} diff --git a/explainers/mri-scanner/src/scene/parts/effects/fieldLines.ts b/explainers/mri-scanner/src/scene/parts/effects/fieldLines.ts new file mode 100644 index 00000000..7fba1a28 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/fieldLines.ts @@ -0,0 +1,136 @@ +import { + BufferAttribute, + BufferGeometry, + CatmullRomCurve3, + Float32BufferAttribute, + Group, + Mesh, + TubeGeometry, + Vector3, +} from 'three'; +import type { Object3D, ShaderMaterial } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import { clamp } from '@core/math'; +import { anchorAt } from '@core/scene/parts'; +import type { Fringe } from '../../../ids'; +import { BORE, FLOOR_Y, ISOCENTRE, ROOM } from '../../../model/layout'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { advanceOffset, flowMaterial, ribbonMaterial } from './glow'; +import { FIELD_LINES, FRINGE } from './looks'; +import { ellipseRibbon, fieldLoop, fieldLoopSpecs, loopAzimuths, placeLoop } from './paths'; + +const CURVE_TYPE = 'centripetal'; +const COMPONENTS = 3; +const FRINGE_REACH = { side: ROOM.x[1], along: Math.min(-ROOM.z[0], ROOM.z[1]) } as const; +const LABEL_ANGLE = Math.PI / 4; + +function loopTube(points: readonly (readonly [number, number, number])[]): BufferGeometry { + const curve = new CatmullRomCurve3( + points.map((point) => new Vector3(...point)), + true, + CURVE_TYPE, + ); + const geometry = new TubeGeometry( + curve, + FIELD_LINES.tubularSegments, + FIELD_LINES.tubeRadius, + FIELD_LINES.radialSegments, + true, + ); + const dashes = Math.max(1, Math.round(curve.getLength() / FIELD_LINES.dashPeriod)); + const count = geometry.getAttribute('position').count; + geometry.setAttribute('aDashes', new BufferAttribute(new Float32Array(count).fill(dashes), 1)); + return geometry; +} + +function fieldTubes(): BufferGeometry { + const tubes = fieldLoopSpecs().flatMap((spec) => + loopAzimuths(FIELD_LINES.planes).map((azimuth) => + loopTube(placeLoop(fieldLoop(spec), azimuth, ISOCENTRE)), + ), + ); + const merged = mergeGeometries(tubes); + tubes.forEach((tube) => tube.dispose()); + if (!merged) throw new Error('Could not merge the field line tubes'); + return merged; +} + +function ribbonGeometry(): BufferGeometry { + const { segments } = FRINGE; + const vertices = (segments + 1) * 2; + const uv = Array.from({ length: vertices }, (_, index) => [ + Math.floor(index / 2) / segments, + index % 2, + ]).flat(); + const indices = Array.from({ length: segments }, (_, index) => { + const a = index * 2; + return [a, a + 1, a + 2, a + 1, a + 3, a + 2]; + }).flat(); + const geometry = new BufferGeometry(); + geometry.setAttribute('position', new Float32BufferAttribute(vertices * COMPONENTS, COMPONENTS)); + geometry.setAttribute('uv', new Float32BufferAttribute(uv, 2)); + geometry.setIndex(indices); + return geometry; +} + +export class FieldLines { + readonly lines = new Group(); + readonly fringe = new Group(); + readonly linesLabel: Object3D; + readonly fringeLabel: Object3D; + private readonly flow: ShaderMaterial; + private readonly ribbon: BufferGeometry; + private shownFringe: Fringe = { along: 0, side: 0 }; + + constructor(context: PartContext) { + this.lines.name = 'fieldLinesGroup'; + this.fringe.name = 'fringeLine'; + this.flow = registered(context, 'fieldLinesGroup', flowMaterial()); + const tubes = new Mesh(context.tracker.track(fieldTubes()), this.flow); + this.lines.add(tubes); + this.ribbon = context.tracker.track(ribbonGeometry()); + const ribbon = new Mesh(this.ribbon, registered(context, 'fringeLine', ribbonMaterial())); + ribbon.position.set(ISOCENTRE[0], FLOOR_Y + FRINGE.lift, ISOCENTRE[2]); + ribbon.frustumCulled = false; + this.fringe.add(ribbon); + this.linesLabel = anchorAt( + this.lines, + ISOCENTRE[0], + ISOCENTRE[1] + BORE.radius, + ISOCENTRE[2] - BORE.halfLength - FIELD_LINES.capClearance, + ); + this.fringeLabel = anchorAt(this.fringe, ISOCENTRE[0], FLOOR_Y, ISOCENTRE[2]); + } + + show(visible: boolean): void { + this.lines.visible = visible; + this.fringe.visible = visible; + } + + setFringe({ along, side }: Fringe): void { + const target = { + along: clamp(along, 0, FRINGE_REACH.along), + side: clamp(side, 0, FRINGE_REACH.side), + }; + if (target.along === this.shownFringe.along && target.side === this.shownFringe.side) return; + this.shownFringe = target; + const position = this.ribbon.getAttribute('position') as BufferAttribute; + ellipseRibbon(target.side, target.along, FRINGE.segments, FRINGE.halfWidth).forEach( + ([x, z], index) => position.setXYZ(index, x, 0, z), + ); + position.needsUpdate = true; + this.ribbon.computeBoundingSphere(); + this.fringeLabel.position.set( + ISOCENTRE[0] + target.side * Math.cos(LABEL_ANGLE), + FLOOR_Y, + ISOCENTRE[2] + target.along * Math.sin(LABEL_ANGLE), + ); + } + + advance(deltaSeconds: number): boolean { + if (!this.lines.visible) return false; + advanceOffset(this.flow, deltaSeconds * FIELD_LINES.flowSpeed); + return true; + } +} diff --git a/explainers/mri-scanner/src/scene/parts/effects/glow.ts b/explainers/mri-scanner/src/scene/parts/effects/glow.ts new file mode 100644 index 00000000..6715cfab --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/glow.ts @@ -0,0 +1,210 @@ +import { + AdditiveBlending, + Color, + DoubleSide, + NormalBlending, + ShaderMaterial, + Vector2, +} from 'three'; +import { BORE, FLOOR_Y, ISOCENTRE } from '../../../model/layout'; +import { FIELD_LINES, FRINGE, RING_GLOW } from './looks'; + +const FLOW_VERTEX = /* glsl */ ` +attribute float aDashes; +uniform vec2 uAxis; +uniform float uBore; +varying float vAlong; +varying float vDashes; +varying float vInside; +varying float vHeight; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + vAlong = uv.x; + vDashes = aDashes; + vInside = 1.0 - smoothstep(uBore, uBore * 1.6, length(position.xy - uAxis)); + vHeight = position.y; + vec4 view = modelViewMatrix * vec4(position, 1.0); + vViewPosition = -view.xyz; + vNormalView = normalize(normalMatrix * normal); + gl_Position = projectionMatrix * view; +} +`; + +const FLOW_FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uOffset; +uniform float uDuty; +uniform float uBoost; +uniform float uFloor; +uniform float uFloorFade; +varying float vAlong; +varying float vDashes; +varying float vInside; +varying float vHeight; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + float ground = smoothstep(uFloor, uFloor + uFloorFade, vHeight); + if (ground <= 0.0) discard; + float facing = abs(dot(normalize(vNormalView), normalize(vViewPosition))); + float phase = fract(vAlong * vDashes - uOffset); + float dash = smoothstep(0.0, uDuty, phase) * (1.0 - smoothstep(uDuty, uDuty + 0.08, phase)); + float body = mix(0.3, 1.0, dash) * mix(0.6, 1.0, facing); + float alpha = uOpacity * body * mix(1.0, uBoost, vInside) * ground; + gl_FragColor = vec4(uColour * (1.0 + dash * 0.6), alpha); + #include +} +`; + +const RIBBON_VERTEX = /* glsl */ ` +varying float vAlong; +void main() { + vAlong = uv.x; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const RIBBON_FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uDashes; +uniform float uDuty; +varying float vAlong; +void main() { + if (fract(vAlong * uDashes) > uDuty) discard; + gl_FragColor = vec4(uColour, uOpacity); + #include +} +`; + +const RING_VERTEX = /* glsl */ ` +varying float vAcross; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + vAcross = uv.y; + vec4 view = modelViewMatrix * vec4(position, 1.0); + vViewPosition = -view.xyz; + vNormalView = normalize(normalMatrix * normal); + gl_Position = projectionMatrix * view; +} +`; + +const RING_FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uEdge; +uniform float uBrightness; +uniform float uCore; +varying float vAcross; +varying vec3 vNormalView; +varying vec3 vViewPosition; +void main() { + float facing = abs(dot(normalize(vNormalView), normalize(vViewPosition))); + float rim = mix(0.55, 1.0, pow(1.0 - facing, uEdge)); + float band = sin(3.14159265 * vAcross); + float core = pow(band, uCore); + gl_FragColor = vec4(uColour * uBrightness * (0.6 + core), min(uOpacity * rim * band, 1.0)); + #include +} +`; + +const SLAB_FRAGMENT = /* glsl */ ` +uniform vec3 uColour; +uniform float uOpacity; +uniform float uSoft; +varying vec2 vFace; +void main() { + float edge = 1.0 - smoothstep(1.0 - uSoft, 1.0, length(vFace * 2.0 - 1.0)); + gl_FragColor = vec4(uColour, uOpacity * edge); + #include +} +`; + +const SLAB_VERTEX = /* glsl */ ` +varying vec2 vFace; +void main() { + vFace = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +const EFFECT_PARAMETERS = { + transparent: true, + depthWrite: false, + blending: NormalBlending, + side: DoubleSide, + toneMapped: false, +} as const; + +export function flowMaterial(): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(FIELD_LINES.colour) }, + uOpacity: { value: FIELD_LINES.opacity }, + uOffset: { value: 0 }, + uDuty: { value: FIELD_LINES.dashDuty }, + uBoost: { value: FIELD_LINES.insideBoost }, + uAxis: { value: new Vector2(ISOCENTRE[0], ISOCENTRE[1]) }, + uBore: { value: BORE.radius }, + uFloor: { value: FLOOR_Y }, + uFloorFade: { value: FIELD_LINES.floorFade }, + }, + vertexShader: FLOW_VERTEX, + fragmentShader: FLOW_FRAGMENT, + ...EFFECT_PARAMETERS, + }); +} + +export function ribbonMaterial(): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(FRINGE.colour) }, + uOpacity: { value: FRINGE.opacity }, + uDashes: { value: FRINGE.dashes }, + uDuty: { value: FRINGE.dashDuty }, + }, + vertexShader: RIBBON_VERTEX, + fragmentShader: RIBBON_FRAGMENT, + transparent: true, + depthWrite: false, + side: DoubleSide, + toneMapped: false, + }); +} + +export function ringMaterial(colour: string, edge: number): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(colour) }, + uOpacity: { value: 0 }, + uEdge: { value: edge }, + uBrightness: { value: RING_GLOW.brightness }, + uCore: { value: RING_GLOW.core }, + }, + vertexShader: RING_VERTEX, + fragmentShader: RING_FRAGMENT, + ...EFFECT_PARAMETERS, + blending: AdditiveBlending, + }); +} + +export function slabMaterial(colour: string, soft: number): ShaderMaterial { + return new ShaderMaterial({ + uniforms: { + uColour: { value: new Color(colour) }, + uOpacity: { value: 0 }, + uSoft: { value: soft }, + }, + vertexShader: SLAB_VERTEX, + fragmentShader: SLAB_FRAGMENT, + ...EFFECT_PARAMETERS, + }); +} + +export function advanceOffset(material: ShaderMaterial, distance: number): void { + const offset = material.uniforms.uOffset.value + distance; + material.uniforms.uOffset.value = offset - Math.floor(offset); +} diff --git a/explainers/mri-scanner/src/scene/parts/effects/looks.ts b/explainers/mri-scanner/src/scene/parts/effects/looks.ts new file mode 100644 index 00000000..548d6f83 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/looks.ts @@ -0,0 +1,114 @@ +import { THEME } from '../../../theme'; + +export const FIELD_LINES = { + innerRadii: [0.25, 0.1], + planes: 4, + planeStart: Math.PI / 2, + outerClearance: 0.16, + outerStep: 0.42, + capClearance: 0.22, + capStep: 0.42, + straightSamples: 10, + capSamples: 16, + tubeRadius: 0.0055, + tubularSegments: 128, + radialSegments: 5, + dashPeriod: 0.2, + dashDuty: 0.45, + flowSpeed: 0.6, + opacity: 0.55, + insideBoost: 1.7, + floorFade: 0.12, + colour: THEME.fieldLine, +} as const; + +export const FRINGE = { + segments: 192, + halfWidth: 0.012, + lift: 0.004, + dashes: 56, + dashDuty: 0.55, + opacity: 0.9, + colour: THEME.fringe, +} as const; + +export const VOXEL_LOOK = { + edgeColour: '#eaf6ff', + edgeGlow: 1.8, + edgeRadius: 0.0055, + edgeSegments: 6, + backdrop: '#03070d', + glassOpacity: 0.92, + glassGlow: 0.06, + arrowFill: 1, + arrowShaftRadius: 0.012, + arrowHeadRadius: 0.027, + arrowHeadShare: 0.36, + arrowSegments: 8, + emissiveIntensity: 0.35, + leaderRadius: 0.008, + leaderCore: '#0b1622', + leaderCoreRadius: 0.005, + leaderDash: 0.035, + leaderGap: 0.025, + targetRadius: 0.02, + targetSegments: 12, +} as const; + +export const NET_ARROW = { + scale: 0.95, + shaftRadius: 0.032, + headRadius: 0.078, + headLength: 0.12, + segments: 20, + emissiveIntensity: 0.7, + renderOrder: 3, + colour: THEME.netMagnet, +} as const; + +export const FIELD_ARROW = { + shaftRadius: 0.024, + headRadius: 0.062, + headLength: 0.12, + segments: 16, + emissiveIntensity: 0.35, + colour: THEME.mainField, +} as const; + +export const RF_RINGS = { + colour: THEME.rf, + segments: 64, + innerRadius: 0.1, + rings: { 90: 3, 180: 4 }, + band: { 90: 0.2, 180: 0.32 }, + strength: { 90: 0.85, 180: 1 }, + speed: 0.7, + edge: 2.4, +} as const; + +export const ECHO_RINGS = { + colour: THEME.echo, + segments: 64, + skinGap: 0.01, + reach: 0.16, + rings: 4, + band: 0.3, + floor: 0.6, + speed: 0.9, + edge: 2.4, +} as const; + +export const RING_GLOW = { brightness: 1.5, core: 3 } as const; + +export const SLICE = { + colour: THEME.slice, + span: 0.42, + haloThickness: 0.06, + coreOpacity: 0.9, + haloOpacity: 0.35, + soft: 0.2, + easeRate: 6, + settle: 0.01, +} as const; + +export const HALO = { width: 2.4, opacity: 0.45 } as const; diff --git a/explainers/mri-scanner/src/scene/parts/effects/paths.test.ts b/explainers/mri-scanner/src/scene/parts/effects/paths.test.ts new file mode 100644 index 00000000..d075d62a --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/paths.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_DIRECTION, ISOCENTRE, MAGNET } from '../../../model/layout'; +import { FIELD_LINES } from './looks'; +import { + ellipseRibbon, + fieldLoop, + fieldLoopSpecs, + loopAzimuths, + placeLoop, + ringWaves, +} from './paths'; +import { easeToward } from './pulses'; + +const INSIDE_POINTS = FIELD_LINES.straightSamples; + +describe('field loops', () => { + const specs = fieldLoopSpecs(); + + it('nests the loops so that none of them cross', () => { + expect(specs).toHaveLength(FIELD_LINES.innerRadii.length); + specs.slice(1).forEach((spec, index) => { + expect(spec.inner).toBeLessThan(specs[index].inner); + expect(spec.outer).toBeGreaterThan(specs[index].outer); + expect(spec.bulge).toBeGreaterThan(specs[index].bulge); + }); + }); + + it('fans out past both ends of the magnet and closes on itself', () => { + specs.forEach((spec) => { + const alongs = fieldLoop(spec).map(([, along]) => along); + expect(Math.max(...alongs)).toBeGreaterThan(MAGNET.halfLength); + expect(Math.min(...alongs)).toBeLessThan(-MAGNET.halfLength); + expect(Math.max(...alongs) + Math.min(...alongs)).toBeCloseTo(0); + }); + }); + + it('runs straight through the bore and returns outside the magnet', () => { + specs.forEach((spec) => { + const points = fieldLoop(spec); + const inside = points.slice(0, INSIDE_POINTS); + inside.forEach(([radial]) => expect(radial).toBe(spec.inner)); + const outermost = Math.max(...points.map(([radial]) => radial)); + expect(outermost).toBeGreaterThan(MAGNET.radius); + }); + }); + + it('travels along the main field inside the bore', () => { + const placed = placeLoop(fieldLoop(specs[0]), 0, ISOCENTRE); + const first = placed[0]; + const last = placed[INSIDE_POINTS]; + const travel = [0, 1, 2].map((axis) => last[axis] - first[axis]); + const dot = travel.reduce((sum, value, axis) => sum + value * FIELD_DIRECTION[axis], 0); + expect(dot).toBeCloseTo(2 * specs[0].halfLength); + }); + + it('places two half loops in each plane through the axis', () => { + const azimuths = loopAzimuths(FIELD_LINES.planes); + expect(azimuths).toHaveLength(2 * FIELD_LINES.planes); + expect(azimuths[FIELD_LINES.planes] - azimuths[0]).toBeCloseTo(Math.PI); + }); +}); + +describe('fringe ribbon', () => { + it('straddles the ellipse with a constant width', () => { + const side = 2; + const along = 3; + const halfWidth = 0.01; + const points = ellipseRibbon(side, along, 16, halfWidth); + expect(points).toHaveLength(34); + for (let index = 0; index < points.length; index += 2) { + const [ix, iz] = points[index]; + const [ox, oz] = points[index + 1]; + const mid = [(ix + ox) / 2, (iz + oz) / 2]; + expect((mid[0] / side) ** 2 + (mid[1] / along) ** 2).toBeCloseTo(1); + expect(Math.hypot(ox - ix, oz - iz)).toBeCloseTo(2 * halfWidth); + } + }); +}); + +describe('ring timing', () => { + it('stays within bounds, staggers the rings and loops over time', () => { + const waves = ringWaves(0.4, 3); + waves.forEach(({ travel, strength }) => { + expect(travel).toBeGreaterThanOrEqual(0); + expect(travel).toBeLessThan(1); + expect(strength).toBeGreaterThanOrEqual(0); + expect(strength).toBeLessThanOrEqual(1); + }); + expect(new Set(waves.map(({ travel }) => travel.toFixed(3))).size).toBe(3); + expect(ringWaves(1.4, 3)[0].travel).toBeCloseTo(waves[0].travel); + }); +}); + +describe('glow easing', () => { + it('eases toward the target', () => { + expect(easeToward(0, 1, 6, 0)).toBe(0); + expect(easeToward(0, 1, 6, 0.1)).toBeGreaterThan(0); + expect(easeToward(0, 1, 6, 10)).toBeCloseTo(1); + }); +}); diff --git a/explainers/mri-scanner/src/scene/parts/effects/paths.ts b/explainers/mri-scanner/src/scene/parts/effects/paths.ts new file mode 100644 index 00000000..96523973 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/paths.ts @@ -0,0 +1,104 @@ +import type { Point } from '../../../ids'; +import { FIELD_DIRECTION, MAGNET } from '../../../model/layout'; +import { FIELD_LINES } from './looks'; + +export type PlanePoint = readonly [radial: number, along: number]; + +export interface LoopSpec { + inner: number; + outer: number; + halfLength: number; + bulge: number; +} + +export interface RingWave { + travel: number; + strength: number; +} + +const FULL_TURN = 2 * Math.PI; + +export function fieldLoopSpecs(): LoopSpec[] { + return FIELD_LINES.innerRadii.map((inner, index) => ({ + inner, + outer: MAGNET.radius + FIELD_LINES.outerClearance + index * FIELD_LINES.outerStep, + halfLength: MAGNET.halfLength, + bulge: FIELD_LINES.capClearance + index * FIELD_LINES.capStep, + })); +} + +function straight(radial: number, from: number, to: number): PlanePoint[] { + const { straightSamples } = FIELD_LINES; + return Array.from({ length: straightSamples }, (_, index) => [ + radial, + from + ((to - from) * index) / straightSamples, + ]); +} + +function cap({ inner, outer, halfLength, bulge }: LoopSpec, end: 1 | -1): PlanePoint[] { + const { capSamples } = FIELD_LINES; + const middle = (inner + outer) / 2; + const half = (outer - inner) / 2; + return Array.from({ length: capSamples }, (_, index) => { + const angle = (Math.PI * index) / capSamples; + return [middle - end * half * Math.cos(angle), end * (halfLength + bulge * Math.sin(angle))]; + }); +} + +export function fieldLoop(spec: LoopSpec): PlanePoint[] { + const { inner, outer, halfLength } = spec; + return [ + ...straight(inner, -halfLength, halfLength), + ...cap(spec, 1), + ...straight(outer, halfLength, -halfLength), + ...cap(spec, -1), + ]; +} + +export function placeLoop(points: readonly PlanePoint[], azimuth: number, centre: Point): Point[] { + const [dx, dy, dz] = FIELD_DIRECTION; + const cos = Math.cos(azimuth); + const sin = Math.sin(azimuth); + return points.map(([radial, along]) => [ + centre[0] + radial * cos + along * dx, + centre[1] + radial * sin + along * dy, + centre[2] + along * dz, + ]); +} + +export function loopAzimuths(planes: number): number[] { + return Array.from( + { length: planes * 2 }, + (_, index) => FIELD_LINES.planeStart + (index * Math.PI) / planes, + ); +} + +export function ellipseRibbon( + side: number, + along: number, + segments: number, + halfWidth: number, +): PlanePoint[] { + return Array.from({ length: segments + 1 }, (_, index) => { + const angle = (index / segments) * FULL_TURN; + const x = side * Math.cos(angle); + const z = along * Math.sin(angle); + const nx = along * Math.cos(angle); + const nz = side * Math.sin(angle); + const norm = Math.hypot(nx, nz) || 1; + const ox = (nx / norm) * halfWidth; + const oz = (nz / norm) * halfWidth; + return [ + [x - ox, z - oz], + [x + ox, z + oz], + ] as const; + }).flat(); +} + +export function ringWaves(time: number, count: number): RingWave[] { + return Array.from({ length: count }, (_, index) => { + const shifted = time - index / count; + const travel = shifted - Math.floor(shifted); + return { travel, strength: Math.sin(Math.PI * travel) }; + }); +} diff --git a/explainers/mri-scanner/src/scene/parts/effects/pulses.ts b/explainers/mri-scanner/src/scene/parts/effects/pulses.ts new file mode 100644 index 00000000..23cd53b0 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/pulses.ts @@ -0,0 +1,180 @@ +import { BoxGeometry, CylinderGeometry, Group, Mesh } from 'three'; +import type { BufferGeometry, Object3D, ShaderMaterial } from 'three'; +import { lerp } from '@core/math'; +import { anchorAt } from '@core/scene/parts'; +import type { PartId, PhaseId, SequenceReading } from '../../../ids'; +import { BODY_COIL, HEAD_COIL, ISOCENTRE, PATIENT, SLICE_THICKNESS } from '../../../model/layout'; +import { registered } from '../context'; +import type { PartContext } from '../context'; +import { ringMaterial, slabMaterial } from './glow'; +import { ECHO_RINGS, HALO, RF_RINGS, SLICE } from './looks'; +import { ringWaves } from './paths'; + +type PulseKind = 90 | 180; + +const SLICE_STEPS: readonly PhaseId[] = ['excite', 'refocus']; +const DIAGONAL = Math.SQRT1_2; +const MAX_RF_RINGS = Math.max(...Object.values(RF_RINGS.rings)); + +function isPulse(rf: SequenceReading['rf']): rf is PulseKind { + return rf === 90 || rf === 180; +} + +function wrap(value: number): number { + return value - Math.floor(value); +} + +export function easeToward(current: number, target: number, rate: number, delta: number): number { + return target + (current - target) * Math.exp(-rate * delta); +} + +class RingSet { + readonly group = new Group(); + private readonly rings: { + mesh: Mesh; + material: ShaderMaterial; + halo: Mesh; + glow: ShaderMaterial; + }[]; + + constructor( + context: PartContext, + id: PartId, + geometry: BufferGeometry, + look: { colour: string; edge: number }, + count: number, + ) { + this.group.name = id; + this.group.position.set(...ISOCENTRE); + this.rings = Array.from({ length: count }, () => { + const material = registered(context, id, ringMaterial(look.colour, look.edge)); + const glow = registered(context, id, ringMaterial(look.colour, look.edge)); + const mesh = new Mesh(geometry, material); + const halo = new Mesh(geometry, glow); + this.group.add(mesh, halo); + return { mesh, material, halo, glow }; + }); + } + + place(index: number, radius: number, width: number, opacity: number): void { + const ring = this.rings[index]; + ring.mesh.visible = opacity > 0; + ring.halo.visible = ring.mesh.visible; + ring.mesh.scale.set(radius, radius, width); + ring.halo.scale.set(radius, radius, width * HALO.width); + ring.material.uniforms.uOpacity.value = opacity; + ring.glow.uniforms.uOpacity.value = opacity * HALO.opacity; + } + + hideFrom(index: number): void { + this.rings.slice(index).forEach(({ mesh, halo }) => { + mesh.visible = false; + halo.visible = false; + }); + } +} + +export class Pulses { + readonly rf: RingSet; + readonly echo: RingSet; + readonly slab = new Group(); + readonly labels: Readonly>; + private readonly slabMaterials: { material: ShaderMaterial; opacity: number }[]; + private glow = 0; + private glowTarget = 0; + private reading: SequenceReading | null = null; + private rfTime = 0; + private echoTime = 0; + + constructor(context: PartContext) { + const hoop = context.tracker.track( + new CylinderGeometry(1, 1, 1, RF_RINGS.segments, 1, true).rotateX(Math.PI / 2), + ); + this.rf = new RingSet(context, 'rfWave', hoop, RF_RINGS, MAX_RF_RINGS); + this.echo = new RingSet(context, 'echoWave', hoop, ECHO_RINGS, ECHO_RINGS.rings); + this.slab.name = 'sliceSlab'; + this.slab.position.set(...ISOCENTRE); + this.slabMaterials = [ + { thickness: SLICE_THICKNESS, opacity: SLICE.coreOpacity }, + { thickness: SLICE.haloThickness, opacity: SLICE.haloOpacity }, + ].map(({ thickness, opacity }) => { + const material = registered(context, 'sliceSlab', slabMaterial(SLICE.colour, SLICE.soft)); + const geometry = context.tracker.track(new BoxGeometry(SLICE.span, SLICE.span, thickness)); + this.slab.add(new Mesh(geometry, material)); + return { material, opacity }; + }); + this.slab.visible = false; + this.labels = { + rfWave: anchorAt(this.rf.group, BODY_COIL.radius * DIAGONAL, BODY_COIL.radius * DIAGONAL, 0), + echoWave: anchorAt( + this.echo.group, + HEAD_COIL.radius * DIAGONAL, + HEAD_COIL.radius * DIAGONAL, + 0, + ), + sliceSlab: anchorAt(this.slab, SLICE.span / 2, 0, 0), + }; + } + + setState(sequence: SequenceReading): void { + this.reading = sequence; + this.placeRings(); + this.glowTarget = SLICE_STEPS.includes(sequence.step) ? 1 : 0; + } + + advance(deltaSeconds: number): boolean { + const travelling = this.rf.group.visible || this.echo.group.visible; + if (travelling) { + this.rfTime = wrap(this.rfTime + deltaSeconds * RF_RINGS.speed); + this.echoTime = wrap(this.echoTime + deltaSeconds * ECHO_RINGS.speed); + this.placeRings(); + } + return this.easeSlab(deltaSeconds) || travelling; + } + + private easeSlab(deltaSeconds: number): boolean { + const settled = Math.abs(this.glow - this.glowTarget) < SLICE.settle; + this.glow = settled + ? this.glowTarget + : easeToward(this.glow, this.glowTarget, SLICE.easeRate, deltaSeconds); + this.slab.visible = this.glow > 0; + this.slabMaterials.forEach(({ material, opacity }) => { + material.uniforms.uOpacity.value = opacity * this.glow; + }); + return !settled; + } + + private placeRings(): void { + if (!this.reading) return; + this.placeRf(this.reading.rf); + this.placeEcho(this.reading.echo); + } + + private placeRf(rf: SequenceReading['rf']): void { + this.rf.group.visible = isPulse(rf); + if (!isPulse(rf)) return; + const count = RF_RINGS.rings[rf]; + ringWaves(this.rfTime, count).forEach(({ travel, strength }, index) => + this.rf.place( + index, + lerp(BODY_COIL.radius, RF_RINGS.innerRadius, travel), + RF_RINGS.band[rf], + strength * RF_RINGS.strength[rf], + ), + ); + this.rf.hideFrom(count); + } + + private placeEcho(echo: number): void { + this.echo.group.visible = echo > 0; + if (!this.echo.group.visible) return; + ringWaves(this.echoTime, ECHO_RINGS.rings).forEach(({ travel, strength }, index) => + this.echo.place( + index, + lerp(PATIENT.headRadius + ECHO_RINGS.skinGap, HEAD_COIL.radius + ECHO_RINGS.reach, travel), + ECHO_RINGS.band * (1 + echo) * HEAD_COIL.length, + strength * lerp(ECHO_RINGS.floor, 1, Math.min(echo, 1)), + ), + ); + } +} diff --git a/explainers/mri-scanner/src/scene/parts/effects/voxel.ts b/explainers/mri-scanner/src/scene/parts/effects/voxel.ts new file mode 100644 index 00000000..f528ef56 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/voxel.ts @@ -0,0 +1,350 @@ +import { + BackSide, + BoxGeometry, + ConeGeometry, + CylinderGeometry, + Group, + InstancedMesh, + Matrix4, + Mesh, + Quaternion, + SphereGeometry, + Vector3, +} from 'three'; +import type { MeshStandardMaterial, Object3D, BufferGeometry } from 'three'; +import { clamp } from '@core/math'; +import type { MaterialFinish } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import type { Point, SpinReading, TissueId } from '../../../ids'; +import { TISSUE_IDS } from '../../../ids'; +import { DISPLAY_TURNS_PER_S } from '../../../model/constants'; +import { + FIELD_DIRECTION, + MAIN_FIELD_ARROW, + VOXEL, + VOXEL_ARROWS, + VOXEL_LEADER, +} from '../../../model/layout'; +import { TISSUE_TONES } from '../../../theme'; +import { mergeParts, partMesh } from '../context'; +import type { EmphasisGroup, PartContext } from '../context'; +import { FIELD_ARROW, NET_ARROW, VOXEL_LOOK } from './looks'; + +interface ArrowLook { + shaftRadius: number; + headRadius: number; + headLength: number; + segments: number; + emissiveIntensity: number; + colour: string; +} + +interface Arrow { + pivot: Group; + setLength(length: number): void; +} + +interface TissueMaterials { + arrow: MeshStandardMaterial; + glass: MeshStandardMaterial; +} + +interface TissueFinish { + arrow: MaterialFinish; + glass: MaterialFinish; +} + +const UP = new Vector3(0, 1, 0); +const AXIS = new Vector3(...FIELD_DIRECTION); +const FULL_TURN = 2 * Math.PI; +const PRECESSION_RATE = DISPLAY_TURNS_PER_S * FULL_TURN; +const MIN_LENGTH = 1e-4; +const ROUGHNESS = 0.4; +const CELL = VOXEL.size / VOXEL.perSide; + +const TISSUE_FINISHES = Object.fromEntries( + TISSUE_IDS.map((tissue) => { + const color = TISSUE_TONES[tissue]; + return [ + tissue, + { + arrow: { + color, + emissive: color, + emissiveIntensity: VOXEL_LOOK.emissiveIntensity, + roughness: ROUGHNESS, + }, + glass: { + color: VOXEL_LOOK.backdrop, + emissive: color, + emissiveIntensity: VOXEL_LOOK.glassGlow, + transparent: true, + opacity: VOXEL_LOOK.glassOpacity, + depthWrite: false, + side: BackSide, + }, + }, + ]; + }), +) as Record; + +function solidFinish(look: ArrowLook): MaterialFinish { + return { + color: look.colour, + emissive: look.colour, + emissiveIntensity: look.emissiveIntensity, + roughness: ROUGHNESS, + }; +} + +const NET_FINISH: MaterialFinish = { ...solidFinish(NET_ARROW), depthTest: false }; +const FIELD_FINISH = solidFinish(FIELD_ARROW); +const EDGE_FINISH: MaterialFinish = { + color: VOXEL_LOOK.edgeColour, + emissive: VOXEL_LOOK.edgeColour, + emissiveIntensity: VOXEL_LOOK.edgeGlow, + roughness: ROUGHNESS, +}; +const CORE_FINISH: MaterialFinish = { color: VOXEL_LOOK.leaderCore, roughness: ROUGHNESS }; +const CORNER_SIGNS = [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], +] as const; +const AXES = [0, 1, 2] as const; +const LEADER_FROM = new Vector3(...VOXEL_LEADER.from); +const LEADER_TO = new Vector3(...VOXEL_LEADER.to); + +function rod(from: Vector3, to: Vector3, radius: number): BufferGeometry { + const span = new Vector3().subVectors(to, from); + const middle = from.clone().add(to).multiplyScalar(0.5); + return new CylinderGeometry(radius, radius, span.length(), VOXEL_LOOK.edgeSegments, 1, true) + .applyQuaternion(new Quaternion().setFromUnitVectors(UP, span.normalize())) + .translate(middle.x, middle.y, middle.z); +} + +function frameEdges(): BufferGeometry[] { + const centre = new Vector3(...VOXEL.centre); + const half = VOXEL.size / 2; + const reach = half + VOXEL_LOOK.edgeRadius; + return AXES.flatMap((axis) => + CORNER_SIGNS.map(([first, second]) => { + const [across, up] = AXES.filter((other) => other !== axis); + const end = (sign: number) => + new Vector3() + .setComponent(axis, sign * reach) + .setComponent(across, first * half) + .setComponent(up, second * half) + .add(centre); + return rod(end(-1), end(1), VOXEL_LOOK.edgeRadius); + }), + ); +} + +function leaderDashes(): BufferGeometry[] { + const length = LEADER_FROM.distanceTo(LEADER_TO); + const period = VOXEL_LOOK.leaderDash + VOXEL_LOOK.leaderGap; + return Array.from({ length: Math.floor(length / period) }, (_, index) => { + const start = (index * period) / length; + const end = start + VOXEL_LOOK.leaderDash / length; + return rod( + LEADER_FROM.clone().lerp(LEADER_TO, start), + LEADER_FROM.clone().lerp(LEADER_TO, end), + VOXEL_LOOK.leaderRadius, + ); + }); +} + +function leaderTarget(): BufferGeometry { + const { targetRadius, targetSegments } = VOXEL_LOOK; + return new SphereGeometry(targetRadius, targetSegments, targetSegments / 2).translate( + LEADER_TO.x, + LEADER_TO.y, + LEADER_TO.z, + ); +} + +function spinArrowGeometry(): BufferGeometry { + const length = CELL * VOXEL_LOOK.arrowFill; + const head = length * VOXEL_LOOK.arrowHeadShare; + const shaft = length - head; + const { arrowShaftRadius, arrowHeadRadius, arrowSegments } = VOXEL_LOOK; + return mergeParts([ + new CylinderGeometry( + arrowShaftRadius, + arrowShaftRadius, + shaft, + arrowSegments, + 1, + true, + ).translate(0, shaft / 2 - length / 2, 0), + new ConeGeometry(arrowHeadRadius, head, arrowSegments).translate(0, length / 2 - head / 2, 0), + ]); +} + +function gridOffsets(): Vector3[] { + const side = Array.from( + { length: VOXEL.perSide }, + (_, index) => (index + 0.5) * CELL - VOXEL.size / 2, + ); + return side.flatMap((x) => side.flatMap((y) => side.map((z) => new Vector3(x, y, z)))); +} + +function buildArrow( + context: PartContext, + group: EmphasisGroup, + look: ArrowLook, + finish: MaterialFinish, +): Arrow { + const pivot = new Group(); + const shaft = partMesh( + context, + new CylinderGeometry(look.shaftRadius, look.shaftRadius, 1, look.segments).translate(0, 0.5, 0), + group, + finish, + ); + const head = partMesh( + context, + new ConeGeometry(look.headRadius, look.headLength, look.segments).translate( + 0, + look.headLength / 2, + 0, + ), + group, + finish, + ); + pivot.add(shaft, head); + return { + pivot, + setLength(length: number) { + const stem = Math.max(length - look.headLength, MIN_LENGTH); + shaft.scale.y = stem; + head.position.y = length > look.headLength ? stem : 0; + head.scale.setScalar(clamp(length / look.headLength, MIN_LENGTH, 1)); + }, + }; +} + +export class VoxelInset { + readonly spins = new Group(); + readonly net = new Group(); + readonly field = new Group(); + readonly labels: Readonly>; + private readonly arrows: InstancedMesh; + private readonly glass: Mesh; + private readonly tissueMaterials: Record; + private readonly netArrow: Arrow; + private readonly offsets = gridOffsets(); + private readonly matrix = new Matrix4(); + private readonly turn = new Quaternion(); + private readonly orient = new Quaternion(); + private readonly direction = new Vector3(); + private readonly scale = new Vector3(); + private reading: SpinReading = { net: [0, 0, 0], arrows: [], precession: 0 }; + private angle = 0; + + constructor(context: PartContext) { + this.spins.name = 'spinArrows'; + this.net.name = 'netMagnet'; + this.field.name = 'mainField'; + this.tissueMaterials = Object.fromEntries( + TISSUE_IDS.map((tissue) => [ + tissue, + { + arrow: context.materials.get('spinArrows', TISSUE_FINISHES[tissue].arrow), + glass: context.materials.get('spinArrows', TISSUE_FINISHES[tissue].glass), + }, + ]), + ) as Record; + const inset = new Group(); + inset.position.set(...VOXEL.centre); + this.arrows = new InstancedMesh( + context.tracker.track(spinArrowGeometry()), + this.tissueMaterials.fat.arrow, + VOXEL_ARROWS, + ); + this.arrows.frustumCulled = false; + this.glass = new Mesh( + context.tracker.track(new BoxGeometry(VOXEL.size, VOXEL.size, VOXEL.size)), + this.tissueMaterials.fat.glass, + ); + this.glass.renderOrder = 1; + inset.add(this.arrows, this.glass); + this.spins.add( + inset, + partMesh( + context, + mergeParts([...frameEdges(), ...leaderDashes(), leaderTarget()]), + 'spinArrows', + EDGE_FINISH, + ), + partMesh( + context, + rod(LEADER_FROM, LEADER_TO, VOXEL_LOOK.leaderCoreRadius), + 'spinArrows', + CORE_FINISH, + ), + ); + this.netArrow = buildArrow(context, 'netMagnet', NET_ARROW, NET_FINISH); + this.netArrow.pivot.position.set(...VOXEL.centre); + this.netArrow.pivot.traverse((part) => { + part.renderOrder = NET_ARROW.renderOrder; + }); + this.net.add(this.netArrow.pivot); + const fieldArrow = buildArrow(context, 'mainField', FIELD_ARROW, FIELD_FINISH); + fieldArrow.pivot.position.set(...MAIN_FIELD_ARROW.tail); + fieldArrow.pivot.quaternion.setFromUnitVectors(UP, AXIS); + fieldArrow.setLength(MAIN_FIELD_ARROW.length); + this.field.add(fieldArrow.pivot); + const [x, y, z] = VOXEL.centre; + const half = VOXEL.size / 2; + this.labels = { + spinArrows: anchorAt(this.spins, x - half, y, z), + netMagnet: anchorAt(this.net, x, y + half, z), + mainField: anchorAt(this.field, ...MAIN_FIELD_ARROW.tail), + }; + } + + show(visible: boolean): void { + this.spins.visible = visible; + this.net.visible = visible; + this.field.visible = visible; + } + + setState(spins: SpinReading, tissue: TissueId): void { + this.reading = spins; + this.arrows.material = this.tissueMaterials[tissue].arrow; + this.glass.material = this.tissueMaterials[tissue].glass; + this.place(); + } + + advance(deltaSeconds: number, playing: boolean): boolean { + if (!playing || !this.spins.visible) return false; + this.angle = (this.angle + deltaSeconds * PRECESSION_RATE) % FULL_TURN; + this.place(); + return true; + } + + private place(): void { + this.turn.setFromAxisAngle(AXIS, this.angle); + this.offsets.forEach((offset, index) => { + const length = this.orientAlong(this.reading.arrows[index] ?? [0, 0, 0]); + this.scale.set(1, Math.max(length, MIN_LENGTH), 1); + this.arrows.setMatrixAt(index, this.matrix.compose(offset, this.orient, this.scale)); + }); + this.arrows.instanceMatrix.needsUpdate = true; + const length = this.orientAlong(this.reading.net); + this.netArrow.pivot.quaternion.copy(this.orient); + this.netArrow.setLength(length * VOXEL.size * NET_ARROW.scale); + } + + private orientAlong(vector: Point): number { + this.direction.set(...vector); + const length = this.direction.length(); + if (length < MIN_LENGTH) return 0; + this.direction.divideScalar(length).applyQuaternion(this.turn); + this.orient.setFromUnitVectors(UP, this.direction); + return length; + } +} From 704375a1bb49e15be1f43644bf178bbb64908fb4 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:54 +0200 Subject: [PATCH 52/82] feat(mri-scanner): add the scene lighting, regions and options --- .../mri-scanner/src/scene/lighting.test.ts | 47 +++++++++++++++ explainers/mri-scanner/src/scene/lighting.ts | 57 +++++++++++++++++++ .../mri-scanner/src/scene/regions.test.ts | 16 ++++++ explainers/mri-scanner/src/scene/regions.ts | 10 ++++ .../src/scene/sceneOptions.test.ts | 25 ++++++++ .../mri-scanner/src/scene/sceneOptions.ts | 19 +++++++ 6 files changed, 174 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/lighting.test.ts create mode 100644 explainers/mri-scanner/src/scene/lighting.ts create mode 100644 explainers/mri-scanner/src/scene/regions.test.ts create mode 100644 explainers/mri-scanner/src/scene/regions.ts create mode 100644 explainers/mri-scanner/src/scene/sceneOptions.test.ts create mode 100644 explainers/mri-scanner/src/scene/sceneOptions.ts diff --git a/explainers/mri-scanner/src/scene/lighting.test.ts b/explainers/mri-scanner/src/scene/lighting.test.ts new file mode 100644 index 00000000..96e3a9d9 --- /dev/null +++ b/explainers/mri-scanner/src/scene/lighting.test.ts @@ -0,0 +1,47 @@ +import type { Color } from 'three'; +import { DirectionalLight } from 'three'; +import { describe, expect, it } from 'vitest'; +import { mriScannerLight } from './lighting'; + +function shellLights() { + return { + key: new DirectionalLight('#ffffff', 1), + fill: new DirectionalLight('#ffffff', 1), + rim: new DirectionalLight('#ffffff', 1), + }; +} + +function coolness(colour: Color): number { + return colour.b - colour.r; +} + +describe('mri scanner light', () => { + it('keys softly from above the bore mouth', () => { + const lights = shellLights(); + mriScannerLight(lights); + const key = lights.key.position.clone().normalize(); + expect(key.y).toBeGreaterThan(0.7); + expect(key.z).toBeGreaterThan(0); + expect(lights.key.intensity).toBeGreaterThan(lights.fill.intensity); + }); + + it('fills cool and rims from the control window side', () => { + const lights = shellLights(); + mriScannerLight(lights); + expect(coolness(lights.fill.color)).toBeGreaterThan(0); + const rim = lights.rim.position.clone().normalize(); + expect(rim.x).toBeGreaterThan(0.8); + expect(coolness(lights.rim.color)).toBeGreaterThan(0); + }); + + it('puts the shell lights back as they were', () => { + const lights = shellLights(); + lights.key.position.set(1, 2, 3); + mriScannerLight(lights)(); + Object.values(lights).forEach((light) => { + expect(light.intensity).toBe(1); + expect(light.color.getHexString()).toBe('ffffff'); + }); + expect(lights.key.position.toArray()).toEqual([1, 2, 3]); + }); +}); diff --git a/explainers/mri-scanner/src/scene/lighting.ts b/explainers/mri-scanner/src/scene/lighting.ts new file mode 100644 index 00000000..8a45c1f0 --- /dev/null +++ b/explainers/mri-scanner/src/scene/lighting.ts @@ -0,0 +1,57 @@ +import type { DirectionalLight } from 'three'; +import type { Lighting } from '@core/scene/lighting'; +import { LIGHT_RIG } from './constants'; + +type Triple = readonly [number, number, number]; + +interface LightSetting { + readonly color: string; + readonly intensity: number; + readonly position: Triple; +} + +type LightName = 'key' | 'fill' | 'rim'; + +function scaled(direction: Triple): Triple { + const length = Math.hypot(...direction); + return direction.map((value) => (value / length) * LIGHT_RIG.distance) as unknown as Triple; +} + +export const MRI_SCANNER_LIGHT: Readonly> = { + key: { + color: LIGHT_RIG.key.color, + intensity: LIGHT_RIG.key.intensity, + position: scaled(LIGHT_RIG.key.direction), + }, + fill: { + color: LIGHT_RIG.fill.color, + intensity: LIGHT_RIG.fill.intensity, + position: scaled(LIGHT_RIG.fill.direction), + }, + rim: { + color: LIGHT_RIG.rim.color, + intensity: LIGHT_RIG.rim.intensity, + position: scaled(LIGHT_RIG.rim.direction), + }, +}; + +function apply(light: DirectionalLight, setting: LightSetting): () => void { + const color = light.color.clone(); + const intensity = light.intensity; + const position = light.position.clone(); + light.color.set(setting.color); + light.intensity = setting.intensity; + light.position.set(...setting.position); + return () => { + light.color.copy(color); + light.intensity = intensity; + light.position.copy(position); + }; +} + +export function mriScannerLight(lighting: Pick): () => void { + const restorers = (Object.keys(MRI_SCANNER_LIGHT) as LightName[]).map((name) => + apply(lighting[name], MRI_SCANNER_LIGHT[name]), + ); + return () => restorers.forEach((restore) => restore()); +} diff --git a/explainers/mri-scanner/src/scene/regions.test.ts b/explainers/mri-scanner/src/scene/regions.test.ts new file mode 100644 index 00000000..c8932d59 --- /dev/null +++ b/explainers/mri-scanner/src/scene/regions.test.ts @@ -0,0 +1,16 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { ISOCENTRE, SCREEN } from '../model/layout'; +import { buildRegions } from './regions'; + +describe('regions', () => { + it('builds a box per region from the layout', () => { + const regions = buildRegions(); + expect(Object.keys(regions).sort()).toEqual( + ['bore', 'console', 'layers', 'room', 'scanner', 'voxel'].sort(), + ); + expect(regions.bore.containsPoint(new Vector3(...ISOCENTRE))).toBe(true); + expect(regions.room.containsBox(regions.scanner)).toBe(true); + expect(regions.console.containsPoint(new Vector3(...SCREEN.centre))).toBe(true); + }); +}); diff --git a/explainers/mri-scanner/src/scene/regions.ts b/explainers/mri-scanner/src/scene/regions.ts new file mode 100644 index 00000000..a6e6af38 --- /dev/null +++ b/explainers/mri-scanner/src/scene/regions.ts @@ -0,0 +1,10 @@ +import type { Box3 } from 'three'; +import { regionFromSpec } from '@core/scene/regions'; +import type { RegionId } from '../ids'; +import { REGIONS } from '../model/layout'; + +export function buildRegions(): Readonly> { + return Object.fromEntries( + Object.entries(REGIONS).map(([id, spec]) => [id, regionFromSpec(spec)]), + ) as Record; +} diff --git a/explainers/mri-scanner/src/scene/sceneOptions.test.ts b/explainers/mri-scanner/src/scene/sceneOptions.test.ts new file mode 100644 index 00000000..531c2155 --- /dev/null +++ b/explainers/mri-scanner/src/scene/sceneOptions.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { ROOM } from '../model/layout'; +import { HAZE } from './constants'; +import { SCENE_OPTIONS } from './sceneOptions'; + +describe('scene options', () => { + it('turns the stage off on the theme background with a matching haze', () => { + expect(SCENE_OPTIONS.stage).toBe(false); + expect(SCENE_OPTIONS.background).toBe(HAZE.colour); + expect(SCENE_OPTIONS.fog?.color).toBe(SCENE_OPTIONS.background); + expect(SCENE_OPTIONS.gaugeSide).toBe('top'); + }); + + it('keeps the camera above the floor and lets it leave the room', () => { + const camera = SCENE_OPTIONS.camera ?? {}; + expect(camera.maxPolarAngle).toBeLessThan(Math.PI / 2); + expect(camera.distance?.max).toBeGreaterThan(ROOM.z[1] - ROOM.z[0]); + expect(camera.far).toBeGreaterThan(2 * (camera.distance?.max ?? 0)); + expect(SCENE_OPTIONS.fog?.near).toBeGreaterThan(camera.distance?.max ?? 0); + }); + + it('never dims the room and the field lines', () => { + expect(SCENE_OPTIONS.highlight?.undimmed).toEqual(['room', 'fieldLinesGroup']); + }); +}); diff --git a/explainers/mri-scanner/src/scene/sceneOptions.ts b/explainers/mri-scanner/src/scene/sceneOptions.ts new file mode 100644 index 00000000..5ea3e31d --- /dev/null +++ b/explainers/mri-scanner/src/scene/sceneOptions.ts @@ -0,0 +1,19 @@ +import type { SceneOptions } from '@core/scene/shell'; +import type { PartId } from '../ids'; +import { HAZE, HIGHLIGHT_DIM, SCENE_LIMITS } from './constants'; + +export const UNDIMMED_PARTS: readonly PartId[] = ['room', 'fieldLinesGroup']; + +export const SCENE_OPTIONS: SceneOptions = { + background: HAZE.colour, + fog: { color: HAZE.colour, near: HAZE.near, far: HAZE.far }, + stage: false, + camera: { + near: SCENE_LIMITS.cameraNear, + far: SCENE_LIMITS.cameraFar, + maxPolarAngle: SCENE_LIMITS.maxPolarAngle, + distance: { min: SCENE_LIMITS.cameraMinDistance, max: SCENE_LIMITS.cameraMaxDistance }, + }, + gaugeSide: 'top', + highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED_PARTS }, +}; From e7ff5c8a78fd90ca7e0c0538ffbe7a29ae7991f6 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:37:54 +0200 Subject: [PATCH 53/82] feat(mri-scanner): add the mri scanner assembly --- .../src/scene/mriScannerAssembly.dom.test.ts | 103 +++++++++++++++ .../src/scene/mriScannerAssembly.ts | 119 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts create mode 100644 explainers/mri-scanner/src/scene/mriScannerAssembly.ts diff --git a/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts b/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts new file mode 100644 index 00000000..5315482e --- /dev/null +++ b/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { createSceneTextures } from '@core/scene/textures'; +import { PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, RegionId } from '../ids'; +import { FIELDS } from '../model/field'; +import { HEAD_COIL, ISOCENTRE, VOXEL } from '../model/layout'; +import { MODEL_SIZE } from '../model/constants'; +import type { Assembly } from './assembly'; +import { isShown } from '@core/scene/parts'; +import { createMriScannerAssembly } from './mriScannerAssembly'; +import { MONITOR_CENTRE } from './parts/room/monitor'; + +const ANCHOR_IDS: readonly AnchorId[] = ['isocentre', 'voxel', 'headCoil', 'screen', 'coldHead']; +const SETTLE_SECONDS = 5; +const RESTING_SEQUENCE: AssemblyState['sequence'] = { + phase: 0, + step: 'excite', + rf: null, + gradient: null, + gradientLevel: 0, + echo: 0, +}; +const REGION_IDS: readonly RegionId[] = ['room', 'scanner', 'layers', 'voxel', 'bore', 'console']; + +function stateWith(overrides: Partial = {}): AssemblyState { + return { + phase: 0, + playing: false, + field: 'field15', + weighting: 't2', + sequence: { phase: 0, step: 'excite', rf: 90, gradient: 'z', gradientLevel: 1, echo: 0 }, + spins: { net: [0, 0, -1], arrows: [], precession: 0 }, + tissue: 'whiteMatter', + gradientAxis: null, + picture: new Float32Array(MODEL_SIZE * MODEL_SIZE), + pictureVersion: 0, + fringe: FIELDS.field15.fringe, + view: { cutaway: false, fieldLines: false, voxel: false, labels: true }, + ...overrides, + }; +} + +function worldPoint(object: Object3D): readonly number[] { + object.updateWorldMatrix(true, false); + return object.getWorldPosition(new Vector3()).toArray(); +} + +let textures: ReturnType; +let assembly: Assembly; + +beforeAll(() => { + textures = createSceneTextures(); + assembly = createMriScannerAssembly({ materials: new MaterialLibrary(), textures }, stateWith()); +}); + +afterAll(() => { + assembly.dispose(); + textures.dispose(); +}); + +describe('mri scanner assembly', () => { + it('gives every part a label anchor', () => { + expect([...assembly.labelAnchors().keys()]).toEqual([...PART_IDS]); + }); + + it('places the named anchors at the layout points', () => { + ANCHOR_IDS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); + expect(worldPoint(assembly.anchor('isocentre'))).toEqual([...ISOCENTRE]); + expect(worldPoint(assembly.anchor('voxel'))).toEqual([...VOXEL.centre]); + expect(worldPoint(assembly.anchor('screen'))).toEqual([...MONITOR_CENTRE]); + expect(worldPoint(assembly.anchor('headCoil'))).toEqual([...HEAD_COIL.centre]); + }); + + it('frames every region with a box that holds something', () => { + REGION_IDS.forEach((id) => expect(assembly.region(id).isEmpty()).toBe(false)); + expect(assembly.region('room').containsBox(assembly.region('scanner'))).toBe(true); + expect(assembly.region('scanner').containsBox(assembly.region('bore'))).toBe(true); + }); + + it('follows the view flags and keeps moving only while something turns', () => { + const view = { cutaway: true, fieldLines: true, voxel: true, labels: true }; + assembly.setState(stateWith({ playing: true, view })); + expect(assembly.root.getObjectByName('cover')?.visible).toBe(false); + expect(assembly.root.getObjectByName('spinArrows')?.visible).toBe(true); + expect(assembly.root.getObjectByName('fringeLine')?.visible).toBe(true); + expect(assembly.update(0.016, 5)).toBe(true); + assembly.setState(stateWith({ sequence: RESTING_SEQUENCE })); + expect(assembly.root.getObjectByName('cover')?.visible).toBe(true); + expect(assembly.root.getObjectByName('spinArrows')?.visible).toBe(false); + assembly.update(SETTLE_SECONDS, 5); + expect(assembly.update(0.016, 5)).toBe(false); + }); + + it('hides a label with its part', () => { + assembly.setState(stateWith()); + expect(isShown(assembly.labelAnchors().get('spinArrows')!)).toBe(false); + expect(isShown(assembly.labelAnchors().get('fieldLinesGroup')!)).toBe(false); + expect(isShown(assembly.labelAnchors().get('table')!)).toBe(true); + }); +}); diff --git a/explainers/mri-scanner/src/scene/mriScannerAssembly.ts b/explainers/mri-scanner/src/scene/mriScannerAssembly.ts new file mode 100644 index 00000000..811e17a5 --- /dev/null +++ b/explainers/mri-scanner/src/scene/mriScannerAssembly.ts @@ -0,0 +1,119 @@ +import { Group } from 'three'; +import type { Box3, Material, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import { anchorAt, isShown } from '@core/scene/parts'; +import { ResourceTracker } from '@core/scene/resources'; +import { PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { ISOCENTRE } from '../model/layout'; +import type { Assembly, AssemblyResources } from './assembly'; +import type { PartContext, SceneModule } from './parts/context'; +import { createEffectsModule } from './parts/effects/effects'; +import { createMagnetModule } from './parts/magnet/magnet'; +import { createRoomModule } from './parts/room/room'; +import { createSubjectModule } from './parts/subject/subject'; +import { buildRegions } from './regions'; + +const MODULE_FACTORIES: readonly ((context: PartContext) => SceneModule)[] = [ + createRoomModule, + createSubjectModule, + createMagnetModule, + createEffectsModule, +]; + +const ROOT_NAME = 'mriScanner'; + +type Drawable = Object3D & { material: Material }; + +function isDrawable(object: Object3D): object is Drawable { + return 'material' in object && !Array.isArray(object.material); +} + +function collectLabels(modules: readonly SceneModule[]): ReadonlyMap { + const found = new Map(modules.flatMap((module) => [...module.labels])); + return new Map( + PART_IDS.map((id) => { + const anchor = found.get(id); + if (!anchor) throw new Error(`No label anchor for part ${id}`); + return [id, anchor]; + }), + ); +} + +function collectAnchors( + root: Object3D, + modules: readonly SceneModule[], +): Readonly> { + const provided = Object.assign({}, ...modules.map((module) => module.anchors)) as Partial< + Record + >; + const { voxel, headCoil, screen, coldHead } = provided; + if (!voxel || !headCoil || !screen || !coldHead) throw new Error('A scene anchor is missing'); + return { isocentre: anchorAt(root, ...ISOCENTRE), voxel, headCoil, screen, coldHead }; +} + +class MriScannerAssembly implements Assembly { + readonly root = new Group(); + private readonly materials: MaterialLibrary; + private readonly tracker = new ResourceTracker(); + private readonly modules: readonly SceneModule[]; + private readonly labels: ReadonlyMap; + private readonly anchors: Readonly>; + private readonly regions = buildRegions(); + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.materials = resources.materials; + this.root.name = ROOT_NAME; + const context: PartContext = { ...resources, tracker: this.tracker }; + this.modules = MODULE_FACTORIES.map((create) => create(context)); + this.modules.forEach((module) => this.root.add(module.root)); + this.labels = collectLabels(this.modules); + this.anchors = collectAnchors(this.root, this.modules); + this.setState(state); + } + + setState(state: AssemblyState): void { + this.modules.forEach((module) => module.setState(state)); + } + + update(deltaSeconds: number, cameraDistance: number): boolean { + return this.modules.reduce( + (moving, module) => module.update(deltaSeconds, cameraDistance) || moving, + false, + ); + } + + labelAnchors(): ReadonlyMap { + return this.labels; + } + + anchor(id: AnchorId): Object3D { + return this.anchors[id]; + } + + region(id: RegionId): Box3 { + return this.regions[id]; + } + + warmUp(compile: (object: Object3D) => void): void { + const seen = new Set(); + this.root.traverse((object) => { + if (!isDrawable(object) || isShown(object) || seen.has(object.material)) return; + seen.add(object.material); + compile(object); + }); + } + + dispose(): void { + this.root.removeFromParent(); + this.materials.clearRegistered(); + this.tracker.dispose(); + } +} + +export function createMriScannerAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new MriScannerAssembly(resources, state); +} From aceebb92d67b7361bc381943b7d47df90058c5e4 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:51:45 +0200 Subject: [PATCH 54/82] test(mri-scanner): name the pulse steps as the copy does --- .../src/scene/placeholderAssembly.dom.test.ts | 83 ----- .../src/scene/placeholderAssembly.ts | 333 ------------------ .../mri-scanner/src/timeline.dom.test.ts | 4 +- 3 files changed, 2 insertions(+), 418 deletions(-) delete mode 100644 explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts delete mode 100644 explainers/mri-scanner/src/scene/placeholderAssembly.ts diff --git a/explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts b/explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts deleted file mode 100644 index 0c263b38..00000000 --- a/explainers/mri-scanner/src/scene/placeholderAssembly.dom.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { Vector3 } from 'three'; -import type { Object3D } from 'three'; -import { MaterialLibrary } from '@core/scene/materials'; -import { createSceneTextures } from '@core/scene/textures'; -import { PART_IDS } from '../ids'; -import type { AnchorId, AssemblyState, RegionId } from '../ids'; -import { FIELDS } from '../model/field'; -import { ISOCENTRE, SCREEN, VOXEL } from '../model/layout'; -import { MODEL_SIZE } from '../model/constants'; -import type { Assembly } from './assembly'; -import { createAssembly } from './assembly'; - -const ANCHOR_IDS: readonly AnchorId[] = ['isocentre', 'voxel', 'headCoil', 'screen', 'coldHead']; -const REGION_IDS: readonly RegionId[] = ['room', 'scanner', 'layers', 'voxel', 'bore', 'console']; - -function stateWith(overrides: Partial = {}): AssemblyState { - return { - phase: 0, - playing: false, - field: 'field15', - weighting: 't2', - sequence: { phase: 0, step: 'excite', rf: 90, gradient: 'z', gradientLevel: 1, echo: 0 }, - spins: { net: [0, 0, -1], arrows: [], precession: 0 }, - tissue: 'whiteMatter', - gradientAxis: null, - picture: new Float32Array(MODEL_SIZE * MODEL_SIZE), - pictureVersion: 0, - fringe: FIELDS.field15.fringe, - view: { cutaway: false, fieldLines: false, voxel: false, labels: true }, - ...overrides, - }; -} - -function worldPoint(object: Object3D): readonly number[] { - object.updateWorldMatrix(true, false); - return object.getWorldPosition(new Vector3()).toArray(); -} - -let textures: ReturnType; -let assembly: Assembly; - -beforeAll(() => { - textures = createSceneTextures(); - assembly = createAssembly({ materials: new MaterialLibrary(), textures }, stateWith()); -}); - -afterAll(() => { - assembly.dispose(); - textures.dispose(); -}); - -describe('placeholder assembly', () => { - it('gives every part a label anchor', () => { - expect([...assembly.labelAnchors().keys()]).toEqual([...PART_IDS]); - }); - - it('places the named anchors at the layout points', () => { - ANCHOR_IDS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); - expect(worldPoint(assembly.anchor('isocentre'))).toEqual([...ISOCENTRE]); - expect(worldPoint(assembly.anchor('voxel'))).toEqual([...VOXEL.centre]); - expect(worldPoint(assembly.anchor('screen'))).toEqual([...SCREEN.centre]); - }); - - it('frames every region with a box that holds something', () => { - REGION_IDS.forEach((id) => expect(assembly.region(id).isEmpty()).toBe(false)); - expect(assembly.region('room').containsBox(assembly.region('scanner'))).toBe(true); - expect(assembly.region('scanner').containsBox(assembly.region('bore'))).toBe(true); - }); - - it('follows the view flags and keeps moving only while something turns', () => { - const view = { cutaway: true, fieldLines: true, voxel: true, labels: true }; - assembly.setState(stateWith({ playing: true, view })); - expect(assembly.root.getObjectByName('cover')?.visible).toBe(false); - expect(assembly.root.getObjectByName('spinArrows')?.visible).toBe(true); - expect(assembly.root.getObjectByName('fringeLine')?.visible).toBe(true); - expect(assembly.update(0.016, 5)).toBe(true); - assembly.setState(stateWith()); - expect(assembly.root.getObjectByName('cover')?.visible).toBe(true); - expect(assembly.root.getObjectByName('spinArrows')?.visible).toBe(false); - expect(assembly.update(0.016, 5)).toBe(false); - }); -}); diff --git a/explainers/mri-scanner/src/scene/placeholderAssembly.ts b/explainers/mri-scanner/src/scene/placeholderAssembly.ts deleted file mode 100644 index 7315b2cc..00000000 --- a/explainers/mri-scanner/src/scene/placeholderAssembly.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { CylinderGeometry, Group, Mesh } from 'three'; -import type { BufferGeometry, Box3, Object3D } from 'three'; -import { clamp } from '@core/math'; -import { box } from '@core/scene/geometry/box'; -import type { BoxBounds } from '@core/scene/geometry/box'; -import { anchorAt } from '@core/scene/parts'; -import { regionFromSpec } from '@core/scene/regions'; -import type { AnchorId, AssemblyState, GradientAxisId, PartId, Point, RegionId } from '../ids'; -import { GRADIENT_AXIS_IDS, PART_IDS } from '../ids'; -import { - BODY_COIL, - BORE, - COLD_HEAD, - FLOOR_Y, - HEAD_COIL, - ISOCENTRE, - LAYERS, - MAGNET, - MAIN_COILS, - MAIN_FIELD_ARROW, - PATIENT, - QUENCH_PIPE, - REGIONS, - ROOM, - SCREEN, - SHIELD_COILS, - SHIMS, - SLICE_THICKNESS, - TABLE, - VOXEL, - gradientShell, -} from '../model/layout'; -import type { CoilRings, Shell } from '../model/layout'; -import { GRADIENT_TONES, THEME } from '../theme'; -import type { Assembly, AssemblyResources } from './assembly'; - -const SEGMENTS = 32; -const FLOOR_THICKNESS = 0.02; -const ARROW_THICKNESS = 0.03; -const SCREEN_DEPTH = 0.03; -const ECHO_GAP = 0.02; -const ECHO_RADIUS = HEAD_COIL.radius + ECHO_GAP; -const FIELD_LINE_REACH = 1.5; -const DIAGONAL = Math.SQRT1_2; -const GRADIENT_PARTS: Readonly> = { - x: 'gradientX', - y: 'gradientY', - z: 'gradientZ', -}; - -function bounds(centre: Point, size: Point): BoxBounds { - const [x, y, z] = centre; - const [width, height, depth] = size; - return { - minX: x - width / 2, - maxX: x + width / 2, - minY: y - height / 2, - maxY: y + height / 2, - minZ: z - depth / 2, - maxZ: z + depth / 2, - }; -} - -function tube(radius: number, length: number): BufferGeometry { - return new CylinderGeometry(radius, radius, length, SEGMENTS, 1, true).rotateX(Math.PI / 2); -} - -function post(radius: number, from: number, to: number): BufferGeometry { - return new CylinderGeometry(radius, radius, to - from, SEGMENTS).translate(0, (from + to) / 2, 0); -} - -function diagonal(radius: number, z = 0): Point { - return [radius * DIAGONAL, ISOCENTRE[1] + radius * DIAGONAL, z]; -} - -const PART_ANCHORS: Readonly> = { - room: [ROOM.x[0], FLOOR_Y, ROOM.z[0]], - cover: diagonal(MAGNET.radius, MAGNET.halfLength), - vacuumVessel: diagonal(LAYERS.vacuumVessel.outer), - radiationShield: diagonal(LAYERS.radiationShield.outer), - heliumVessel: diagonal(LAYERS.heliumVessel.outer), - mainCoils: diagonal(MAIN_COILS.outer, MAIN_COILS.z[3]), - shieldCoils: diagonal(SHIELD_COILS.outer, SHIELD_COILS.z[1]), - shims: diagonal(SHIMS.radius), - coldHead: [COLD_HEAD.centre[0], COLD_HEAD.top, COLD_HEAD.centre[2]], - quenchPipe: [QUENCH_PIPE.x, QUENCH_PIPE.to, QUENCH_PIPE.z], - gradientX: diagonal(gradientShell('x').outer), - gradientY: diagonal(gradientShell('y').outer), - gradientZ: diagonal(gradientShell('z').outer), - bodyCoil: diagonal(BODY_COIL.radius), - bore: [0, ISOCENTRE[1] - BORE.radius, BORE.halfLength], - table: [TABLE.halfWidth, TABLE.top, TABLE.z[1]], - patient: [0, TABLE.top, PATIENT.feetZ], - headCoil: [0, ISOCENTRE[1] + HEAD_COIL.radius, 0], - sliceSlab: [BORE.radius, ISOCENTRE[1], 0], - spinArrows: VOXEL.centre, - netMagnet: [VOXEL.centre[0], VOXEL.centre[1] + VOXEL.size / 2, VOXEL.centre[2]], - mainField: MAIN_FIELD_ARROW.tail, - rfWave: diagonal(BODY_COIL.radius, -BODY_COIL.halfLength), - echoWave: diagonal(ECHO_RADIUS), - fieldLinesGroup: [0, ISOCENTRE[1], -BORE.halfLength - FIELD_LINE_REACH], - fringeLine: [0, FLOOR_Y, ROOM.z[0]], - screen: SCREEN.centre, -}; - -class PlaceholderAssembly implements Assembly { - readonly root = new Group(); - private readonly resources: AssemblyResources; - private readonly parts = new Map(); - private readonly anchors = new Map(); - private readonly named: Readonly>; - private readonly regions: Readonly>; - private state: AssemblyState; - - constructor(resources: AssemblyResources, state: AssemblyState) { - this.resources = resources; - this.state = state; - for (const id of PART_IDS) { - const group = new Group(); - group.name = id; - this.parts.set(id, group); - this.root.add(group); - this.anchors.set(id, anchorAt(group, ...PART_ANCHORS[id])); - } - this.buildRoom(); - this.buildMagnet(); - this.buildPatient(); - this.buildInset(); - this.named = { - isocentre: anchorAt(this.root, ...ISOCENTRE), - voxel: anchorAt(this.root, ...VOXEL.centre), - headCoil: anchorAt(this.root, ...PART_ANCHORS.headCoil), - screen: anchorAt(this.root, ...SCREEN.centre), - coldHead: anchorAt(this.root, ...PART_ANCHORS.coldHead), - }; - this.regions = Object.fromEntries( - Object.entries(REGIONS).map(([id, spec]) => [id, regionFromSpec(spec)]), - ) as Record; - this.setState(state); - } - - private part(id: PartId): Group { - const group = this.parts.get(id); - if (!group) throw new Error(`Unknown part ${id}`); - return group; - } - - private add(id: PartId, color: string, geometry: BufferGeometry, at: Point = ISOCENTRE): Mesh { - const mesh = new Mesh(geometry, this.resources.materials.get(id, { color })); - mesh.position.set(...at); - this.part(id).add(mesh); - return mesh; - } - - private shell(id: PartId, color: string, { outer, halfLength }: Shell): void { - this.add(id, color, tube(outer, 2 * halfLength)); - } - - private rings(id: PartId, color: string, { outer, width, z }: CoilRings): void { - z.forEach((at) => this.add(id, color, tube(outer, width), [0, ISOCENTRE[1], at])); - } - - private buildRoom(): void { - const floor: Point = [0, FLOOR_Y - FLOOR_THICKNESS / 2, (ROOM.z[0] + ROOM.z[1]) / 2]; - const size: Point = [ROOM.x[1] - ROOM.x[0], FLOOR_THICKNESS, ROOM.z[1] - ROOM.z[0]]; - this.add('room', THEME.floor, box(bounds(floor, size)), [0, 0, 0]); - const screen = this.add( - 'screen', - THEME.screenFrame, - box(bounds([0, 0, 0], [SCREEN_DEPTH, SCREEN.height, SCREEN.width])), - SCREEN.centre, - ); - screen.rotation.y = SCREEN.yawTowardTable; - } - - private buildMagnet(): void { - this.shell('cover', THEME.cover, LAYERS.cover); - this.shell('vacuumVessel', THEME.vacuumVessel, LAYERS.vacuumVessel); - this.shell('radiationShield', THEME.radiationShield, LAYERS.radiationShield); - this.shell('heliumVessel', THEME.helium, LAYERS.heliumVessel); - this.rings('mainCoils', THEME.winding, MAIN_COILS); - this.rings('shieldCoils', THEME.shieldWinding, SHIELD_COILS); - this.add('shims', THEME.shim, tube(SHIMS.radius, 2 * SHIMS.halfLength)); - for (const axis of GRADIENT_AXIS_IDS) { - this.shell(GRADIENT_PARTS[axis], GRADIENT_TONES[axis], gradientShell(axis)); - } - this.add('bodyCoil', THEME.metal, tube(BODY_COIL.radius, 2 * BODY_COIL.halfLength)); - this.add('bore', THEME.boreLiner, tube(BORE.radius, 2 * BORE.halfLength)); - const turretFoot = 2 * COLD_HEAD.centre[1] - COLD_HEAD.top; - this.add('coldHead', THEME.metal, post(COLD_HEAD.radius, turretFoot, COLD_HEAD.top), [ - COLD_HEAD.centre[0], - 0, - COLD_HEAD.centre[2], - ]); - this.add( - 'quenchPipe', - THEME.metalDark, - post(QUENCH_PIPE.radius, QUENCH_PIPE.from, QUENCH_PIPE.to), - [QUENCH_PIPE.x, 0, QUENCH_PIPE.z], - ); - this.add('rfWave', THEME.rf, tube(BODY_COIL.radius, 2 * BODY_COIL.halfLength)); - this.add( - 'sliceSlab', - THEME.slice, - box(bounds([0, 0, 0], [2 * BORE.radius, 2 * BORE.radius, SLICE_THICKNESS])), - ); - this.add( - 'fieldLinesGroup', - THEME.fieldLine, - box( - bounds( - [0, 0, 0], - [ARROW_THICKNESS, ARROW_THICKNESS, 2 * (BORE.halfLength + FIELD_LINE_REACH)], - ), - ), - ); - this.add('fringeLine', THEME.fringe, box(bounds([0, 0, 0], [2, FLOOR_THICKNESS, 2])), [ - 0, - FLOOR_Y, - 0, - ]); - } - - private buildPatient(): void { - const [tableFrom, tableTo] = TABLE.z; - const [cradleFrom, cradleTo] = TABLE.cradleZ; - const cradleBase = TABLE.top - TABLE.cradleThickness; - const tableSize: Point = [2 * TABLE.halfWidth, cradleBase, tableTo - tableFrom]; - this.add( - 'table', - THEME.table, - box(bounds([0, cradleBase / 2, (tableFrom + tableTo) / 2], tableSize)), - [0, 0, 0], - ); - const cradleSize: Point = [ - 2 * TABLE.cradleHalfWidth, - TABLE.cradleThickness, - cradleTo - cradleFrom, - ]; - this.add( - 'table', - THEME.cradle, - box( - bounds( - [0, cradleBase + TABLE.cradleThickness / 2, (cradleFrom + cradleTo) / 2], - cradleSize, - ), - ), - [0, 0, 0], - ); - const headSize = 2 * PATIENT.headRadius; - this.add('patient', THEME.skin, box(bounds([0, 0, 0], [headSize, headSize, headSize]))); - const bodyFrom = PATIENT.head[2] + PATIENT.headRadius; - const bodySize: Point = [2 * PATIENT.halfWidth, headSize, PATIENT.feetZ - bodyFrom]; - this.add( - 'patient', - THEME.blanket, - box(bounds([0, TABLE.top + PATIENT.headRadius, (bodyFrom + PATIENT.feetZ) / 2], bodySize)), - [0, 0, 0], - ); - this.add('headCoil', THEME.headCoil, tube(HEAD_COIL.radius, HEAD_COIL.length)); - this.add('echoWave', THEME.echo, tube(ECHO_RADIUS, HEAD_COIL.length)); - } - - private buildInset(): void { - const { centre, size } = VOXEL; - this.add('spinArrows', THEME.spinArrow, box(bounds([0, 0, 0], [size, size, size])), centre); - this.add( - 'netMagnet', - THEME.netMagnet, - box(bounds([0, 0, 0], [ARROW_THICKNESS, ARROW_THICKNESS, size])), - centre, - ); - const { tail, length } = MAIN_FIELD_ARROW; - this.add( - 'mainField', - THEME.mainField, - box(bounds([0, 0, -length / 2], [ARROW_THICKNESS, ARROW_THICKNESS, length])), - tail, - ); - } - - setState(state: AssemblyState): void { - this.state = state; - const { view, sequence, fringe } = state; - const show = (id: PartId, visible: boolean): void => { - this.part(id).visible = visible; - }; - show('cover', !view.cutaway); - show('fieldLinesGroup', view.fieldLines); - show('fringeLine', view.fieldLines); - for (const id of ['spinArrows', 'netMagnet', 'mainField'] as const) show(id, view.voxel); - show('rfWave', sequence.rf !== null); - show('echoWave', sequence.echo > 0); - show('sliceSlab', sequence.rf !== null); - this.part('fringeLine').scale.set( - clamp(fringe.side, 0, ROOM.x[1]), - 1, - clamp(fringe.along, 0, Math.min(-ROOM.z[0], ROOM.z[1])), - ); - } - - update(): boolean { - const { playing, view } = this.state; - return playing && (view.voxel || view.fieldLines); - } - - labelAnchors(): ReadonlyMap { - return this.anchors; - } - - anchor(id: AnchorId): Object3D { - return this.named[id]; - } - - region(id: RegionId): Box3 { - return this.regions[id]; - } - - dispose(): void { - this.root.traverse((object) => { - if (object instanceof Mesh) object.geometry.dispose(); - }); - this.root.clear(); - } -} - -export function createPlaceholderAssembly( - resources: AssemblyResources, - state: AssemblyState, -): Assembly { - return new PlaceholderAssembly(resources, state); -} diff --git a/explainers/mri-scanner/src/timeline.dom.test.ts b/explainers/mri-scanner/src/timeline.dom.test.ts index dd25f46e..818f3cf9 100644 --- a/explainers/mri-scanner/src/timeline.dom.test.ts +++ b/explainers/mri-scanner/src/timeline.dom.test.ts @@ -67,8 +67,8 @@ describe('mri scanner timeline', () => { }); it('tells a screen reader which step of five is playing', () => { - expect(describePhase(0)).toBe('Step 1 of 5, Radio pulse'); - expect(describePhase(PHASE_RANGES.refocus[0])).toBe('Step 3 of 5, Refocus'); + expect(describePhase(0)).toBe('Step 1 of 5, 90° pulse'); + expect(describePhase(PHASE_RANGES.refocus[0])).toBe('Step 3 of 5, 180° pulse'); }); it('shows the speed short on the slider and in full words for a screen reader', () => { From 5ce544fdd465238e65fc4bf6732a4c56d7ac59fc Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:51:45 +0200 Subject: [PATCH 55/82] feat(mri-scanner): mount the real scanner scene --- explainers/mri-scanner/src/scene/assembly.ts | 4 +- explainers/mri-scanner/src/scene/index.ts | 49 ++----------------- .../src/scene/mriScannerAssembly.ts | 1 - 3 files changed, 5 insertions(+), 49 deletions(-) diff --git a/explainers/mri-scanner/src/scene/assembly.ts b/explainers/mri-scanner/src/scene/assembly.ts index f75204c9..2aef2db7 100644 --- a/explainers/mri-scanner/src/scene/assembly.ts +++ b/explainers/mri-scanner/src/scene/assembly.ts @@ -2,7 +2,7 @@ import type { Box3, Group, Object3D } from 'three'; import type { MaterialLibrary } from '@core/scene/materials'; import type { SceneTextures } from '@core/scene/textures'; import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; -import { createPlaceholderAssembly } from './placeholderAssembly'; +import { createMriScannerAssembly } from './mriScannerAssembly'; export interface AssemblyResources { materials: MaterialLibrary; @@ -21,5 +21,5 @@ export interface Assembly { } export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { - return createPlaceholderAssembly(resources, state); + return createMriScannerAssembly(resources, state); } diff --git a/explainers/mri-scanner/src/scene/index.ts b/explainers/mri-scanner/src/scene/index.ts index 8b5b9a16..54bfa763 100644 --- a/explainers/mri-scanner/src/scene/index.ts +++ b/explainers/mri-scanner/src/scene/index.ts @@ -1,59 +1,16 @@ -import type { DirectionalLight } from 'three'; -import type { Lighting } from '@core/scene/lighting'; import { createLabelVisibility } from '@core/scene/presetBinder'; -import type { SceneOptions, SceneShell } from '@core/scene/shell'; +import type { SceneShell } from '@core/scene/shell'; import type { MriScannerStore } from '../state'; -import { THEME } from '../theme'; import { bindStore } from './bindings'; import { MriScannerController } from './controller'; +import { mriScannerLight } from './lighting'; import { LABEL_PRIORITY } from './partInfo'; export { bindStore } from './bindings'; export type { SceneTargets } from './bindings'; export { MriScannerController } from './controller'; export type { MriScannerControllerDependencies, ViewFramer } from './controller'; - -type LightName = 'key' | 'fill' | 'rim'; - -interface LightSetting { - readonly color: string; - readonly intensity: number; - readonly position: readonly [x: number, y: number, z: number]; -} - -const MRI_SCANNER_LIGHT: Readonly> = { - key: { color: '#fff4e6', intensity: 2.2, position: [0, 80, 45] }, - fill: { color: '#b8d0f0', intensity: 0.6, position: [-70, 30, 25] }, - rim: { color: '#cfe0ff', intensity: 1.2, position: [80, 25, 25] }, -}; - -export const SCENE_OPTIONS: SceneOptions = { - background: THEME.background, - stage: false, - camera: { near: 0.05, far: 60, distance: { min: 0.3, max: 12 } }, - highlight: { undimmed: ['room', 'fieldLinesGroup'] }, -}; - -function applyLight(light: DirectionalLight, setting: LightSetting): () => void { - const color = light.color.clone(); - const intensity = light.intensity; - const position = light.position.clone(); - light.color.set(setting.color); - light.intensity = setting.intensity; - light.position.fromArray(setting.position); - return () => { - light.color.copy(color); - light.intensity = intensity; - light.position.copy(position); - }; -} - -function mriScannerLight(lighting: Pick): () => void { - const restorers = (Object.keys(MRI_SCANNER_LIGHT) as LightName[]).map((name) => - applyLight(lighting[name], MRI_SCANNER_LIGHT[name]), - ); - return () => restorers.forEach((restore) => restore()); -} +export { SCENE_OPTIONS } from './sceneOptions'; export function mountMriScannerScene(shell: SceneShell, store: MriScannerStore): () => void { const restoreLight = mriScannerLight(shell.lighting); diff --git a/explainers/mri-scanner/src/scene/mriScannerAssembly.ts b/explainers/mri-scanner/src/scene/mriScannerAssembly.ts index 811e17a5..3a345740 100644 --- a/explainers/mri-scanner/src/scene/mriScannerAssembly.ts +++ b/explainers/mri-scanner/src/scene/mriScannerAssembly.ts @@ -105,7 +105,6 @@ class MriScannerAssembly implements Assembly { } dispose(): void { - this.root.removeFromParent(); this.materials.clearRegistered(); this.tracker.dispose(); } From 549ba865d7cc86798c012a3ee5c77f6c03a30c55 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:52:46 +0200 Subject: [PATCH 56/82] feat(mri-scanner): place the picture screen on the real monitor --- explainers/mri-scanner/src/model/layout.test.ts | 6 +++++- explainers/mri-scanner/src/model/layout.ts | 13 ++++++++----- .../src/scene/mriScannerAssembly.dom.test.ts | 5 ++--- .../mri-scanner/src/scene/parts/room/monitor.ts | 16 +++------------- .../src/scene/parts/room/room.dom.test.ts | 9 ++++----- .../mri-scanner/src/scene/parts/room/room.ts | 6 +++--- 6 files changed, 25 insertions(+), 30 deletions(-) diff --git a/explainers/mri-scanner/src/model/layout.test.ts b/explainers/mri-scanner/src/model/layout.test.ts index e13316bc..cf4ac4d8 100644 --- a/explainers/mri-scanner/src/model/layout.test.ts +++ b/explainers/mri-scanner/src/model/layout.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + CONTROL_WINDOW, BODY_COIL, BORE, COLD_HEAD, @@ -81,7 +82,10 @@ describe('scanner layout', () => { }); it('places the screen, the voxel inset and the regions inside the room', () => { - expect(SCREEN.centre).toEqual([2.6, 1.55, 1.0]); + [2.66, 1.55, -0.3].forEach((value, axis) => expect(SCREEN.centre[axis]).toBeCloseTo(value, 9)); + expect(SCREEN.centre[2] + SCREEN.width / 2).toBeLessThan( + CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2, + ); expect(VOXEL.centre).toEqual([0, 1.75, 1.35]); expect(VOXEL_ARROWS).toBe(64); for (const region of Object.values(REGIONS)) { diff --git a/explainers/mri-scanner/src/model/layout.ts b/explainers/mri-scanner/src/model/layout.ts index 4fc0281d..f6eed7d5 100644 --- a/explainers/mri-scanner/src/model/layout.ts +++ b/explainers/mri-scanner/src/model/layout.ts @@ -99,8 +99,14 @@ export const ROOM = { x: [-3, 3] as Extent, z: [-2, 3] as Extent, height: CEILIN export const CONTROL_WINDOW = { x: ROOM.x[1], centreZ: 1.0, width: 1.4, sill: 0.9, top: 2.0 }; +const SCREEN_MOUNT = { wallStandoff: 0.34, windowGap: 0.6, height: 1.55 } as const; + export const SCREEN = { - centre: [2.6, 1.55, 1.0] as Point, + centre: [ + ROOM.x[1] - SCREEN_MOUNT.wallStandoff, + SCREEN_MOUNT.height, + CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2 - SCREEN_MOUNT.windowGap, + ] as Point, width: 0.9, height: 0.6, yawTowardTable: toRadians(25), @@ -145,9 +151,6 @@ export const REGIONS: Readonly> = { console: { x: [SCREEN.centre[0] - SCREEN.height, ROOM.x[1]], y: [CONTROL_WINDOW.sill, CONTROL_WINDOW.top], - z: [ - CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2, - CONTROL_WINDOW.centreZ + CONTROL_WINDOW.width / 2, - ], + z: [SCREEN.centre[2] - SCREEN.width / 2, CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 4], }, }; diff --git a/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts b/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts index 5315482e..1c1e0fe3 100644 --- a/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts +++ b/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts @@ -6,12 +6,11 @@ import { createSceneTextures } from '@core/scene/textures'; import { PART_IDS } from '../ids'; import type { AnchorId, AssemblyState, RegionId } from '../ids'; import { FIELDS } from '../model/field'; -import { HEAD_COIL, ISOCENTRE, VOXEL } from '../model/layout'; +import { HEAD_COIL, ISOCENTRE, SCREEN, VOXEL } from '../model/layout'; import { MODEL_SIZE } from '../model/constants'; import type { Assembly } from './assembly'; import { isShown } from '@core/scene/parts'; import { createMriScannerAssembly } from './mriScannerAssembly'; -import { MONITOR_CENTRE } from './parts/room/monitor'; const ANCHOR_IDS: readonly AnchorId[] = ['isocentre', 'voxel', 'headCoil', 'screen', 'coldHead']; const SETTLE_SECONDS = 5; @@ -70,7 +69,7 @@ describe('mri scanner assembly', () => { ANCHOR_IDS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); expect(worldPoint(assembly.anchor('isocentre'))).toEqual([...ISOCENTRE]); expect(worldPoint(assembly.anchor('voxel'))).toEqual([...VOXEL.centre]); - expect(worldPoint(assembly.anchor('screen'))).toEqual([...MONITOR_CENTRE]); + expect(worldPoint(assembly.anchor('screen'))).toEqual([...SCREEN.centre]); expect(worldPoint(assembly.anchor('headCoil'))).toEqual([...HEAD_COIL.centre]); }); diff --git a/explainers/mri-scanner/src/scene/parts/room/monitor.ts b/explainers/mri-scanner/src/scene/parts/room/monitor.ts index 7c5f8cd2..f9eb78ef 100644 --- a/explainers/mri-scanner/src/scene/parts/room/monitor.ts +++ b/explainers/mri-scanner/src/scene/parts/room/monitor.ts @@ -10,7 +10,7 @@ import { Vector3, } from 'three'; import type { BufferGeometry } from 'three'; -import { CONTROL_WINDOW, ROOM, SCREEN } from '../../../model/layout'; +import { ROOM, SCREEN } from '../../../model/layout'; import { ROOM_DETAIL } from '../../constants'; import { mergeParts } from '../context'; @@ -21,8 +21,6 @@ const BAR_LEVEL = 7; const SURFACE_LIFT = 0.002; const WALL_X = ROOM.x[1]; const MOUNT = { - standoff: 0.34, - windowGap: 0.6, bezel: 0.018, depth: 0.03, hump: { width: 0.36, height: 0.26, depth: 0.03 }, @@ -30,26 +28,18 @@ const MOUNT = { plate: { thickness: 0.016, height: 0.22, width: 0.12 }, } as const; -export const MONITOR_CENTRE = [ - WALL_X - MOUNT.standoff, - SCREEN.centre[1], - CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 2 - MOUNT.windowGap, -] as const; - const PICTURE_ROWS = ROOM_DETAIL.pictureSize; export const PICTURE_COLUMNS = Math.round((PICTURE_ROWS * SCREEN.width) / SCREEN.height); const BAR_COLUMNS = Math.floor((PICTURE_COLUMNS - PICTURE_ROWS) / 2); function monitorMatrix(): Matrix4 { - return new Matrix4() - .makeRotationY(SCREEN.yawTowardTable - QUARTER) - .setPosition(...MONITOR_CENTRE); + return new Matrix4().makeRotationY(SCREEN.yawTowardTable - QUARTER).setPosition(...SCREEN.centre); } function wallArm(matrix: Matrix4): BufferGeometry[] { const { depth, hump, arm, plate } = MOUNT; const back = depth + hump.depth; - const reach = (WALL_X - MONITOR_CENTRE[0]) / Math.cos(SCREEN.yawTowardTable) - back; + const reach = (WALL_X - SCREEN.centre[0]) / Math.cos(SCREEN.yawTowardTable) - back; const end = new Vector3(0, 0, -(back + reach)).applyMatrix4(matrix); return [ new BoxGeometry(arm, arm, reach).translate(0, 0, -(back + reach / 2)).applyMatrix4(matrix), diff --git a/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts b/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts index 36e61c91..3acb35fd 100644 --- a/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts +++ b/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts @@ -7,7 +7,6 @@ import { createSceneTextures } from '@core/scene/textures'; import type { AssemblyState } from '../../../ids'; import { MODEL_SIZE } from '../../../model/constants'; import { CONTROL_WINDOW, ROOM, SCREEN } from '../../../model/layout'; -import { MONITOR_CENTRE } from './monitor'; import { createRoomModule } from './room'; const TRIANGLE_BUDGET = 20000; @@ -60,7 +59,7 @@ describe('room module', () => { const room = createRoomModule(context()); expect(room.root.getObjectByName('room')).toBeDefined(); expect(room.root.getObjectByName('screen')).toBeDefined(); - expect(worldPoint(room.anchors.screen as Object3D)).toEqual([...MONITOR_CENTRE]); + expect(worldPoint(room.anchors.screen as Object3D)).toEqual([...SCREEN.centre]); expect([...room.labels.keys()]).toEqual(['room', 'screen']); expect(room.update(0.016, 5)).toBe(false); }); @@ -114,12 +113,12 @@ describe('room module', () => { const room = createRoomModule(context()); room.root.updateMatrixWorld(true); const blockers = (x: number, z: number) => - new Raycaster(new Vector3(x, MONITOR_CENTRE[1], z), new Vector3(-1, 0, 0)) + new Raycaster(new Vector3(x, SCREEN.centre[1], z), new Vector3(-1, 0, 0)) .intersectObject(room.root, true) .filter((hit) => hit.point.x > 0); expect(blockers(ROOM.x[1] + 4, CONTROL_WINDOW.centreZ)).toHaveLength(0); - expect(blockers(ROOM.x[1] + 4, MONITOR_CENTRE[2])).toHaveLength(0); - expect(blockers(ROOM.x[1] - 0.1, MONITOR_CENTRE[2]).length).toBeGreaterThan(0); + expect(blockers(ROOM.x[1] + 4, SCREEN.centre[2])).toHaveLength(0); + expect(blockers(ROOM.x[1] - 0.1, SCREEN.centre[2]).length).toBeGreaterThan(0); }); it('never blocks the scanner from above, behind or the far side', () => { diff --git a/explainers/mri-scanner/src/scene/parts/room/room.ts b/explainers/mri-scanner/src/scene/parts/room/room.ts index 238b8b0c..2b51107c 100644 --- a/explainers/mri-scanner/src/scene/parts/room/room.ts +++ b/explainers/mri-scanner/src/scene/parts/room/room.ts @@ -14,7 +14,7 @@ import { UNDIMMED_GROUP } from '@core/scene/materials'; import type { MaterialFinish } from '@core/scene/materials'; import { anchorAt } from '@core/scene/parts'; import type { AssemblyState, PartId } from '../../../ids'; -import { CEILING_Y, CONTROL_WINDOW, FLOOR_Y, ROOM } from '../../../model/layout'; +import { CEILING_Y, CONTROL_WINDOW, FLOOR_Y, ROOM, SCREEN } from '../../../model/layout'; import { ROOM_DETAIL } from '../../constants'; import { FINISHES, @@ -27,7 +27,7 @@ import type { PartContext, SceneModule } from '../context'; import { mergeParts, partMesh } from '../context'; import { farSideMaterial, farSideMesh } from './farSide'; import type { WallSide } from './farSide'; -import { MONITOR_CENTRE, monitorGeometry, pictureTexture, writePicture } from './monitor'; +import { monitorGeometry, pictureTexture, writePicture } from './monitor'; import { ALWAYS_SHOWN, shellMesh } from './shell'; import type { ShellPiece } from './shell'; import { displayTexture, floorTexture, glassSheenTexture, wallTexture } from './surfaces'; @@ -260,7 +260,7 @@ class RoomModule implements SceneModule { const room = this.buildRoom(); const screen = this.buildScreen(); this.root.add(room, screen); - const screenAnchor = anchorAt(screen, ...MONITOR_CENTRE); + const screenAnchor = anchorAt(screen, ...SCREEN.centre); this.labels = new Map([ ['room', anchorAt(room, X0, FLOOR_Y, Z0)], ['screen', screenAnchor], From 8a62540cf5405929c31676d2436b5bb46f02bb64 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:55:42 +0200 Subject: [PATCH 57/82] fix(mri-scanner): dim the gradient glow with the rest of the scene --- .../src/scene/parts/magnet/gradients.ts | 6 +++-- .../src/scene/parts/magnet/magnet.dom.test.ts | 22 ++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts b/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts index 126deff3..52639ea7 100644 --- a/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts +++ b/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts @@ -89,11 +89,12 @@ function shellGeometry(axis: GradientAxisId): BufferGeometry { function glowMaterial(axis: GradientAxisId): ShaderMaterial { const shell = gradientShell(axis); - return new ShaderMaterial({ + const tone = new Color(GRADIENT_TONES[axis]); + const material = new ShaderMaterial({ uniforms: { axis: { value: AXIS_VECTORS[axis] }, extent: { value: axis === 'z' ? shell.halfLength : shell.outer }, - tone: { value: new Color(GRADIENT_TONES[axis]) }, + tone: { value: tone }, level: { value: 0 }, dimEnd: { value: GLOW.dimEnd }, gain: { value: GLOW.gain }, @@ -112,6 +113,7 @@ function glowMaterial(axis: GradientAxisId): ShaderMaterial { polygonOffsetFactor: POLYGON_PULL, polygonOffsetUnits: POLYGON_PULL, }); + return Object.assign(material, { color: tone }); } interface Glow { diff --git a/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts b/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts index 779857ca..79f0f343 100644 --- a/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts +++ b/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { InstancedMesh, Mesh } from 'three'; +import { InstancedMesh, Mesh, ShaderMaterial } from 'three'; import type { Object3D } from 'three'; import { MaterialLibrary } from '@core/scene/materials'; import { ResourceTracker } from '@core/scene/resources'; @@ -26,9 +26,9 @@ const OWNED: readonly PartId[] = [ const TRIANGLE_BUDGET = 250_000; const FRAME = 1 / 60; -function moduleUnderTest() { +function moduleUnderTest(materials = new MaterialLibrary()) { return createMagnetModule({ - materials: new MaterialLibrary(), + materials, textures: createSceneTextures(), tracker: new ResourceTracker(), }); @@ -92,4 +92,20 @@ describe('magnet module', () => { expect(frames).toBeGreaterThan(0); expect(magnet.update(FRAME, 1)).toBe(false); }); + + it('dims the gradient glow with the rest of the scene when another part has focus', () => { + const materials = new MaterialLibrary(); + const magnet = moduleUnderTest(materials); + const glows: ShaderMaterial[] = []; + magnet.root.traverse((object) => { + if (!(object instanceof Mesh) || !(object.material instanceof ShaderMaterial)) return; + if (materials.groupOf(object.material) === 'gradientX') glows.push(object.material); + }); + const [glow] = glows; + const tone = glow.uniforms.tone.value.clone(); + materials.setEmphasis('gradientX', 0); + expect(glow.uniforms.tone.value.equals(tone)).toBe(false); + materials.setEmphasis('gradientX', 1); + expect(glow.uniforms.tone.value.equals(tone)).toBe(true); + }); }); From 97ffb224c1dd62b308f3d717a9feba5b657e60a5 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 10:55:42 +0200 Subject: [PATCH 58/82] fix(mri-scanner): sit the cover trim band flush at the cutaway --- explainers/mri-scanner/src/scene/parts/magnet/cover.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/explainers/mri-scanner/src/scene/parts/magnet/cover.ts b/explainers/mri-scanner/src/scene/parts/magnet/cover.ts index 70a9753f..df366117 100644 --- a/explainers/mri-scanner/src/scene/parts/magnet/cover.ts +++ b/explainers/mri-scanner/src/scene/parts/magnet/cover.ts @@ -20,7 +20,7 @@ const FLATTEN_FROM = 0.93; const FRONT = { joint: 0.58, mouth: 0.53, face: 0.85, rim: 0.86, corner: 0.66 } as const; const BACK = { joint: -0.64, mouth: 0.43, face: -0.85, rim: 0.9, corner: -0.7 } as const; const LIP = 0.004; -const BAND = { inner: 1.045, outer: 1.057, from: 0.4, to: 0.6, corner: 0.004 } as const; +const BAND = { depth: 0.008, lift: 0.001, from: 0.4, to: 0.6, corner: 0.002 } as const; const ACCENT = { inner: 0.585, outer: 0.6, depth: 0.007 } as const; const PLINTH = { height: 0.32, inset: 0.05, segments: 3, radius: 0.03 } as const; @@ -65,7 +65,8 @@ function coverProfile(): Profile { } function bandProfile(): Profile { - const loop = rectLoop(BAND.inner, BAND.outer, BAND.from, BAND.to); + const outer = MAGNET.radius + BAND.lift; + const loop = rectLoop(outer - BAND.depth, outer, BAND.from, BAND.to); return { outer: roundCorners(loop, BAND.corner, 2), holes: [] }; } From 0caa431abb398df84ca68e61524d40cf0fd11088 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:02:38 +0200 Subject: [PATCH 59/82] fix(mri-scanner): hold the radio and echo rings still while paused --- .../src/scene/parts/effects/effects.dom.test.ts | 17 ++++++++++++++++- .../src/scene/parts/effects/effects.ts | 2 +- .../src/scene/parts/effects/pulses.ts | 4 ++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts b/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts index 0dad0f80..b4272a5e 100644 --- a/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts +++ b/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts @@ -117,7 +117,7 @@ describe('effects module', () => { expect(module.update(FRAME, 1)).toBe(true); }); - it('keeps the pulse rings travelling and eases the slab in during excite', () => { + it('holds the pulse rings still while paused', () => { module.setState( stateWith({ sequence: { phase: 75, step: 'excite', rf: 90, gradient: 'z', gradientLevel: 1, echo: 0 }, @@ -126,6 +126,20 @@ describe('effects module', () => { const ring = module.root.getObjectByName('rfWave')?.children[0]; expect(ring?.parent?.visible).toBe(true); const before = ring?.scale.x; + expect(settle(module)).toBe(false); + expect(ring?.scale.x).toBe(before); + }); + + it('keeps the pulse rings travelling while playing and eases the slab in during excite', () => { + module.setState( + stateWith({ + playing: true, + sequence: { phase: 75, step: 'excite', rf: 90, gradient: 'z', gradientLevel: 1, echo: 0 }, + }), + ); + const ring = module.root.getObjectByName('rfWave')?.children[0]; + expect(ring?.parent?.visible).toBe(true); + const before = ring?.scale.x; expect(settle(module)).toBe(true); expect(ring?.scale.x).not.toBeCloseTo(before ?? 0); expect(module.root.getObjectByName('sliceSlab')?.visible).toBe(true); @@ -134,6 +148,7 @@ describe('effects module', () => { it('keeps the echo rings travelling only while the echo sounds', () => { module.setState( stateWith({ + playing: true, sequence: { phase: 535, step: 'echo', rf: null, gradient: 'x', gradientLevel: 1, echo: 1 }, }), ); diff --git a/explainers/mri-scanner/src/scene/parts/effects/effects.ts b/explainers/mri-scanner/src/scene/parts/effects/effects.ts index 54fe5811..327b3ec4 100644 --- a/explainers/mri-scanner/src/scene/parts/effects/effects.ts +++ b/explainers/mri-scanner/src/scene/parts/effects/effects.ts @@ -58,7 +58,7 @@ class EffectsModule implements SceneModule { update(deltaSeconds: number): boolean { const flowing = this.fieldLines.advance(deltaSeconds); const turning = this.voxel.advance(deltaSeconds, this.playing); - const easing = this.pulses.advance(deltaSeconds); + const easing = this.pulses.advance(deltaSeconds, this.playing); return flowing || turning || easing; } } diff --git a/explainers/mri-scanner/src/scene/parts/effects/pulses.ts b/explainers/mri-scanner/src/scene/parts/effects/pulses.ts index 23cd53b0..c6e11799 100644 --- a/explainers/mri-scanner/src/scene/parts/effects/pulses.ts +++ b/explainers/mri-scanner/src/scene/parts/effects/pulses.ts @@ -122,8 +122,8 @@ export class Pulses { this.glowTarget = SLICE_STEPS.includes(sequence.step) ? 1 : 0; } - advance(deltaSeconds: number): boolean { - const travelling = this.rf.group.visible || this.echo.group.visible; + advance(deltaSeconds: number, playing: boolean): boolean { + const travelling = playing && (this.rf.group.visible || this.echo.group.visible); if (travelling) { this.rfTime = wrap(this.rfTime + deltaSeconds * RF_RINGS.speed); this.echoTime = wrap(this.echoTime + deltaSeconds * ECHO_RINGS.speed); From f6ff57e17193188683618f949497e1fd39f6fada Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:13:15 +0200 Subject: [PATCH 60/82] fix(mri-scanner): frame the chapter views on the real scene --- explainers/mri-scanner/src/model/layout.ts | 10 ++++++--- .../mri-scanner/src/scene/cameraViews.test.ts | 21 ++++++++++++------ .../mri-scanner/src/scene/cameraViews.ts | 22 +++++++++---------- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/explainers/mri-scanner/src/model/layout.ts b/explainers/mri-scanner/src/model/layout.ts index f6eed7d5..db0783ad 100644 --- a/explainers/mri-scanner/src/model/layout.ts +++ b/explainers/mri-scanner/src/model/layout.ts @@ -127,15 +127,19 @@ export const MAIN_FIELD_ARROW = { }; export const REGIONS: Readonly> = { - room: { x: ROOM.x, y: [FLOOR_Y, CEILING_Y], z: ROOM.z }, + room: { + x: [-MAGNET.radius, ROOM.x[1]], + y: [FLOOR_Y, MAGNET.top], + z: [-MAGNET.halfLength, ROOM.z[1]], + }, scanner: { x: [-MAGNET.radius, MAGNET.radius], y: [FLOOR_Y, MAGNET.top], z: [-MAGNET.halfLength, MAGNET.halfLength], }, layers: { - x: [ISOCENTRE[0], MAGNET.radius], - y: [ISOCENTRE[1], ISOCENTRE[1] + MAGNET.radius], + x: [ISOCENTRE[0] - BORE.radius, MAGNET.radius], + y: [ISOCENTRE[1] - BORE.radius, MAGNET.top], z: [-MAGNET.halfLength, MAGNET.halfLength], }, voxel: { diff --git a/explainers/mri-scanner/src/scene/cameraViews.test.ts b/explainers/mri-scanner/src/scene/cameraViews.test.ts index 3d915cb3..2515a58c 100644 --- a/explainers/mri-scanner/src/scene/cameraViews.test.ts +++ b/explainers/mri-scanner/src/scene/cameraViews.test.ts @@ -62,13 +62,20 @@ describe('camera views', () => { expect(x).toBeGreaterThan(Math.max(Math.abs(y), Math.abs(z))); }); - it('turns more end-on on a phone so the rings and arrows stay large', () => { - (['cryostat', 'voxel'] as const).forEach((view) => { - const ratio = (variant: StageVariant): number => { - const [x, , z] = CAMERA_VIEWS[view].direction[variant]; - return x / z; - }; - expect(ratio('phone'), view).toBeLessThan(ratio('desktop')); + it('turns more end-on on a phone so the cryostat rings stay large', () => { + const ratio = (variant: StageVariant): number => { + const [x, , z] = CAMERA_VIEWS.cryostat.direction[variant]; + return x / z; + }; + expect(ratio('phone')).toBeLessThan(ratio('desktop')); + }); + + it('looks at the voxel arrows side-on from the +x side', () => { + eachVariant((variant) => { + const [x, y, z] = CAMERA_VIEWS.voxel.direction[variant]; + expect(x).toBeGreaterThan(z); + expect(x).toBeGreaterThan(y); + expect(z).toBeGreaterThan(0); }); }); diff --git a/explainers/mri-scanner/src/scene/cameraViews.ts b/explainers/mri-scanner/src/scene/cameraViews.ts index cf239db8..314730e2 100644 --- a/explainers/mri-scanner/src/scene/cameraViews.ts +++ b/explainers/mri-scanner/src/scene/cameraViews.ts @@ -18,32 +18,32 @@ export function stageVariant(width: number): StageVariant { export const CAMERA_VIEWS: Readonly> = { room: { region: 'room', - direction: { phone: [0.5, 0.5, 1], desktop: [0.75, 0.45, 1] }, - margin: 0.85, + direction: { phone: [0.08, 0.45, 1], desktop: [0.08, 0.4, 1] }, + margin: 1.05, }, cryostat: { region: 'layers', - direction: { phone: [0.3, 0.25, 1], desktop: [0.5, 0.35, 1] }, - margin: 1.5, + direction: { phone: [0.5, 0.5, 1], desktop: [0.55, 0.45, 1] }, + margin: 1.35, }, voxel: { region: 'voxel', - direction: { phone: [0.12, 0.08, 1], desktop: [0.3, 0.15, 1] }, + direction: { phone: [1, 0.6, 0.35], desktop: [1, 0.6, 0.35] }, margin: 1.35, }, coil: { region: 'bore', - direction: { phone: [0.4, 0.35, 1], desktop: [1, 0.55, 0.75] }, - margin: 1.15, + direction: { phone: [0.25, 0.7, 1], desktop: [0.25, 0.7, 1] }, + margin: 1.7, }, gradient: { region: 'bore', - direction: { phone: [1, 0.35, 0.6], desktop: [1, 0.25, 0.2] }, - margin: 1.1, + direction: { phone: [1, 0.6, 0.25], desktop: [1, 0.6, 0.25] }, + margin: 2.2, }, console: { region: 'console', - direction: { phone: [-1, 0.15, 0.3], desktop: [-1, 0.3, 0.45] }, - margin: 2, + direction: { phone: [-0.8, 0.45, 0.9], desktop: [-1, 0.3, 0.45] }, + margin: 1.5, }, }; From 5c3c5a4c10eb71e7d34ea29fc769cd3ee7d8c868 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:31:11 +0200 Subject: [PATCH 61/82] fix(mri-scanner): let labels see through the room shell --- explainers/mri-scanner/src/scene/parts/room/shell.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/explainers/mri-scanner/src/scene/parts/room/shell.ts b/explainers/mri-scanner/src/scene/parts/room/shell.ts index 423e6594..17804dd9 100644 --- a/explainers/mri-scanner/src/scene/parts/room/shell.ts +++ b/explainers/mri-scanner/src/scene/parts/room/shell.ts @@ -53,5 +53,6 @@ export function shellMesh( const mesh = new Mesh(context.tracker.track(geometry), shellMaterial(context, group, finish)); mesh.name = 'roomShell'; mesh.raycast = () => undefined; + mesh.frustumCulled = false; return mesh; } From d22ab5c8182b22d3c80d3cf089f5a6aedcde5400 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:31:11 +0200 Subject: [PATCH 62/82] fix(mri-scanner): place the bore and patient labels where they show --- explainers/mri-scanner/src/scene/parts/magnet/magnet.ts | 2 +- explainers/mri-scanner/src/scene/parts/subject/subject.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts b/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts index aac59f8a..7189b404 100644 --- a/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts +++ b/explainers/mri-scanner/src/scene/parts/magnet/magnet.ts @@ -70,7 +70,7 @@ function labelPoints(): Partial> { shims: onShimTray(), ...gradients, bodyCoil: [0, -BODY_COIL.radius, BODY_COIL.halfLength], - bore: [0, -BORE.radius, BORE.halfLength], + bore: [BORE.radius, 0, BORE.halfLength], coldHead: [0, COLD_HEAD.top, 0], quenchPipe: [0, QUENCH_PIPE.to, 0], }; diff --git a/explainers/mri-scanner/src/scene/parts/subject/subject.ts b/explainers/mri-scanner/src/scene/parts/subject/subject.ts index 10ffee89..3a2f51e8 100644 --- a/explainers/mri-scanner/src/scene/parts/subject/subject.ts +++ b/explainers/mri-scanner/src/scene/parts/subject/subject.ts @@ -299,7 +299,7 @@ class SubjectModule implements SceneModule { this.root.add(table, patient, headCoil); this.labels = new Map([ ['table', anchorAt(table, TABLE.halfWidth, TABLE.top, TABLE.z[1])], - ['patient', anchorAt(patient, 0, TABLE.top, PATIENT.feetZ)], + ['patient', anchorAt(patient, 0, blanketHeight(0, PATIENT.feetZ), PATIENT.feetZ)], ['headCoil', anchorAt(headCoil, CX, CY + HEAD_COIL.radius, CZ)], ]); this.anchors = { headCoil: anchorAt(headCoil, ...HEAD_COIL.centre) }; From cc993c3358c480632008b969c73508349a984072 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:31:11 +0200 Subject: [PATCH 63/82] fix(mri-scanner): label the picture beside the screen and the room on its floor --- explainers/mri-scanner/src/scene/parts/room/monitor.ts | 4 ++++ explainers/mri-scanner/src/scene/parts/room/room.ts | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/explainers/mri-scanner/src/scene/parts/room/monitor.ts b/explainers/mri-scanner/src/scene/parts/room/monitor.ts index f9eb78ef..c7a43d72 100644 --- a/explainers/mri-scanner/src/scene/parts/room/monitor.ts +++ b/explainers/mri-scanner/src/scene/parts/room/monitor.ts @@ -36,6 +36,10 @@ function monitorMatrix(): Matrix4 { return new Matrix4().makeRotationY(SCREEN.yawTowardTable - QUARTER).setPosition(...SCREEN.centre); } +export function screenSideEdge(): Vector3 { + return new Vector3(SCREEN.width / 2 + MOUNT.bezel, 0, 0).applyMatrix4(monitorMatrix()); +} + function wallArm(matrix: Matrix4): BufferGeometry[] { const { depth, hump, arm, plate } = MOUNT; const back = depth + hump.depth; diff --git a/explainers/mri-scanner/src/scene/parts/room/room.ts b/explainers/mri-scanner/src/scene/parts/room/room.ts index 2b51107c..f2995c54 100644 --- a/explainers/mri-scanner/src/scene/parts/room/room.ts +++ b/explainers/mri-scanner/src/scene/parts/room/room.ts @@ -27,7 +27,7 @@ import type { PartContext, SceneModule } from '../context'; import { mergeParts, partMesh } from '../context'; import { farSideMaterial, farSideMesh } from './farSide'; import type { WallSide } from './farSide'; -import { monitorGeometry, pictureTexture, writePicture } from './monitor'; +import { monitorGeometry, pictureTexture, screenSideEdge, writePicture } from './monitor'; import { ALWAYS_SHOWN, shellMesh } from './shell'; import type { ShellPiece } from './shell'; import { displayTexture, floorTexture, glassSheenTexture, wallTexture } from './surfaces'; @@ -38,6 +38,7 @@ const DISPLAY_FILL = { width: 0.92, height: 0.86 } as const; const DESK_SPAN = { top: 0.9, panel: 0.85 } as const; const [X0, X1] = ROOM.x; const [Z0, Z1] = ROOM.z; +const ROOM_LABEL_POINT = [X1 / 2, FLOOR_Y, CONTROL_WINDOW.centreZ] as const; const WINDOW_SIDE: WallSide = { normal: [1, 0, 0], offset: X1 }; const WALL_SIDES: readonly WallSide[] = [ { normal: [0, 0, -1], offset: -Z0 }, @@ -262,8 +263,8 @@ class RoomModule implements SceneModule { this.root.add(room, screen); const screenAnchor = anchorAt(screen, ...SCREEN.centre); this.labels = new Map([ - ['room', anchorAt(room, X0, FLOOR_Y, Z0)], - ['screen', screenAnchor], + ['room', anchorAt(room, ...ROOM_LABEL_POINT)], + ['screen', anchorAt(screen, ...screenSideEdge().toArray())], ]); this.anchors = { screen: screenAnchor }; } From 992f09f2ca789820dea593c8fcd8bd6ea13a5c8c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:31:11 +0200 Subject: [PATCH 64/82] fix(mri-scanner): frame the room closer and pull back the cryostat view --- explainers/mri-scanner/src/scene/cameraViews.test.ts | 10 +++++++++- explainers/mri-scanner/src/scene/cameraViews.ts | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/explainers/mri-scanner/src/scene/cameraViews.test.ts b/explainers/mri-scanner/src/scene/cameraViews.test.ts index 2515a58c..44bc485e 100644 --- a/explainers/mri-scanner/src/scene/cameraViews.test.ts +++ b/explainers/mri-scanner/src/scene/cameraViews.test.ts @@ -9,7 +9,7 @@ import { CAMERA_VIEWS, STAGE_VARIANTS, stageVariant } from './cameraViews'; import type { StageVariant } from './cameraViews'; const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; -const TABLE_END_VIEWS: readonly CameraView[] = ['room', 'cryostat', 'voxel', 'coil']; +const TABLE_END_VIEWS: readonly CameraView[] = ['cryostat', 'voxel', 'coil']; const SLOPES: Readonly> = { phone: { vertical: 0.75, horizontal: 0.38 }, desktop: { vertical: 0.42, horizontal: 0.75 }, @@ -57,6 +57,14 @@ describe('camera views', () => { ); }); + it('looks across the table toward the monitor wall in the room view', () => { + eachVariant((variant) => { + const camera = cameraOf('room', variant); + expect(camera.x).toBeLessThan(0); + expect(camera.z).toBeGreaterThan(MAGNET.halfLength); + }); + }); + it('looks into the cutaway along x for the gradient shells', () => { const [x, y, z] = CAMERA_VIEWS.gradient.direction.desktop; expect(x).toBeGreaterThan(Math.max(Math.abs(y), Math.abs(z))); diff --git a/explainers/mri-scanner/src/scene/cameraViews.ts b/explainers/mri-scanner/src/scene/cameraViews.ts index 314730e2..3d67c6e0 100644 --- a/explainers/mri-scanner/src/scene/cameraViews.ts +++ b/explainers/mri-scanner/src/scene/cameraViews.ts @@ -18,13 +18,13 @@ export function stageVariant(width: number): StageVariant { export const CAMERA_VIEWS: Readonly> = { room: { region: 'room', - direction: { phone: [0.08, 0.45, 1], desktop: [0.08, 0.4, 1] }, - margin: 1.05, + direction: { phone: [-0.25, 0.3, 1], desktop: [-0.45, 0.25, 1] }, + margin: 0.95, }, cryostat: { region: 'layers', direction: { phone: [0.5, 0.5, 1], desktop: [0.55, 0.45, 1] }, - margin: 1.35, + margin: 1.55, }, voxel: { region: 'voxel', From 49c73db53a564f19fe2718a1406ec43e7330f946 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:34:35 +0200 Subject: [PATCH 65/82] feat(mri-scanner): add the cover and social images --- explainers/mri-scanner/public/cover.webp | Bin 1246 -> 31926 bytes .../mri-scanner/public/social/og-image.png | Bin 3160 -> 106000 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/explainers/mri-scanner/public/cover.webp b/explainers/mri-scanner/public/cover.webp index 1b50f2e35f2528eb41b43bb88d4c6b8e11e1b6ec..28f1569c76094f21277292f8ec039a61c2e424fa 100644 GIT binary patch literal 31926 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 UrGv#k28MtCpGlO-IWSNR0Ehpa)Bpeg diff --git a/explainers/mri-scanner/public/social/og-image.png b/explainers/mri-scanner/public/social/og-image.png index 9b9accebe38152e54a7c5abdfaa5c4775e8653a5..13d65ac54fa2f4ba26a0b74eb4ae1facb21a6133 100644 GIT binary patch literal 106000 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�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

)sQ-Uo-1Yx~4Eg=3d4WPJ8 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 {vg?(eYIWo{#W-2KF9$i00Bk@P4i{C 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 WhJ5SaU%TZr;A3ul#i$yAi~c`TFFGIq literal 3160 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz*NS;1Qa=-^oE;(fk)oc#WAGf*4yhFIT;u@ zST??QE%=`~_uwrDwH@z*Y=P=*M1T?uJc>Y~O#nzZb1*P8D6#+z5C9sJz`@ACV9){- zmvEw{GCEoXcDVBjpl4|BE!04wX$YcP1&+1^j!NkEyOFAky kA0c(}Xq%4GsxnV(^^^{llE3%P0vnDDp00i_>zopr00-DtyZ`_I From 1fe33b1a6e2d219bd8a72ae5ef47f904d3bf91ef Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Tue, 6 Oct 2026 11:18:54 +0200 Subject: [PATCH 66/82] fix(mri-scanner): polish the English copy --- explainers/mri-scanner/chapters.html | 182 +++++++++++++------------ explainers/mri-scanner/locales/en.json | 44 +++--- 2 files changed, 116 insertions(+), 110 deletions(-) diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html index 9571d35e..598c0869 100644 --- a/explainers/mri-scanner/chapters.html +++ b/explainers/mri-scanner/chapters.html @@ -4,31 +4,32 @@

A tunnel that listens to your body

- This is an MRI scanner with a 1.5 tesla magnet. MRI stands for magnetic - resonance imaging. The tesla measures how strong a magnetic field is. You lie - on the table, and it slides you into the bore, a tunnel about - 70 cm wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes. + This is an MRI scanner. MRI is short for magnetic resonance imaging. Its magnet + has a strength of 1.5 tesla. The tesla is the unit for how strong a magnetic + field is. You lie on the table, and it slides you into the + bore, a tunnel about 70 cm wide. The machine around it is about 1.6 to 1.9 m + long and weighs 3 to 6 tonnes.

For a brain scan, a head coil sits around your head like a cage. It is an - antenna with 20 to 64 small receivers that listen for a faint radio echo from your body. The - room is lined with copper sheet. It keeps outside radio waves from drowning that echo. There are - no X-rays here. The scanner's radio waves carry about 50 million times too little energy to - knock an electron out of an atom. + antenna with 20 to 64 small receivers. They listen for a faint radio echo from your body. The + room is lined with copper sheet, so outside radio waves cannot drown out that echo. There are no + X-rays here. The scanner's radio waves carry about 50 million times too little energy to knock + an electron out of an atom.

- Under the white cover, layers sit one inside the other like rings. From the + Under the white cover, the parts sit one inside the other like rings. From the outside in, you find the magnet in its cold jacket, three gradient coils and - the body coil, a radio antenna. Switch on the cutaway (C) to see them. The next - chapters take them one at a time. + the body coil, a radio antenna. Switch on the cutaway (C) to open a wedge of + the magnet and see them. The next chapters take them one at a time.

- The model follows one repetition, the short cycle a scan runs again and again - to build one picture. In this model one repetition lasts 0.5 to 2.5 s of real time, but the - radio pulses inside it take only milliseconds. So the timeline is not to scale. The few - milliseconds of pulses and echo fill about two thirds of it. The long rest is squeezed into the - last third. The timeline names the step, and the live readings show the real time. A whole head - exam, with many pictures, takes about 30 to 45 minutes. + The model plays one repetition. That is the short cycle a scan runs again and + again to build one picture. Here one repetition lasts 0.5 to 2.5 s of real time, but the radio + pulses inside it take only milliseconds. So the timeline is not to scale. The few milliseconds + of pulses and echo fill about two thirds of it, and the long rest is squeezed into the last + third. The timeline shows which step is playing, and the live readings show the real time. A + whole head exam, with many pictures, takes about 30 to 45 minutes.

@@ -47,33 +48,36 @@

A magnet that never switches off

The biggest part is the main magnet: about 30 to 40 km of niobium-titanium wire - wound into coils. Cooled far enough, this wire becomes superconducting, which - means current flows through it with no resistance at all. Several hundred amperes go round and - round in a closed loop. Once the loop is closed, holding the field takes no power. It fades by - less than a tenth of a percent in a year. + wound into coils. When this wire is cooled far enough, it becomes + superconducting. That means current flows through it with no resistance at all. + Several hundred amperes run round and round in a closed loop. Once the loop is closed, keeping + the field going takes no power. The field fades by less than a tenth of a percent in a year.

To stay superconducting, the coils sit in liquid helium at 4.2 kelvin (K), - about −269 °C. That is only about 1.5 K warmer than deep space. A classic magnet holds about - 1,500 litres of it, while newer sealed magnets need 7 litres or even less. A heat shield and a - vacuum around the helium vessel work like a thermos flask. A small fridge on top, the - cold head, turns boiled helium back into liquid. It runs day and night and - draws about 6 to 7 kW. + about −269 °C. Kelvin is a temperature scale that starts at absolute zero, the coldest anything + can get. So the helium is only about 1.5 K warmer than deep space. A classic magnet holds about + 1,500 litres of it, while newer sealed magnets need 7 litres or even less. Around the helium + vessel, a heat shield and a vacuum keep the warmth out, like a thermos flask. The open wedge + shows these layers. A small fridge on top, the cold head, turns boiled-off + helium back into liquid. It runs day and night and draws about 6 to 7 kW.

- The field is tens of thousands of times stronger than Earth's. Earth's own field differs from - place to place. Pick 3 T to compare a stronger magnet. Extra shield coils on - the outside carry current the other way and pull in the field that leaks out. Even so, the field - stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that - zone, a pacemaker may no longer work safely. The dashed ring on the floor marks its edge. + The field is tens of thousands of times stronger than Earth's. The exact number depends on where + you are, because Earth's own field differs from place to place. Pick 3 T to compare a stronger + magnet. The lines around the magnet show the shape of its field. Extra + shield coils on the outside carry current the other way and pull back the field + that leaks out. Even so, the field stays above 0.5 mT, half a thousandth of a tesla, up to about + 4 m along the tunnel. Inside that zone, a pacemaker may stop working safely. The dashed ring on + the floor marks its edge.

- Since the magnet never switches off, a lit sign at the door says so. Anything with iron in it - can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check - everyone and everything at the door. If the wire warms up somewhere, it stops superconducting - and the current turns into heat. This is a quench. The field drops to zero in 2 - to 5 seconds. The boiling helium roars out of the building through the - quench pipe. + The magnet never switches off, so a lit sign at the door warns that it is always on. Anything + with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is + why staff check everyone and everything at the door. If the wire warms up somewhere, it stops + superconducting and the current turns into heat. This is called a quench. The + field drops to zero in 2 to 5 seconds, and the boiling helium roars out of the building through + the quench pipe.