- 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
>
-
+
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
zcmV(zK<2+vNk&Gld;kDfMM6+kP&go>d;kD2PXe6*Dx?Fu0zOeFkVd1Tp`oHv={Rr-
z31@EG?Pj37&Q7}BgQKyd4&&b4*SdaJyR^U5tMLw;K4iw9PXCtv?(P?J|5MNpj(gDf
zXW+c#|KjnhwEW?E5C0$dU(D~Gf9?Nhed7Og|2zMe{2#zqoxk|Mx4!DXc|Aq{S^sD1
z-T#;OC*6{(Q`d_;r{QdC%|Nrp!
z0RR8(+tR<~Kkz^1dT{yg@_+UGUGpRG58Xd<|HFH+|GVWk*8ji%)#!Kf&&c1n{}21I
z{zvBj_MQj+!TkU1r?5xn|JlFd`waazY_+caoAwCrpZ&i^zqIpf_b>FH_`l110e?yU
zss9)I@1Adr2j0Kr`vLz)|269u{-^(M_Lr8f2a5ErHB
zi`=Gy=&ta85c#O=Kwz!O(^)GE_j)^=Th)gfxX?$C+ku&7pq#zRyTDD0W
zJbsDcP_O@zP|VD_BBisaVAdmH+E>iC3G
znw5l5DXK9$L=VNbVMvIthq2^`xRyJg)XF#_X9T*#Fv0JCt~U%#$ckzt4qes1AE`2ttsfy9Ob-2^z1SbrBJ
z<}z80;5ynTA?SUQs3(yj-wGq8UH_zSqu++?QGSgzV1D1l_YFd6Y~MX&uN!#M4I^uY
z^M?0Vy*XNM3e$$u<}xitE%rW5GQPmRU}VP%0+eybne36+3;&hNjy8yq^#+n*8$O^L
zrUfd!P4TsJhPM;7gCc#-#9N{r{hzQEE&$6wTcQLyT#uSveuO&)pIny8AD_)b_TZUaZ_qavPH-hr2
zq1}ZI!<01lpN_^DcwQEHug{fLC7TmA>8n(e&Vb%lW#F-tsNKbBhrI(W<7?#N<@3Bi
zcG?=&G^!bJb4VV9eKVPq7nQ%V!_WVqgdZ^o0=zE8YBi(Vdr02HwT
zkgu=Xum}Y=weqMqkDHhpHXoqiSQkPPOLUm-
z&E}ec>(z*zDg9RB1iv`wuswu^CN~nz{V0;Z3t$8xp
zU53JH`3sz=U=aq}_B=6^)Z#pXiP^%4PPBQiARR`keBLOM)zv~__*vycDL&uG1dY_%G8%d$
zE}J(z8fp$u&XIGzf&tJ%5M#6EomTdgIDVJ*0_+gmi8xO6z%gF)Gvy@%cSO4_sG{p0
zRF2k~sXL0krc5s&hi+@eykovyzhJ0sT30Zh5rA@8-hE)+%df?P@HwsZA;FtwDY7gs
z0fxd2<`<2Ck|VKz8R576ri*{I4wklEAm$%PngM=NT1^glFcYig0qw2>v+Y8J2+KcA
zPkj^0e4yy_;@ZQfGa&Ro0PYl8nh=Q@6mpXvKO_SPzp?AtnurIt*G=;=^fK7i&^W$+
zwai(WNOe21_&diCE*@dAjE0pk)Nqjs``vAO#%*CW7#CJW<9|!uYSw=I7l>_i0L{1#
zA8j6DWszJT#4)>6@`@(0&YNpZ+%8u9We9|M
zr{7bxw`HY8tyLFqRmHqpcMPQ#)Pf1elVgnC@2Ag(-bY$$w7z5{|h3-5b$ju8iR6}Wk+)n1Fo3Qa^lVb2N)AAC
z2rYFuA=u>5q9h?v${E93mfvU_)=Wcm9z?l&h$r*!r2T{LJYA%9a$^mfKdEdllzt6<
zW-~>G2`uxVRp>zhZW9DM5C7U2r>v#Q(
zx7&|(1_an#5$tGu_#HW%nIm@*{f!9=H=ESx@8Qe@AJj1}AQLv(RubTy&H_b`m9S4=
zTv!aX+`F#H(3UPdYE0m3Ip22FazTbk5|U(d#!^o4mzCAL0fI|=8l@&4+ko-&)HWNh
zgECWp9(p=yxw5{i5PtI|2E9?eR(Cb!gp+3}oq}NpH+JZxkf#GBd&2vgW%4)CrIK`g
zjGd4DUVXb}K!T5pPt}dwmG4PE;H(T>pd(8@rvE|X7v63e!Oh(H8{gYbmcLG%0~z{5
zlw#|+{{pC1chgOj(1<6PUInUrPo>p-EBK^iWayg3pGC&&Yj%jB-*<0nw{dUU4E`WeljpJe+k9KB?JH^_F
zbf)|7V1;GOcmyrvEpT(7Y%?6JF2@nu=c)|$*)Ao0F7}(d&GLmLPbH4^_bRWfoZ2^S
zVMVTpocsd!t$%=
z4+#E15|^r1G?jCZ{u5yR3bss6?M4gL&3l@s%5Sify3=0OXUM_V5!h6z<_{n}PIeZ8
z!Tc|>jXFrLotaxA6i&fd3UQNcm5q+RbX)}k?NmO+S259Zy9rGMAJ{k~1l&SLQ61Xt
ziwk#*iM)(i2X=Gqq_dF8`~POsd(b}d4hX0g4cV8LS0+8iLMT(1+uK8uk{oDkT1N&^
zE>rq_LN!+^jG@_*Zjf4^zWHoFX-|7gFDp&Ut7hp`X4;BF-LhOkaIO^*s6=
zJD`2D7R`iIVIYeg93-rPR9b`Wfji~7p1G(pX{kAmp4&?oV$>5LPe=Oq#IYMM
ziYe2$o=kGMD?T1|->(DK(U>Yc*ho-y6RnH7p6kuD{qqsp^AP?*&@G(A?n{D#$pqoPsqmSww2%#U-v+`->)&C5$Pbv{cgX2
zmec9leZMbLy4%IT$Lp9K+ZhU8#+vPSpt|O`21vtl8fqN!2imBBbPzyJ)_q84r{|ve
z^=kLTCzko`%q%9_T?xZ&5Auw+SPpnWch#HeHDY?d5rW@1ekbzt9n7ap8-dH^i(Qb!Ip58M!6#nov$LBPDG
z0N{z%)Kjd~n3%Y%ncneK$^VUV=*Don(tD+6MQ4_4
zIf~tkV7~`vJBGEL0c_f)#q40A?jy$y}q4f)KR}3$(b%PqaTk@}g4)
ziAX-l*Kjy8hS~hAQ@2~eP3XrdN60~*lb$E_jx_E|b$RDxDAmGiZn)485nddhJi%Y0U)0nTuU+F0;%hhn`$XkW?_KPKQ6T!3As57o!D8>Z!4Fr*~ye_=D}uz
zYUYzb2TCdaf;!Qk{D2M>SG#s?k-Y?uENz~4OI)uR;24Mz0iRPjPF(T5Q$)%RxGNZ^
zl0l737Nrjs%@=H11E7L1QE^cR)GI`~y;9%@5!AiI<>BJt_No*F!bU%pEO6`+s<9GC
z9?)t-?bGzUarc&fL@WKRnJ$7A{OBZmUCW+87Ym8YfB^pdT4R#7a0a96LfUnr2A&4<
zs0+b7x>9zwTb9rxQU4J<@Iv0N><0?dN34P{`$?1;iW;MGn@cmKMq$)(kUiKZJhr=<
zd8`8B5Xs@ySUGBR361g_6&K+bE{x+`;WVO-rxs2<|yH=~4Vi^{MZ`NBipbIR(8TK^sNS3-^K
zgYV-vvq_)2K7Np5&wUGH57W>!jv9|uLe9fV2gP9_?oPemMU}e0>G4skLW}dgeV1(*
zf?xa>t90Kq&c4@-qFV>72FgV{4rKLu0T~A?X&u1J3^X1uH5LBiHORY4vacH=-2TjA
zwRPUIuJ1ynD;N}FhR2JX&Q_{Fhr!t7m~b2Uhr)`eS;BCI2IF$q<7mA?2?zmUYtBu~
z6NYEKxv)n?1uY_a*3hpbS
zEP$PW;EcCRRr~3+}b0H8>nOp^P
zx%6a4=e8;8d^&!(SCJJdd&jUe*V6j3MZ}Dd4sorXiwmrOM{cXz@|Ei;_ZbMlZBd*2;~nZ=;V*bmVQ&*MXQS4
zcFAO%?8M56F^s$HG2?XX@l(+S))}pKAociIm>I%i*cb4p(mV8c$JCz{Pym+bY7Dl~
z-FHK+!}M(1Y@F>Y?hUgc|H*qQ!oF3DQv!qgx=#u@@^~|mQ*HG9H}s|;@rWZ9_ymVT)PhjBg$&U1bTK?T?LY-si(zg{=5IJO*JEeeTWg~ff)AdwtS2pye`
zkY9?xr^9t;vs5
zdY6F3(Kwm0EJ>YD0N9hlbogu?C!!!pQf<}TAA|-uEXCEcV48nHq!*GL0O2+k&Mq+;
z8wjf$Ya|H#-T#hOkyTJHwu@9XGKD^4IQi5Tze)ZVA0yL=QJmbcC6*zrqe6t~>IM-YYT?Te
zisENfGmgdu2H8%%v`3d*j=I-?nUsS19cuhoPg984((aP0{V}o$A36(uAngsdY;q_z
zdU4U@`PkzF?}P=ljdU20UMJ!%pp~sJ>tv>!?tTa`TXtffkm1v{42F_K8I_B4h3M0Y
z<44VTo}?q(TTuBkn?cgiPT=J|k6i;SDG)R9enJJqSbGt;M^2ts?T
zqYCdS{|{K2{R0NdlLt?bdL0N_#UVcyhL^0p`Tj$)d61t^IfC9(`83~6ASFVDU^QXD
z9!hJhmmRteAOq%z>JpReMDY{XfFn@3-N}y#G21jX0l$|O^;n`LZ!0i;kiR12WFBbK7g!;dH@ln9k@$i7eG+?TL7X-M_R~KQ`(LL@
zSmM`<32-B@fDBes?iAT!J;tJ~h-Pe*K}Yn==FvC&pV~7IB@zPd&@AP?FwRQ>5kfa3
z(MuonNBq{m)j9ZgdA0(ftq(|x=*v()%MEhV%=!UZ2AY2V_suWSm
zmW^r?#ykF``T~y{GBZl^VYeG}-YFAqJStZa#VYo~+?)o^`EY9#gI_Vbso=JQW$hk%
z5MscjVl>oC`Y>!_^Hm0z+XHB67vggS%TivQi#tRbmXjFb>
zYIu8vK6NBMG~fJH#KR~at*x>0mQmZs4+4zpI8kGNCPBRl=Q0;69A`D>*mvXFBl0mm
z=wO_DgMj6|LSexjzj((!7DirDxmdD8Ber5Tb|m)sH3q>}tq5yQF{c!H7$scMZwu$o
zW0g#R26_J>hMMIo)S?EG+OFpQex{f?d_&hx{=(hw-J+jJh*H)qWOUw64qhnqe&VNB
zy@$NqJUR`9D;)LB0f>Sc5Sb>AXlBPuN$e_lQ_)k_Jw^8?&eWLQZGWw9|8i1rQX6
zM^?qvWDsY+9L%~rDU7dgR_ASwqMaFE3&5TK_P;?mDx{e=Hszh961w`C3oZhUvSUC5
z#6O&HlDbpLarZBx){bt~2dA|-=5zf?3W*7^`A-xBs~P+oKNMbW==7qhp(dC~N$|(k
z_8UA^8`B!SE8kggC!*w>WE4V;bb!WB89)!1k!4^el@uSqc!OIVhBay7nuZoXQx-vmFb9T7cWha>5n8mFk0Gx;ht8sF&7_QvLz^w
z1FzSJ{pJ$teFYeO({nj~iZ=IZCr%-N^+d|pcJ`!J9p74>ovj8dSz
zZi6L^hh89Zx7^e^vX^L)h?hTCXvpKtfW>d)WAPBM`Vc%jAJUe6ZR*2FYy>)*20cep
z@2gJJtgkg?Q}5?TNOd1{RU0-;F|wQcS2d7uH^xK%?9M<{QFVtgGkyZPIKFUo;pk49
zb#>xPF``P>CTjwH3hA`cLV1@d8G4|_`PdJeMQigf?=a8F!ZXC)#a4vPDlr}1hQC?m
z<*AZCjz{@!O(JQPA(Ci!7V_>o1I@n|Tjh3Ui|O?@ro=P#h+jTiOc&8fL_isaw&D?S
z5|*NJY|c@uRKpB_$nC0^F7o-|wsihKu(FtR*&&;HO}a0jXU;wng#sjR^Bpg7wJ@0R
zCT_N1S3}C&+}2%ZiRK(vmV0K$`IdY+C$4{fd9w80LnL6Ak50ULh&CmI*na}$BT@eA
zP{cVNz8UvawM&rB?_NjV0#2*<;vr%qnWUq%>c4M6_IKBt(i~Icz{dFA#}8kU9jSTa
z!i9qi{0r5R<}0c-IDN*w`6G*VohI3yi@WT;+RURoE=mo{GMgwBgZP0ILW4AUD@8o4
zF!I}{^oSQY$d?$PaI24}_c18&50aN-C10f@sBmJW0VozRfcOKYD
z#MU~r0YhhL{Nl1bz}bWuc8#Dn>Bis)s-cCk`XCz(!KE$vlEYiOiHez;LmeirK>d^V
zbZ?-Z6YD<|&YVU#_+u0on*%RRsfQY%!kPxS4IRX8@|yK^YWvJo0GTsDr!i4PMpjOs
z|DPW7aIXdZ3>9H67o8Vg
zs(Op@KgQ;tD$7qb_*%a8jMaCC&ujhqac()vUHvl&zh)OAm6G<~VeS!Jq(%sAY1rC+
zeCuf%Egf=)70O^A<^^y#QeM?*``q%Y{}jW|0x}vMpgxYMr!~^q+j&NGM&)3Dx#s{V
zADp{r1}Lz{O;)}ba`$A(`#QqoVDPZsfFd!ypqf+uCXgJ%=_&Wnlzi?ZfJ}
zaFR+%L(=uF(#Y1RnVXP1Zx1GBq?V1txuba0-hz39nYKMl@qb6xw?awn1(d!5{!tR*
zejYFDI>$A460Q|pZzVEUJU5IW@8I+Ur3fqn(BY9xmu>xx%(zT6LPg!v-i!|@QYPR_
zKekE7iV`OEFN!OovUZ@~n4-g+uJ%|Vo&+uJL{DqcHZ~Da&YgAa5RDfc0*w7r2BgPE
zz(>5xR0_LAz0@=k)YSK^_Ciul6B1;uK=jB)F*x}j=fG6Q!`L5l-O>R2_Ko{QZBs
z&x#9X(bq*KSat>GEsnFL)^a}A!W!}^Y(8|OZr%zbb3tYaBT&liWE6>UA;_=T>{-{8
zk?@B05BgYjNLQE+=`8$o*A!m>L%zP3Eo)3i5`OjnsV}AU1Z*9n{*j*zFCntPb~4Db
z1mpQ}kiO~C8l@k@)3b2W_Xzr^m>AfFxScWY^QVTIGY_2d3gaT&;22XT{~33%;C6KK
z=h${%Jku~tOfAL6&0~BOzxTr5t<#y75wX2fT@hGXu}BWy@>a+
zS)!X^KIbr!pFR&sd~2fp_P?ks_OQT4ayxYNr=UPMNk_%oa2;>NorEvCAGRi;f}J0w
z?*h37mCcR)$fK3SP`hvHm(-fE#(Qi3pp7zrv^b;Al8)6m1XMbe;PivY(jWRb4{EDS
zF*HnYo>ubGqGYKu0VoB`l+CN}cCi!kP?nx1By#tfq=+gDm-KbD^!X=4GMeMfMfWgc
z_RzA%YFFDy`r+ss`D^GOrzjusyuT3T=$$MUPDj<`XbM*p3=NMIy4i+TS!d29d{5c|
zU18dq#Yt)9S)*SwV2X}Ji6dqR@8y#!*>QuNfjH$H!BHK~$Pxok{%D7opOu%G<}E>w
z-*!G91R5~K!2lCCI)bgU1;XE1v`do4Keus0&==U
zK}8@#HwoN%7*etXNl$1NRH->Ac{@tAxJO`j9VFAZ7Gigrl*|TECnVnCimTZ4PQ9Bh
zSp$|y3$aktKFrCP82NO_k7ih{o4HV^-&9Np#?GxAHou+e?
z*j>%Q;NJ-Xhil?~BOeSPjJt*i32!U@RFvLhHo`VC>ywczl)FMZr*QTD5!+=6V+Zq+Q+w|ONEiT=lpYKcYx!)
za#C0aPg#Dxx^4-M0o}kNr>uv_KxB*4Jfita!En4t;N~7
z37rfRMi*tc6w`>U?{CHr4;@2`9~rcrTXF}P1-EjgxC!YjO$3P+=S{<4_*V7J
z=a6F_?H##6L^2U=`U|DwX9eRKJ2aNw5^%8=$!@M#c$)lsPq9~Ch!2tm==H1h^=If-
z4A)@*JHYd3cD-tcJaq*GiGF!BgmrOR3jLorOxu%FpF|Hz2T+~6r{98yQYr-Z=CGY%
zj*T5HcVHu3&hbc7HC}#lf^Vin5&$<%$V+${(p+lT-5>k-&jzClEdNNA8OGqd!$<}XX9QU4tUmSgu=xD
z;(_&pw=g@;2--D8Q2Wf3JK=1N5CVHn*;?1brI=dO9L!ez4Far~x5_h)BgK`2p`e<7
z0L(Z=c+{BWtZ~o@B89B-DruRGbFZ5MiBSw58uzd%5wE>H!I&U{YRQj0c7}GMYsJsd
zunO5d(>Qi+3~-hH|1pUb8MD<}u!-l`QjtEoH>9kQiE5d8=KH{?90&zF7wIUO%>l#8
z{C?(w^*=YfLJi|b7%ezGK^q(Jz-k=XeE*N|wrm{skVBccD#a5|rh6pb_Z8DThe{E`
zuflnyG`wu7mXv+*{(&*Pr{huJ5`-;62#SMlsvrN(9Vk
zs3sJPwt+L!X2VdA(opLDv}*bT49%RzL9A#NM#-*X0Wv|!?n-3TZWf6-#&F;5@y3$(
zTG7#Q8G|2nFuA$O^hdl#)hC0C&wtDmF*fTdss<8
zL=*MeYV=;Bj1#7Q6w;fwzOH6n=u3vfE%a)(Lz^4l0eHY&<_Qy)2Q_`z)1p$~E
zulutSX!?S&4~}Htbq$%Qb|JZThv8$3*@RP`
zncOC#^7VgmHcz*pNSw!A8G3WShlHG*4J!_*w$I$vgWPHYcxb2p1txV#@X$Fn+gc0h
zhV+0U@$udPT-P>Oy=OxFsTr-|_Zpl_C+c{fgiI|}V3;lE=FFN%goH1#T|1V&s>Tf`
z2teRtnYCNrVQ^fDiWnfWSSue)6_Gj^5^ObH@?P)cWt~KO9X$A`EGA?=6atGP^OnWP
zVW<4j7A$@z+~l$X>)6rb*s1z_p7$xTF`Yo`yO=kkjShqdcrVA@34
zzfsJ=*u_yOpYT7-=@5YYAy_)13)X`^x=0D96w$+^E~ngU^2jg=MhIbl0BQs
zQOMNFm@zikn2!a7{llQV;;AG_j12i5f$j
zDIPpvu#$jBZzU9Is++4q5$4~n@Zix6(~rXhTN(mW3OE)C6~w&X+UfVa9G#_`#n*>1
zF9P2!GgDu09niaqcn-d8TZ5%baBA7Pk|ZUlYl~;2zT28-a0w;WXJvKf(^7eyf&uXcte^SQAuR7XS&iXrr
zUA0b&NI#<%uBN_{^1S9z4tuPQNZ)n=;Km;=
z)x$#W029_mF2eF%RtYelV8Zq*$?7o5k&6XyDI~x-we1?U?Vxdyk@*SogREepRYNcv
zpLHY{++Dp|$YD)5_xULksP*u`M{~3H@_@Ohm5b~9=<$Y#x@IdZR=6Q~1`3OXe!_8)
z%9|ioEJ2Xq4%r1wUV~mb+a5BR6*D400}W+X-sNdk-v(hBW9H7}?lg`m4Xoiy?8AuC
z8Qhwvfn?!Z^e6fa7Ce7A)FF$&Sf7K&DoVaY&J?woxo6vnNdh*I+$bT=W|wTr_N831
z&+xY!n$4zN9pR_~;izdSnXj7VF*PLnt*Y_T(h5Na=U0&KRX4!6vgz2f&Wj*W1F7F{
zC>b2&azI}-UuNZk1cNh2R}lFX6O|aE5Ab8WvzoPR{Bi0oOnsXl_gIzObrcI7da0rh
zraH_v>rNYKhDEU0o)VWjfN>aM(bY5{=jjb3-Q)a5DVBZ$@4cg`lXC4zj`kMKDen
z=_}HbLw0nT_nwM;K21r*Ui$<$9)AzphGukn*{?-$^Sbzdmn(Fa12jS07&*!~5d`cq
zV(h{(JE;hU73d9U0xEeZ
zIAqdHK8!A!S}8jdKp8t~BjUWJ8`I%%i>qmQ@D)p>XZiJngD`*a|2yTyp#QKk&n;MY
z_Wq9%YPWk{XQbYGkAB;C4ROlJ{yw$Z>f&Nt!1J2&Zo+MSjqEEfJUU|_EM66|gPP}`
z!CSo5*VG$pz42(emq;U+r)}L1&`7cGC|Lad9gOA3wXWqpW&m~`yFctZ)6NSg};hw_cG>P8z%
zy=ST)a)}bx)oq&e?k8sbbl`s50$75F8|BIDFY@Q2ji;mg?E3Ui=ua}FYGDH^1Dm;J
zo)m@5T`IdMv7MqCrXHsv^)NUFly1`2QI{Z^O2$Jlm1@OY67)j-)wRsm4YC411
z{|i0`(|u=ZEeAk;Ctw8gUqMX%I1WDSm$)}`yx)sK%^V$kfO1#1V)sPS_M3$zz)NN_
zQzjrrKCjm6eUi6^hVD#+akUNMn=OZCSTjDj$e%RTv1f4iGx
zq3$rqKP%5&sPS*PWT9~neIQJ703Z@ja^Bmf^H!alOItU~_MbqxdHzR1h}y19UwICQ
zRhVz)@mUg+829Eio}tFDWq7AtgWaEDQni9g7+@B3SM>cFM~`(BkZNt8!lJf5_}VZ8
zFChv#R&k@Xqzj>#sbnN?$Jarib^3XUPi*qoE
zLiLZ%L}z#5(CX(L+z44DFM9i-sBZ$^!+U2kS{~|(leOKrgks|XfEq+5!rD8Veh%u^
zOUj$Uxja!)v8*y1vt=rWO-htE5!R1A%8)G2`(Ef2s|d#-7|Ys8mS8g{-RFoF
zZMrK!w5HuWtmnJ#;UNn)n)@FBg=wep-+|!xByNn6MmO&OJz{miMSY#8z*oBaPHqpb4)s+I8&A##AGwUOFoU!E)k%VpZtWj$
zz3Ev%&3{Tu4~u>1m)`t?|3+Nw&rcC9!c9*t)=gG&OE{C&u2$M-I_w6OrIY-?>=eli
z%6(s#e;AqoeqoET`@wS~q^nlHWvs_7bV@{8*p{eN`
zCz^m+lTtT};V4}#P9BqKgY##zH2X)l9(M~V%`=1U$SxS2By_Q?URN64hT4HJs8z#F
zX;j0Lf#;au3^4oT_c755=Y@#4dWy0so%oSbmLwxmJ`h3PM7>WYH;?`IBI}G1s8Bkk
zu1a1Z2QI_YZ|*HTBujgB2efta!NvY-&N8+goK4RQCxW1bMXuhADV7L`g
zVE%wRy=Yg*EH>L6GA=~MRD0UyZkK!KCuWg)Cry3wl8!$G#G}MEAqk}iYKnYJ_%g?p
z+;Nju6#^qV->uF`aZR2tI>dm)d|#)VgFM%SO9@{9a42`|6n@r&{fhY(rZ>f}h0Wg68<3>I^;lFjD%qfKv-$L`mMt`8XZhg|X%^$=c
zlLlH@urlGFGjLpb&!;MaW}Sh`a)H)ka?&u$ikiCEFwF>>JXOFASl*QtIEwqVi&oH+
zYsehm8AmERb@SH=fj5kNv0u@ij-
zoN`c$p2S=1(SzYcTrO9h9FB)oO*`JWeZP6AzQZ%Ks_mQ=6
zRnAl`%n~vhDiUCo(B^~VTN=q_t&FGY(tE+#q$`j${vl~FM{LBea}RtC3Cn?xjqGJ4
zyA8v(qkZ6tG*_h#LjOiHoK9DPwkT~Ff=EVqusPP#pBzOg|DfAU=KvA6ytuVnO&Z#&
z;52;%-zr%_Dm$o+Apwx)7GJhkQ?UROWNN7e?L6A$DH4y*kSZh7^cA;P4?=(4G4>0-
zn>PGa%AkOoqSmWEofr>XiWT9atOb#>i4j5hfQsU|X<04h(VogJRG^z;q+O#>
zP8q70C(yOT!rPa>uv_dE=;4Ky{D8AF=S;rd+E!vP$e5J*Lo!bzl!qr5%b!ujufm
zHO)nds)~EiKI0fDLfncr?NzhG(g*vmVJ4Ge?P)rMdB>o23{|r9-_vti=+nMvd>Zjb
zqM7-Z@RazBEZRkkVxW+gc&YWcZ1{A%_gR;HxlO6r0lpMD8n{oWXb@>kDmy+zQHJt5
zM3!-OWB{dbAXP*6d&AyGp8np;$^w>?mQshbD{O{ZlGzp)#A!$l>B>vFF!4tf28IHxT*5zg^*#2W#3!n?Kx(vI*ZC1})~bwHnQ`jie*u}JuaaI`jh{1E|{
zXV9aLR!ueU;-NXP2HLHdAtgh#oayDLIV4H7kF%qPLv|sl5Pg+)OMY!QnXIAwUBQ~-
ziumttDpW#f1gIduRR`c=d6o4g5NuWAH<5tIAj6+76idypu<+sa$B4ISJ(_$skoprF
zmCz%M?!%(n3n%0LW8SfFY6sS{%oC|U^8S?x_V2fvp67_YhJgIT5_2-6_7kStbJraJ
zM>W#1xQZE*&*(e2hh2YZaF5)GD|7IPU%lyYJpvnQ;(k0}He~i${=Y((u18uEcPO|a?|LGXZZzu_XQmB
zu-Tv~D0SmL8$@Ap;(0ee;ADfC2uvaX4BVyPN68sWG1JP`c-~wH&@zQmYk|8;UR@PG
z2oFpK5eGNT;sS!v;w5Fj4`33tO(U_3lael6LMJ)VK~2YqF07E}SONFw44g!bjE}I8
z0w6bjCi@D_htXJBo1CwX1-4^=^4c
z0Ej_Dp>ced-62Z%t^Xz=IsATux|Hq$yJHByY?N#6hnj?Kl|)3yZESw)(6!yk5!>MV
z+_3)^OujRBadg3OybX-?Rf4PMpEQ35GFWA8Pm(tu3IBdakIEv>S!fvPylrEM3;E;
z3U!3#x_r@Hg4TM~HY*nK;yzh5t=Su9LMVR3{N%gr$#V16zqn};sOz2LYITCX8zRPx9q
zwU;xWgT_iZboWYh-dH%&sNNP>S1-G^lXD6R1#k_S23X8J#K{GnS!io~ry~cdfXMUv
zyhE+y{i3ilm>Ogkvyc<)OY^6ncmN7_wV4$24ugslIF5j7Z1S0dGejs1AlP-(ATUT!
zwiQ2(73qqJoQPp{=d{fct++fSw_nZ&=;acE0~$CJF>l|Z{V3F2#?}f6a!f8+yM+at
zxJ-37MLE8|#JajuJA?`fjzo`ZEh9o-3;p5$_1uQf{0I*VtvN5g`em_3^WJ6_jcfu|
zjRbBMoEcZ=0ax?5YuP14PHO`h)dEz;+*r|i(Z`^5gE+vu#KfYcLd2JheJgnUUWaWZ
zKCvRv9$g!Wt>Z1wE53v1QGH~gcxwZIc=U~mQoo|C5eCnjlhm0F**rAU4x%-zNX{z-LNb
zQFrBYsI<2(osj}sq=3(-bp#@ulWBH(y%PKe5aqI#(gg{5Bop?Al;7j)O-@knkv)Uh
zil>fG{rN94PZt5OTMJ3jINhc}IGoDWxzMt(6EILRTC-=PD*Xw-Is=_>GQX?muU-C=
zAkK(%?E$T87iA5&%9>~e)4uuofRZG4H8hZj#$e$Ay$d4R8JXFl$FhWvJUSa`n&Wj}
z{+&RkP@tqi-!dCSUyddq+;tTfZODE&O>14;m)jxsl3v2+<)ynnIaxWnn-+}96p_wN
zVfi2~HzXZd)1cpixA{IE89QFqXvr=))v8R?#3D05NdqD~lHSA9S-75;h1+luC)WL(
z-!xcS{JXdcN&%aHE(rwL&oWecH1{`YX?5RC-7OgIQ=5*7rBX1E2&Q2qOXw^&DoY9p
zeJiXDt38VNQ5(Yi_G&ed29#yqVYN(hl(4HI?On+Wx6hF?|0&)Sz2J6=ek6Rc%^(*F
zkY0g$?wck8DN$E(^z2l5LGdUf*spAbuuF?KB&Kp2JmR+zRK(jYf#pdrqSnC}68<)p
zSLdM;1Ze$4eX@;_!l3PBLaXu#8nP$n0a5&DwaXM$14?NtKojd^eg*BeYN%ujqX`xg
zhpoe8?wXn5BbF+cH}Dcw>qaNE|6JXUCD5K%Q1&nP=97oS)#<*cBiUX4F
z=FBg6rzO3uW1WZV#?ps|wfV+Gcq{NaAm0i+bDrF*LXrv>D;~9t&z``s+^YK}+Mf|T
zVK~DJkfn1e8J)Jl`%#*Im3
ztL@|Q-1+bzoFl*dKf{%2npmDvcPNcwwWA>WSx$y(Dmx09_&U&CR~GE8?^1ieavgov
z!O$Y(Ru*%MfS^|DOw>Ol#qdUOretjf=9+%O_#0n4JxH(TcTQaR+VZyHM>gf>CtaKU
zd+U{4;4Aq;l(SnBszZlpM0Rs!wKef!AYgR;VT4gJ=p*7sS#L6#za)*IxL!(CW~)v`
zMtqoTC$=j@i_-)*3LES`TEC_kM*4j0WpeX0>!EaqeERu}0i8Tz!~L}gU;&CnWZ?65
zl4Fs?UR@wiljAmV4yL9tK&1u}pRsn6VH*9)q^g?HaAEwLf8Q$E^C*$5BKJ4n(RnMw<~hFUgPk7v
zYi>_%pp^=NrevxT!ci(gN6FJF~(TozB^o+`_u1LZ}`$RuBY7|2Mi=R=TGV`~YJPTH_!P
zzq5Pwq3{qVGZp;8pI5Spji{kbXjx7@nsgahFXc*cjYhR1?{ay{B1(Pud*
zkDl?O(|XDzgx{<YbjiH@`ZxX-R-NyQWP>
zwUJlnoJ(~g1@49A0aEEM(s&C!`rxw%SNFrUauHMT5A;@SUt-F2Y1%o@A~&DmoM%9QdGOU?j~WHx0=ohAm0{Cgr`4@&SUVZfGwns|a6VJHz;f
zICUhcP)JjZK)i^beuzRW>&-Ql_&w7+M^}c~6h_O2f)YHfZ`0ma;yUI}buRwKfJYOQ
zrE5@Ssvd*Tlul1PvuahKc7W<7nJDFcXKd2n;%Dt}0&_4kJ`*c|O
zIU*t+e1Jru)O1<_$+|y=-O9`S&>jQrk3B;d@#ZRBAK87}$!)1{*fO(T>W1!w!ifqa
zk7z3YDX0=sMwN=P`4q}hzphw#mt&B+c6rmVO=Ol<#fdKhJ7antm0G11lo7#VV$!TQ
zmOQsauQ-?a1?F$R>YTv65$U(G@uX__z)nh#MF@AW+}Qdv#pOmfKL@UZPfK`YI#ECc
zy=CIVyMCP4UQS5G%<^a`cixn8^89;FDA*0`4ji(HR~9^>zLu4CD``G*lEeE4GXp$H
z0@&bYEHWKaU5@;?QW4o{VyW^WcvS9l%kLnKf9`q=;Hcac-$z&&s{jT%n{Ve1>j~e_
zxQ*j1sk-ri{bj7w%CgQJM`ZQ&An^c#9ZRkd-{y6yD7v-Zu72q$?vK-Ybt7b-`A73J
zOW!TZmj3w+tqjK135D#D#D&hu(u77^(7E8H=~z2L%E@e%;T)m4eUCmL|A|2igi&W;
ze^W*AWpV)a2bDJG!dXVrfonZJN)dqQf$t)B~Rv+6jd5^RB00D31xR4bpD)#VHq5
zAQ(#Ykto~j>sFT(Whyw{RE}rjX;r<5sk4j~xwI}o+t4o-VuIw)byi;o-?8|js_miN
zNhn%re`B%CqKUh*g?x1(i&=3KWJ%pYb|Z!
z*)+vnq+G=x?KMHC&P=?oGL1zv*r8sxMM6{tr0}yU9DzFR=F~RW^Y}y(J8TRd?FzbA?QG`h*Pkk)OrU*Z_^h64z6K2)3;%NakAo
zuWbI#<+h4%5aPl>U>;_UPvv42p{>$KMH4r7C(bv80%jpRPE<@{9~Y!UiC&*Pu}5?M
z8}b4eghmrtBXho~fOn)h{yZOdEi+lOARZxvBa9=)??bVNLfFp}Fn6W0VrxLmFu3w}
zNl*J|0xaMMW9M*4b6yV)AOzSOL-}&%DF?HJ1E39`$t6#&OEE>Dwr`BFrZ$dnmg5+P
zYcqkAY25M&=jVb)Kz{3==EZh>*%gf!U_3|*@9deKP!)jUwD3SkzpK=iW67K
zb}0Ve3EU|r!M@7ViA7PDJWPuI{?#rU(v&Nrlp4WN#Y1L8qCRK1wYBt$HziL>=u{<;m3Vr5`=lI*DqI?W#!z;+bU+G!X}<+3M72_IKnmh
z8^-D>ByECXRX@SP`-e8U@MD(hk9-xKnjf0EnMr+jIcGC8o%rX!!V=PmSMUMu_n|un
z>X?O>Y>(iswYaDDI>mJ!{Oo@UmoYclhsN=&J~O~4*WLS`P9^y#}gU%ruV4h
zVO|&>O8-3;ShYT=iy1UA3gS28>pJ+fA;>09d3w})8Q8`qXc}S~x?Lul==(C!cQ_?0
z9EUKQNn2p;3zp_Bn?{b59#$R;W>Iuqb(BU|FrpZW8lX={@gAO=@i`HxtOxx&{}riw
zcDSky%eWaJ|EKc0c@-ERa@TK9Tc{NC?iX
zm_%hDPlj2Hp6e}&mc1Beu$ry@vxk>`iMtS66Z`3irG=w-#`+Q=K
zr4H@#*7-{#y(XyeOh8x!uTrUs$Crhh|6{@yKlm=&cx%b_M0x9pVHbIW;;g33DRU7T
zWHzqsywqqV#~pc#U`v7NT~#%CICsUMtnkCQ#sW?z9DUg{G)DEdhu=^7F0_a}AVo7x
z?@s__4nGiQ$jugmNefsvAU;JU?p162X1Ixkj8mJf$M|o4XcP_^;3z(o07x#|TX+8?
zEzc4O(mq>*EIPC=KS^?t4{W990uTXUoL)U1S5ML@@SIX^8rA{p5fH1-xB2umGzV?p
zOEXvIvEf`8m9YJ)N(LSEfSUyfPJTN1jT9n6sEANodHE`lJNU#$&EEs78p_LI64hfo
z=-cVpFtk}7(6dSMsQ}rRVW|IIR#jURxX%Hj&B}zU)4q2bF44-9YfKX;q0+ym94XC>kn
z7j#6=?5%Q)T^>Fr@pGjWhZ5_R*a$kW-3DM$k<71Vm1U7yZY9z_pDq4J^=2@F#onn=
zw6vO>279j>d1SMI^X8~ozB8pZ+dAxR6C`g^^4y@k
z6XR%l#xq%1PVjDr<|Yg+z06I$10PkNxr#QW+jUa&d1b%Ea3N0DajEQ1knk(w~iMX>cR7E30H?O
zoAdT{1+$-Ztjb!mwbdfzn&ng3I>-h|fvB4Rv1$O6^A6afZ`=>vN^^WPOsfBq=q!RE
zMV`T}mYT}Mm(}7hNQ8$7Y_ap;w8$m@!a!iCCCfJGXM=oDEA;eibux)_4>$WHFx^AN
zS+A`f4Hf>f{iZSwPudn=V#thQ^m&OS0UI5$W&HQlI5*69Uz_{vN$lcFH(n
zo46UkAtmHIC&hc|ruPyoR|d%_N6Mt+y~OjeQpUd=h)4@SKgA>M)U0
zWt6&bgclt;#3$DPHir*c-4by4;u+wy5R0PU;Z*qE92jT&4NsAZ5xfN7r?
zdoq%Yj;m(}mq&ZaA+un^?5t%b`=QUIx4j;Gc!~q3cdfM1Pg$3G@Ki^P4`ry+6
zY5xRGvNA1Ve2>V+0|?yyV3{L53XhGam5?GvBK{J)MMK+Ovu|j+CUDvCa84@h!1>(OkULkX8ub}FUde}6IEwwwmAO)E5p-}mC%-l8Vkcm`U@`K82
zee;b^C_403HGBFgwXk?u)$MLIMQx^b(xQ0fC3z2);bSd9wS%3&-GG;32p+OCf1oll
zmJ>`AOP2G2DwR|uG$xGk=rZ8n!NOF+y?{TZkIK(Q5PovqO7UArGVeDSg*TaIu{`wb
zReG-4VO7H={kwS1g1Xp?-uwmhTBmjc-$xMmX)UC!z$#J}V6H4`_``TkOe4eF^`{LC
zut#l*_l#GUZ8P}YY9vxUW_U?OD3eGl`6h8HDaO%udlSvV_<7x4*#(3LWb8GoJxuQ*
z#Q?4xQjCQ7hME)r#kI*|a$Nse^FBC-q(O#-`r-s_LOly&E^9Q_JS=zJHtmRKU@}@p
z-rH!ys#6dZyD-%|`;uEtaV+b-oafxzq=Y(92G`nFz9ODuZJxl)3}{)2&bcXd^iU>5
zVu@+MbHw=RPrkBBe-%evC4dUcNfqU8E3(w-8;)&ssNY-^rSQ=8Wg-|Uu4FKk13ze3
zo;Z~*)>}ChQ_;1sk&LJ(gx9bFdE@?v`1
zy%V==p>o5fVXL50WyU045jsDPlNCp4F_@h1ob^o