diff --git a/explainers/mri-scanner/chapters.html b/explainers/mri-scanner/chapters.html new file mode 100644 index 00000000..598c0869 --- /dev/null +++ b/explainers/mri-scanner/chapters.html @@ -0,0 +1,527 @@ +
+

01

+

+ A tunnel that listens to your body +

+

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

+

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

+

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

+

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

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

02

+

A magnet that never switches off

+

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

+

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

+

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

+

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

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

03

+

Hydrogen leans and wobbles

+

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

+

+ In the field, each tiny magnet does not simply point along it. Instead 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, or 63.9 MHz (megahertz). This rate is the + Larmor frequency, the pitch of the wobble. It rises with the field, to 127.7 + MHz at 3 T. Both are close to FM radio: one just below the band, the other just above it. +

+

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

+

+ The arrows in the inset are a drawing, enlarged many times. They are not a real spot in the + head, and they 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 + follows the playback speed. Switch to 3 T: the real frequency and the surplus double, but the + drawing keeps its pace, so the slowdown doubles too. +

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

04

+

A radio pulse and its echo

+

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

+

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

+

+ 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. Now the fast + ones are behind, and they catch up. The magnets 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 %. The 180° flip only undoes differences that stay the + same. Random nudges between neighbouring nuclei remain, 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. +

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

05

+

Coils that tell places apart

+

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

+

+ The change is small: up to 45 mT per metre. At the edge of the head it adds up to about 5.4 mT. + That is a third of a percent of a 1.5 T field. Yet 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. + This is slice selection. The y coil gives each repetition a different twist + from top to bottom, called phase encoding. The x coil runs while the echo comes + in, so its pitch tells left from right. This is frequency encoding. Pick a coil + to see its field, or follow the scan. +

+

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

+
+ + + + +
+
+
+
Change at the head's edge
+
5.4 mT
+
+
+
Share of the main field
+
0.36 %
+
+
+
Pitch shift at the edge of the head
+
230 kHz
+
+
+
Switch-on time
+
0.225 ms
+
+
+
Job in this scan
+
Changes with each step
+
+
+
+ +
+

06

+

From echoes to a picture

+

+ 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 calculation called the Fourier transform turns that grid into the picture. It + works out 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. The + picture shows on the monitor on the wall, beside the control window. Pick 8, 16, 32 or 64 lines: + the scan pauses, and you can watch the picture sharpen. +

+

+ The timing decides which tissues look bright. A T1-weighted scan repeats every + 500 ms and listens early, at 15 ms. Each 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. +

+

+ With one line per repetition, 256 lines take about 2.1 minutes for the T1 scan and 10.7 minutes + 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, while 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. +

+
+ + +
+
+ + + + +
+
+ +
+ k-space, the stored echoes + The picture +
+
+
+
+
Fat
+
0 %
+
+
+
White matter
+
0 %
+
+
+
Grey matter
+
0 %
+
+
+
Brain fluid
+
0 %
+
+
+
One line per repetition
+
10.7 min for 256 lines
+
+
+
Turbo spin echo, 16 lines per repetition
+
about 40 s
+
+
+
diff --git a/explainers/mri-scanner/explainer.json b/explainers/mri-scanner/explainer.json new file mode 100644 index 00000000..f65e0733 --- /dev/null +++ b/explainers/mri-scanner/explainer.json @@ -0,0 +1,12 @@ +{ + "slug": "mri-scanner", + "tags": ["physics", "biology"], + "cover": "cover.webp", + "entry": "src/index.ts", + "chapters": "chapters.html", + "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"], + "social": { + "image": "social/og-image.png", + "alt": "A 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" + } +} diff --git a/explainers/mri-scanner/locales/de.json b/explainers/mri-scanner/locales/de.json new file mode 100644 index 00000000..e7a1865a --- /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-Gerät in 3D: Der Magnet schaltet nie ab, Wasserstoff antwortet auf einen Funkimpuls, die Spulen klopfen, und aus Echos wird ein Bild.", + "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 die Röhre, 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": "Schritt {{step}} von {{total}}", + "during": "Schritt {{step}} von {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} Wiederholungen pro Minute", + "phase": { + "excite": "90°-Impuls", + "encode": "Kodierung", + "refocus": "180°-Impuls", + "echo": "Echo", + "recover": "Erholung" + }, + "jump": { + "excite": "Zum 90°-Impuls springen", + "encode": "Zur Kodierung springen", + "refocus": "Zum 180°-Impuls 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": "Larmor-Frequenz", + "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", + "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 seitlich" + }, + "spins": { + "larmor": "Larmor-Frequenz", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "knapp unter dem UKW-Band", + "aboveFm": "knapp über dem UKW-Band" + }, + "surplus": "Mehr Kerne mit dem Feld als dagegen", + "surplusValue": "etwa {{perMillion}} pro Million", + "count": "Überschuss in 1 mm³ Wasser", + "countValue": "etwa {{mantissa}} × 10{{exponent}}", + "slowdown": "Zeichnung verlangsamt", + "slowdownValue": "etwa {{factor}} 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": "Frequenzverschiebung am Rand des Kopfes", + "rise": "Einschaltzeit", + "role": "Aufgabe in diesem Scan", + "roleValue": { + "x": "Frequenz: von links nach rechts", + "y": "Phase: eine neue Verdrehung pro Wiederholung", + "z": "Schicht: wählt eine dünne Scheibe", + "sequence": "Ändert sich mit jedem Schritt" + } + }, + "picture": { + "kspace": "k-Raum, die gespeicherten Echos", + "image": "Das Bild", + "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": "Noch keine Zeilen. Das nächste Echo füllt die erste.", + "kspaceAlt": "Das Raster des k-Raums mit {{lines}} von {{total}} gefüllten Zeilen", + "imageAlt": "Das Schnittbild des Kopfes, berechnet aus {{lines}} von {{total}} Zeilen" + } + }, + "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. MRT steht für Magnetresonanztomografie. Sein Magnet ist 1,5 Tesla stark. Die Einheit Tesla gibt an, wie stark ein Magnetfeld ist. Sie legen sich auf die Liege, die Sie in die Röhre fährt. Dieser Tunnel ist etwa 70 cm breit. 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. Diese lauschen auf ein schwaches Funkecho aus Ihrem Körper. Der Raum ist mit Kupferblech ausgekleidet, damit Funkwellen von außen dieses Echo nicht übertönen. Röntgenstrahlen gibt es hier keine. Um ein Elektron aus einem Atom zu schlagen, bräuchten die Funkwellen des Geräts etwa 50 Millionen Mal so viel Energie.", + "p3": "Unter der weißen Verkleidung stecken die Bauteile 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. Dann öffnet sich ein Keil im Magneten, und Sie sehen die Schichten. Die nächsten Kapitel stellen sie nacheinander vor.", + "p4": "Das Modell spielt eine Wiederholung ab. Das ist der kurze Ablauf, den ein Scan immer wieder durchläuft, um ein Bild aufzubauen. Eine Wiederholung dauert hier in Wirklichkeit 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 zeigt, welcher Schritt gerade läuft. Die Live-Messwerte 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 Kelvin (K), etwa −269 °C. Kelvin ist eine Temperaturskala, die beim absoluten Nullpunkt beginnt. Kälter geht es nicht. Das Helium ist also 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 halten die Wärme draußen, wie bei einer Thermoskanne. Im geöffneten Keil sehen Sie diese Schichten. 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. Die genaue Zahl hängt davon ab, wo Sie sind, denn das Erdfeld ist nicht überall gleich stark. Wählen Sie 3 T, dann sehen Sie zum Vergleich einen stärkeren Magneten. Die Feldlinien um den Magneten zeigen die Form seines Feldes. Zusätzliche Abschirmspulen außen führen Strom in die Gegenrichtung und holen so das Feld zurück, das nach außen dringt. Trotzdem ist das Feld entlang der Röhre noch bis etwa 4 m weit stärker als 0,5 mT. Das ist ein halbes Tausendstel Tesla. In diesem Bereich arbeitet ein Herzschrittmacher womöglich nicht mehr sicher. Der gestrichelte Ring auf dem Boden markiert seine Grenze.", + "p4": "Der Magnet schaltet nie ab, deshalb warnt ein Leuchtschild an der Tür, dass er immer an ist. 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 nennt man einen Quench. Das Feld fällt in 2 bis 5 Sekunden auf null, und 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 Wasserstoffatome, vor allem in Wasser und Fett. Im Zentrum eines Wasserstoffatoms sitzt sein Kern, ein einzelnes Proton. Dieses Proton 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. Stattdessen präzediert er: 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 (Megahertz). Dieser Takt ist die Larmor-Frequenz, sozusagen die Tonhöhe des Taumelns. Je stärker das Feld, desto höher die Frequenz: Bei 3 T sind es 127,7 MHz. Beide Frequenzen liegen nah am UKW-Band: die eine knapp darunter, die andere knapp darüber.", + "p3": "Die Kerne richten sich nicht alle nach dem Feld 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. Trotzdem ergibt dieser kleine Überschuss in einem Kubikmillimeter Wasser etwa 3,3 × 10¹⁴ Kerne. Zusammen bilden sie einen winzigen Gesamtmagneten. Das ist der Pfeil, mit dem das Gerät arbeitet.", + "p4": "Die Pfeile im kleinen Fenster sind eine vielfach vergrößerte Zeichnung, 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 hängt auch vom Wiedergabetempo ab. Schalten Sie auf 3 T um: Die echte Frequenz und der Überschuss verdoppeln sich, aber die Zeichnung behält ihr Tempo. Deshalb verdoppelt sich auch die Verlangsamung." + }, + "resonance": { + "title": "Ein Funkimpuls und sein Echo", + "p1": "Um den Gesamtmagneten zum Sprechen zu bringen, sendet das Gerät einen kurzen Funkimpuls, genau auf der Larmor-Frequenz. Diesen Gleichtakt mit dem Taumeln nennt man Resonanz. Es ist wie beim Schaukeln: Sie schubsen ein Kind genau im richtigen Moment an. Der Impuls kommt aus der Körperspule, einem Käfig aus Kupferstäben direkt an der Innenseite 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, gemessen in Mikrovolt. Diese Spannung ist das Signal. Bewegen Sie den Regler für den Kippwinkel: Nur der seitliche Anteil liefert ein Signal, der Anteil entlang des Feldes liefert 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. Jetzt liegen die schnellen hinten und holen auf. Die Magnete kommen wieder in den Takt und liefern ein Echo. Die Zeit vom 90°-Impuls bis zum Echo heißt Echozeit. Mit den Schaltflächen unten halten Sie an jedem dieser Zeitpunkte an.", + "p4": "Zwei Zeitspannen 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 feste Unterschiede aus. Zufällige Stöße zwischen benachbarten Kernen gleicht es nicht aus, deshalb ist das Echo etwas schwächer. Genau 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 ein wenig ab.", + "p2": "Die Änderung ist klein: 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. Trotzdem bekommt so jede Stelle ihre eigene Frequenz. 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. Das ist die Schichtauswahl. Die y-Spule gibt jeder Wiederholung eine andere Verdrehung von oben nach unten. Das nennt man Phasenkodierung. Die x-Spule läuft, während das Echo eintrifft. So verrät die Frequenz, ob etwas links oder rechts liegt. Das ist die Frequenzkodierung. Wählen Sie eine Spule, um ihr Feld zu sehen, oder folgen Sie dem Scan.", + "p4": "Werden in einem starken Magneten große Ströme geschaltet, 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, 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": "Eine Rechnung namens Fourier-Transformation macht aus diesem Raster das Bild. Dabei ermittelt sie, welche Mischung aus breiten und feinen Streifen zusammen das Bild ergibt. Die Mitte des k-Raums enthält die breiten Streifen: das grobe Hell und Dunkel. Die Ränder enthalten die feinen Details. Das Bild erscheint auf dem Monitor an der Wand, neben dem Fenster zum Kontrollraum. Wählen Sie 8, 16, 32 oder 64 Zeilen: Der Scan hält an, und Sie sehen, wie das Bild schärfer wird.", + "p3": "Der zeitliche Ablauf entscheidet, welche Gewebe hell erscheinen. Ein T1-gewichteter Scan wiederholt sich alle 500 ms und lauscht früh, nach 15 ms. Jede 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 Hirnwasser, denn sein Signal klingt nur langsam ab. 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 Minuten beim T2-Scan. Für die meisten Aufnahmen des Gehirns nutzt man heute das Turbo-Spin-Echo. Dabei liest das Gerät in jeder Wiederholung 4 bis 32 Zeilen. Mit 16 Zeilen pro Wiederholung 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." + } + } +} diff --git a/explainers/mri-scanner/locales/en.json b/explainers/mri-scanner/locales/en.json new file mode 100644 index 00000000..5078b871 --- /dev/null +++ b/explainers/mri-scanner/locales/en.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "How an MRI scanner works", + "eyebrow": "Medical imaging", + "tagline": "A giant magnet, a radio pulse and the hydrogen in your body: how a scanner hears a faint echo and turns it into a picture.", + "description": "An MRI scanner in 3D: the magnet that never switches off, how hydrogen answers a radio pulse, the knocking coils, and how echoes become a picture.", + "summary": "The superconducting magnet, leaning hydrogen, the radio pulse and its echo, the gradient coils, and k-space.", + "socialAlt": "A cutaway MRI scanner with its nested magnet layers around the tunnel, beside a slice picture of a head and the title How an MRI scanner works" + }, + "stage": { + "label": "Interactive MRI scanner model", + "gauge": "Live scan readings" + }, + "timeline": { + "label": "Time in one repetition", + "phases": "Jump to a step", + "speed": "Playback speed", + "value": "Step {{step}} of {{total}}", + "during": "Step {{step}} of {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} repetitions per minute", + "phase": { + "excite": "90° pulse", + "encode": "Encode", + "refocus": "180° pulse", + "echo": "Echo", + "recover": "Recover" + }, + "jump": { + "excite": "Jump to the 90° pulse", + "encode": "Jump to the encoding", + "refocus": "Jump to the 180° pulse", + "echo": "Jump to the echo", + "recover": "Jump to the recovery" + } + }, + "controls": { + "label": "Model controls", + "field": "Field strength", + "weighting": "Picture type", + "fieldOptions": { + "field15": "1.5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "T1-weighted", + "t2": "T2-weighted" + }, + "tissueOptions": { + "fat": "Fat", + "whiteMatter": "White matter", + "greyMatter": "Grey matter", + "fluid": "Brain fluid" + }, + "axisOptions": { + "x": "x coil", + "y": "y coil", + "z": "z coil", + "sequence": "Follow the scan" + }, + "momentOptions": { + "pulse90": "90° pulse", + "pulse180": "180° pulse", + "echoPeak": "Echo peak", + "repetitionEnd": "End of the repetition" + }, + "linesOptions": { + "8": "8 lines", + "16": "16 lines", + "32": "32 lines", + "64": "64 lines" + }, + "groups": { + "field": "Field strength", + "weighting": "Picture type", + "tissue": "Tissue", + "axis": "Gradient coil", + "moment": "Moment in the repetition", + "lines": "Lines of k-space" + }, + "views": { + "cutaway": { + "name": "Cutaway", + "short": "Cutaway", + "hint": "Cutaway (C)" + }, + "fieldLines": { + "name": "Field lines", + "short": "Field", + "hint": "Field lines (F)" + }, + "labels": { + "name": "Labels", + "short": "Labels", + "hint": "Labels (L)" + } + } + }, + "readouts": { + "larmor": "Larmor frequency", + "time": "Time in the repetition", + "lines": "Lines of k-space" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} of {{total}} ms", + "linesOf": "{{value}} of {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Main field", + "field": "{{tesla}}, always on", + "boreLabel": "Tunnel width", + "bore": "{{cm}} across" + }, + "magnet": { + "earth": "Against Earth's field", + "earthValue": "{{min}} to {{max}} times stronger", + "helium": "Liquid helium", + "fringe": "0.5 mT safety line", + "fringeValue": "{{along}} m along the tunnel, {{side}} m to the side" + }, + "spins": { + "larmor": "Larmor frequency", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "just below FM radio", + "aboveFm": "just above FM radio" + }, + "surplus": "Extra nuclei leaning with the field", + "surplusValue": "about {{perMillion}} in a million", + "count": "Surplus in 1 mm³ of water", + "countValue": "about {{mantissa}} × 10{{exponent}}", + "slowdown": "Drawing slowed down", + "slowdownValue": "about {{factor}} times" + }, + "resonance": { + "tip": "Tip angle", + "across": "Sideways part (signal)", + "along": "Part along the field", + "t1": "T1, regrowth", + "t2": "T2, fading" + }, + "gradients": { + "edge": "Change at the head's edge", + "share": "Share of the main field", + "spread": "Pitch shift at the edge of the head", + "rise": "Switch-on time", + "role": "Job in this scan", + "roleValue": { + "x": "Frequency: left to right", + "y": "Phase: a new twist each repetition", + "z": "Slice: picks one thin slab", + "sequence": "Changes with each step" + } + }, + "picture": { + "kspace": "k-space, the stored echoes", + "image": "The picture", + "scanTime": "One line per repetition", + "scanTimeValue": "{{minutes}} min for {{lines}} lines", + "turbo": "Turbo spin echo, 16 lines per repetition", + "turboValue": "about {{seconds}} s", + "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": { + "room": "Copper-lined room", + "cover": "Cover", + "vacuumVessel": "Vacuum vessel", + "radiationShield": "Heat shield", + "heliumVessel": "Helium vessel", + "mainCoils": "Main magnet coils", + "shieldCoils": "Shield coils", + "shims": "Shims", + "coldHead": "Cold head", + "quenchPipe": "Quench pipe", + "gradientX": "Gradient coil x", + "gradientY": "Gradient coil y", + "gradientZ": "Gradient coil z", + "bodyCoil": "Body coil", + "bore": "Bore", + "table": "Table", + "patient": "Patient", + "headCoil": "Head coil", + "sliceSlab": "Slice", + "spinArrows": "Hydrogen nuclei", + "netMagnet": "Net magnet", + "mainField": "Main field", + "rfWave": "Radio pulse", + "echoWave": "Echo", + "fieldLinesGroup": "Field lines", + "fringeLine": "0.5 mT line", + "screen": "Picture" + }, + "sections": { + "overview": { + "title": "A tunnel that listens to your body", + "p1": "This is an MRI scanner. MRI is short for magnetic resonance imaging. Its magnet has a strength of 1.5 tesla. The tesla is the unit for how strong a magnetic field is. You lie on the table, and it slides you into the bore, a tunnel about 70 cm wide. The machine around it is about 1.6 to 1.9 m long and weighs 3 to 6 tonnes.", + "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. They listen for a faint radio echo from your body. The room is lined with copper sheet, so outside radio waves cannot drown out that echo. There are no X-rays here. The scanner's radio waves carry about 50 million times too little energy to knock an electron out of an atom.", + "p3": "Under the white cover, the parts sit one inside the other like rings. From the outside in, you find the magnet in its cold jacket, three gradient coils and the body coil, a radio antenna. Switch on the cutaway (C) to open a wedge of the magnet and see them. The next chapters take them one at a time.", + "p4": "The model plays one repetition. That is the short cycle a scan runs again and again to build one picture. Here one repetition lasts 0.5 to 2.5 s of real time, but the radio pulses inside it take only milliseconds. So the timeline is not to scale. The few milliseconds of pulses and echo fill about two thirds of it, and the long rest is squeezed into the last third. The timeline shows which step is playing, and the live readings show the real time. A whole head exam, with many pictures, takes about 30 to 45 minutes." + }, + "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. When this wire is cooled far enough, it becomes superconducting. That means current flows through it with no resistance at all. Several hundred amperes run round and round in a closed loop. Once the loop is closed, keeping the field going takes no power. The field fades by less than a tenth of a percent in a year.", + "p2": "To stay superconducting, the coils sit in liquid helium at 4.2 kelvin (K), about −269 °C. Kelvin is a temperature scale that starts at absolute zero, the coldest anything can get. So the helium is only about 1.5 K warmer than deep space. A classic magnet holds about 1,500 litres of it, while newer sealed magnets need 7 litres or even less. Around the helium vessel, a heat shield and a vacuum keep the warmth out, like a thermos flask. The open wedge shows these layers. A small fridge on top, the cold head, turns boiled-off helium back into liquid. It runs day and night and draws about 6 to 7 kW.", + "p3": "The field is tens of thousands of times stronger than Earth's. The exact number depends on where you are, because Earth's own field differs from place to place. Pick 3 T to compare a stronger magnet. The lines around the magnet show the shape of its field. Extra shield coils on the outside carry current the other way and pull back the field that leaks out. Even so, the field stays above 0.5 mT, half a thousandth of a tesla, up to about 4 m along the tunnel. Inside that zone, a pacemaker may stop working safely. The dashed ring on the floor marks its edge.", + "p4": "The magnet never switches off, so a lit sign at the door warns that it is always on. Anything with iron in it can fly into the tunnel, and oxygen tanks have killed people this way. That is why staff check everyone and everything at the door. If the wire warms up somewhere, it stops superconducting and the current turns into heat. This is called a quench. The field drops to zero in 2 to 5 seconds, and the boiling helium roars out of the building through the quench pipe." + }, + "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. Instead 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, or 63.9 MHz (megahertz). This rate is the Larmor frequency, the pitch of the wobble. It rises with the field, to 127.7 MHz at 3 T. Both are close to FM radio: one just below the band, the other just above it.", + "p3": "The nuclei do not all line up. Body heat jostles them, so they point in almost every direction. Only about 5 in a million more lean with the field than against it. Still, that small surplus adds up to about 3.3 × 10¹⁴ nuclei in one cubic millimetre of water. Together they make a tiny net magnet. That is the arrow the scanner works with.", + "p4": "The arrows in the inset are a drawing, enlarged many times. They are not a real spot in the head, and they 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 follows the playback speed. Switch to 3 T: the real frequency and the surplus double, but the drawing keeps its pace, so the slowdown doubles too." + }, + "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. It is 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. As it sweeps past the head coil, it makes a tiny voltage, measured in microvolts. That voltage is the signal. Move the tip angle slider: only the sideways part makes a signal, and 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. Now the fast ones are behind, and they catch up. The magnets 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 only undoes differences that stay the same. Random nudges between neighbouring nuclei remain, 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: up to 45 mT per metre. At the edge of the head it adds up to about 5.4 mT. That is a third of a percent of a 1.5 T field. Yet 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. This is slice selection. The y coil gives each repetition a different twist from top to bottom, called phase encoding. The x coil runs while the echo comes in, so its pitch tells left from right. This is 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, 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. 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 works out 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. The picture shows on the monitor on the wall, beside the control window. Pick 8, 16, 32 or 64 lines: the scan pauses, and you can 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. Each 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 minutes 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, while 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 new file mode 100644 index 00000000..11b171ce --- /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": "Resonancia magnética en 3D: un imán que nunca se apaga, hidrógeno que responde a un pulso de radio, bobinas que golpetean 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": "Vista en corte de un escáner de resonancia magnética, con las capas del imán una dentro de otra alrededor del túnel, junto a la imagen de un corte 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": "Paso {{step}} de {{total}}", + "during": "Paso {{step}} de {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} repeticiones por minuto", + "phase": { + "excite": "Pulso de 90°", + "encode": "Codificación", + "refocus": "Pulso de 180°", + "echo": "Eco", + "recover": "Recuperación" + }, + "jump": { + "excite": "Saltar al pulso de 90°", + "encode": "Saltar a la codificación", + "refocus": "Saltar al pulso de 180°", + "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 Larmor", + "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", + "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 de Larmor", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "justo por debajo de la banda de FM", + "aboveFm": "justo por encima de la banda de FM" + }, + "surplus": "Núcleos de más 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 {{factor}} 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, caída de la señal" + }, + "gradients": { + "edge": "Cambio en el borde de la cabeza", + "share": "Proporción del campo principal", + "spread": "Cambio de tono en el borde de la cabeza", + "rise": "Tiempo de encendido", + "role": "Función en esta exploración", + "roleValue": { + "x": "Frecuencia: de izquierda a derecha", + "y": "Fase: un giro nuevo en cada repetición", + "z": "Corte: elige una capa fina", + "sequence": "Cambia en cada paso" + } + }, + "picture": { + "kspace": "Espacio k, los ecos guardados", + "image": "La imagen", + "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": "Aún no hay líneas. El próximo eco llenará 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": { + "room": "Sala forrada de cobre", + "cover": "Carcasa", + "vacuumVessel": "Cámara de vacío", + "radiationShield": "Pantalla térmica", + "heliumVessel": "Depósito de helio", + "mainCoils": "Bobinas del imán principal", + "shieldCoils": "Bobinas de blindaje", + "shims": "Correctores 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 corporal", + "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": "Este es un escáner de resonancia magnética. Su imán crea un campo de 1,5 teslas. El tesla es la unidad que mide la intensidad de un campo magnético. Te tumbas en la camilla, que se desliza y te mete en el 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 entre 3 y 6 toneladas.", + "p2": "Para explorar el cerebro, una bobina de cabeza te rodea el cráneo como una jaula. Es una antena que lleva 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 para que las ondas de radio de fuera no tapen 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, las piezas van 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 quitar una cuña del imán y verlas. Los capítulos siguientes las explican una a una.", + "p4": "El modelo muestra una repetición. Es el ciclo corto que el escáner repite una y otra vez para formar una imagen. Aquí, 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, y el largo reposo se comprime en el último tercio. La línea de tiempo indica qué paso está en marcha, 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 este hilo se enfría lo suficiente, se vuelve superconductor. Eso quiere decir que 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 se debilita menos de un 0,1 % al año.", + "p2": "Para seguir siendo superconductoras, las bobinas están sumergidas en helio líquido a 4,2 kelvin (K), unos −269 °C. La escala kelvin empieza en el cero absoluto, la temperatura más baja posible. Así que el helio solo está unos 1,5 K más caliente 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. Alrededor del depósito de helio, una pantalla térmica y una capa de vacío impiden que entre el calor, como en un termo. La cuña abierta deja ver estas capas. Encima hay una pequeña nevera, el cabezal frío, que vuelve a licuar el helio evaporado. 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. La cifra exacta depende de dónde estés, porque el campo de la Tierra no es igual en todas partes. Elige 3 T para comparar con un imán más potente. Las líneas que rodean el imán muestran la forma de su campo. Por fuera, unas bobinas de blindaje adicionales llevan corriente en sentido contrario y contienen 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 límite.", + "p4": "El imán nunca se apaga, así que un cartel luminoso en la puerta avisa de que siempre está encendido. Cualquier cosa con hierro puede salir volando hacia el túnel, y así han muerto personas golpeadas por bombonas de oxígeno. Por eso, en la puerta, el personal revisa a cada persona y cada objeto que entra. Si el hilo se calienta en algún punto, deja de ser superconductor y la corriente se convierte en calor. Esto se llama quench. El campo tarda de 2 a 5 segundos en caer a cero, 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. La mayoría está 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 en su dirección. Hace una precesión: se bambolea alrededor de esa dirección, como una peonza que gira inclinada. A 1,5 T se bambolea unos 64 millones de veces por segundo, es decir, 63,9 MHz (megahercios). Este ritmo es la frecuencia de Larmor, el tono del bamboleo. Sube con el campo, hasta 127,7 MHz a 3 T. Las dos frecuencias quedan cerca de la banda de la radio FM: una justo por debajo y la otra justo por encima.", + "p3": "No todos los núcleos se alinean. 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. Esa es la flecha con la que trabaja el escáner.", + "p4": "Las flechas del recuadro son un dibujo ampliado muchas veces. No muestran un punto real de la cabeza, y 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 depende de la velocidad de reproducción. Cambia a 3 T: la frecuencia real y el excedente se duplican, pero el dibujo mantiene su ritmo, así que la ralentización también se duplica." + }, + "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. Es 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 como mucho unos pocos milisegundos.", + "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. Ese voltaje es la señal. Mueve el control del ángulo de inclinación. Solo la parte transversal, la que queda de lado, 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. Ahora los rápidos van detrás y alcanzan a los demás. Los núcleos 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 cerca del 63 % de su valor a lo largo del campo. T2 es el tiempo que tarda la señal transversal en bajar hasta cerca del 37 %. El pulso de 180° solo corrige las diferencias que no cambian. 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 sitúan cada punto", + "p1": "Hasta ahora, cada punto de la cabeza responde con el mismo tono, así que el eco no dice 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.", + "p2": "El cambio es pequeño: 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. Aun así, basta para que cada posición tenga su propia frecuencia. Las bobinas se encienden en unos 0,2 ms.", + "p3": "Cada bobina tiene su función. 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 un giro distinto de arriba abajo. Esto se llama codificación de fase. La bobina x funciona mientras llega el eco, así que el tono de la señal indica si viene de la izquierda o de la derecha. Es la codificación de frecuencia. Elige una bobina para ver su campo, o sigue la exploración.", + "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. Es una cuadrícula que ordena los ecos por tono y giro, no por posición. Una repetición llena una línea. La siguiente cambia el giro 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. Averigua 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. El resultado aparece en el monitor de la pared, junto a la ventana de la sala de control. Elige 8, 16, 32 o 64 líneas: la exploración se pone en pausa y puedes ver 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. Cada 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, llenar 256 líneas lleva unos 2,1 minutos en la exploración T1 y unos 10,7 minutos 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 por repetición, la exploración T2 tarda unos 40 segundos. Este modelo capta un solo corte, mientras que 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." + } + } +} diff --git a/explainers/mri-scanner/locales/fr.json b/explainers/mri-scanner/locales/fr.json new file mode 100644 index 00000000..00c86512 --- /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 vue écorchée, 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 en direct de l’examen" + }, + "timeline": { + "label": "Déroulé d’une répétition", + "phases": "Aller à une étape", + "speed": "Vitesse de lecture", + "value": "Étape {{step}} sur {{total}}", + "during": "Étape {{step}} sur {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} répétitions par minute", + "phase": { + "excite": "Impulsion de 90°", + "encode": "Codage", + "refocus": "Impulsion de 180°", + "echo": "Écho", + "recover": "Récupération" + }, + "jump": { + "excite": "Aller à l’impulsion de 90°", + "encode": "Aller au codage", + "refocus": "Aller à l’impulsion de 180°", + "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éphalorachidien" + }, + "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 écorchée", + "short": "Écorché", + "hint": "Vue écorchée (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 de Larmor", + "time": "Temps écoulé 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", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Champ principal", + "field": "{{tesla}}, toujours en marche", + "boreLabel": "Largeur du tunnel", + "bore": "{{cm}} de diamètre" + }, + "magnet": { + "earth": "Par rapport 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 Larmor", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "juste sous la bande FM", + "aboveFm": "juste au-dessus de la bande FM" + }, + "surplus": "Excédent de noyaux 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 {{factor}} de fois" + }, + "resonance": { + "tip": "Angle de bascule", + "across": "Part transversale (signal)", + "along": "Part dans l’axe du champ", + "t1": "T1, repousse", + "t2": "T2, décroissance" + }, + "gradients": { + "edge": "Écart de champ au bord de la tête", + "share": "Part du champ principal", + "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 nouvelle torsion à chaque répétition", + "z": "Coupe : choisit une fine tranche", + "sequence": "Change à chaque étape" + } + }, + "picture": { + "kspace": "Espace k, les échos enregistrés", + "image": "L’image", + "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": "Aucune ligne pour l’instant. Le prochain écho remplira 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": { + "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. IRM signifie imagerie par résonance magnétique. Son aimant produit un champ de 1,5 tesla. Le tesla est l’unité qui mesure l’intensité 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 qui l’entoure mesure de 1,6 à 1,9 m de long environ et pèse de 3 à 6 tonnes.", + "p2": "Pour un examen du cerveau, une antenne tête vous entoure la tête comme une cage. Elle porte de 20 à 64 petits récepteurs. Ils guettent un faible écho radio venu de votre corps. La salle est tapissée de feuilles de cuivre, pour que les ondes radio de l’extérieur ne couvrent pas 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, les pièces s’emboîtent les unes dans les autres, comme des anneaux. De l’extérieur vers l’intérieur, on trouve l’aimant dans son enveloppe froide, trois bobines de gradient, puis l’antenne corps, qui émet les ondes radio. Activez la vue écorchée (C) : une part de l’aimant s’ouvre et les laisse voir. Les chapitres suivants les présentent une à une.", + "p4": "Le modèle montre une seule répétition. C’est un cycle court, que l’examen reprend encore et encore pour construire une image. Ici, 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, et la longue pause est comprimée dans le dernier tiers. La barre indique l’étape en cours, et les mesures en direct donnent le temps réel. Un examen complet de la tête, avec de nombreuses images, dure de 30 à 45 minutes environ." + }, + "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. Quand ce fil est assez refroidi, il devient supraconducteur. Autrement dit, le courant y circule sans aucune résistance. Plusieurs centaines d’ampères tournent sans fin dans une boucle fermée. Une fois le circuit bouclé, le champ se maintient sans consommer d’énergie. En un an, il perd moins d’un dixième de pour cent.", + "p2": "Pour rester supraconductrices, les bobines baignent dans de l’hélium liquide à 4,2 kelvins (K), soit environ −269 °C. L’échelle kelvin part du zéro absolu, la température la plus basse qui soit. L’hélium n’a donc qu’environ 1,5 K de plus que le vide spatial. Un aimant classique en contient environ 1 500 litres, alors que les aimants scellés plus récents se contentent de 7 litres, voire moins. Autour de la cuve d’hélium, un écran thermique et un vide empêchent la chaleur d’entrer, comme dans un thermos. La part ouverte de l’aimant laisse voir ces couches. Sur le dessus, un petit réfrigérateur, la tête froide, retransforme en liquide l’hélium qui s’est é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 chiffre exact dépend de l’endroit où vous êtes, car le champ terrestre varie d’un lieu à l’autre. Choisissez 3 T pour comparer avec un aimant plus puissant. Les lignes autour de l’aimant montrent la forme de son champ. À 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, le champ reste au-dessus de 0,5 mT, soit un demi-millième de tesla, jusqu’à environ 4 m dans l’axe du tunnel. Dans cette zone, un pacemaker peut ne plus fonctionner sans danger. L’anneau en pointillés tracé au sol en marque la limite.", + "p4": "L’aimant ne s’éteint jamais : à la porte, un panneau lumineux prévient qu’il est toujours en marche. Tout objet qui contient du fer peut être projeté dans le tunnel, et des bouteilles d’oxygène ont déjà tué des gens 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 ce qu’on appelle un quench. Le champ tombe à zéro en 2 à 5 secondes, et l’hélium qui bout s’échappe du bâtiment en grondant 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 seul 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 (mégahertz). 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 sont proches de la radio FM : l’une juste en dessous de la bande, l’autre juste au-dessus.", + "p3": "Les noyaux ne s’alignent pas tous. La chaleur du corps les bouscule, et 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 pourtant environ 3,3 × 10¹⁴ noyaux dans un millimètre cube d’eau. Ensemble, ils forment un minuscule aimant résultant. C’est cette flèche que l’appareil utilise.", + "p4": "Les flèches de l’encart sont un dessin, très agrandi. Elles ne montrent pas un vrai point de la tête, et elles exagèrent beaucoup l’inclinaison pour que vous puissiez la voir. Lancez la lecture : elles se mettent à tourner. Le dessin est énormément ralenti, et la mesure indique de combien. Ce chiffre suit la vitesse de lecture. Passez à 3 T : la vraie fréquence et l’excédent doublent, mais le dessin garde son rythme, donc le ralentissement double lui aussi." + }, + "resonance": { + "title": "Une impulsion radio et son écho", + "p1": "Pour faire parler l’aimant résultant, l’appareil envoie une brève impulsion radio, pile à la fréquence de Larmor. Cet accord avec la rotation s’appelle la résonance. C’est comme pousser 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 quelques millisecondes au plus.", + "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 électrique, qui se mesure en microvolts. Cette tension, c’est le signal. Déplacez le curseur de l’angle de bascule : seule la part couchée sur le côté, dite transversale, crée un signal. La part dans l’axe du champ n’en donne aucun.", + "p3": "Très vite, les petits aimants se décalent les uns des autres, car certains tournent un peu plus vite que d’autres. Le signal s’affaiblit. À mi-chemin de l’écho, une impulsion de 180° les retourne. Les plus rapides se retrouvent alors derrière, et ils rattrapent les autres. Les petits aimants se remettent au pas et produisent un écho. Le temps entre l’impulsion de 90° et l’écho s’appelle le temps d’écho. Avec les boutons ci-dessous, vous pouvez 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 qui restent constants. Mais les petites bousculades au hasard entre noyaux voisins, elles, demeurent : l’écho est donc 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 données pour chaque tissu sont approximatives et choisies pour ce modèle. Les impulsions vous réchauffent aussi un peu : l’appareil veille à garder 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 : au plus 45 mT par mètre. Au bord de la tête, cela fait environ 5,4 mT, soit un tiers de pour cent d’un champ de 1,5 T. Pourtant, 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 fine tranche. C’est la sélection de coupe. La bobine y donne à chaque répétition une torsion différente, de haut en bas : on parle de codage de phase. La bobine x est active pendant que l’écho arrive : la fréquence du signal distingue alors la gauche de la droite. C’est le codage en fréquence. Choisissez une bobine pour voir son champ, ou suivez l’examen.", + "p4": "Quand on allume et coupe de forts courants dans un aimant puissant, les bobines vibrent par à-coups, comme un haut-parleur. C’est ce fort martèlement que vous entendez pendant l’examen. Il peut dépasser 100 décibels, 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 enregistré sous la forme d’une ligne de l’espace k. C’est une grille qui classe 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 : une image grossière apparaît donc vite.", + "p2": "Un calcul appelé transformée de Fourier convertit cette grille en image. Il cherche quel mélange de rayures, larges et fines, compose 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. L’image s’affiche sur l’écran fixé au mur, à côté de la vitre de la salle de commande. Choisissez 8, 16, 32 ou 64 lignes : l’examen se met en pause, et vous voyez l’image gagner en netteté.", + "p3": "C’est le choix des temps qui détermine quels tissus paraissent clairs. Une séquence pondérée en T1 se répète toutes les 500 ms et écoute tôt, à 15 ms. Chaque 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, et 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, il faut environ 2,1 minutes pour 256 lignes en séquence T1, et 10,7 minutes en 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 par répétition, la séquence T2 dure environ 40 secondes. Ce modèle montre une seule coupe, alors qu’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." + } + } +} diff --git a/explainers/mri-scanner/locales/ja.json b/explainers/mri-scanner/locales/ja.json new file mode 100644 index 00000000..a67a6cd0 --- /dev/null +++ b/explainers/mri-scanner/locales/ja.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "MRI装置のしくみ", + "eyebrow": "医療画像", + "tagline": "巨大な磁石、電波のパルス、そして体の中の水素。装置がかすかなエコーを聞き取り、画像に変えるしくみです。", + "description": "MRI装置を3Dで。スイッチを切らない磁石、電波に応える水素、ガンガン鳴るコイル、エコーが画像になるまでを見ていきます。", + "summary": "超伝導磁石、傾く水素、電波パルスとエコー、傾斜磁場コイル、k空間。", + "socialAlt": "入れ子になった磁石の層がトンネルを囲む、MRI装置の断面図。横には頭のスライス画像と、「MRI装置のしくみ」というタイトルがあります。" + }, + "stage": { + "label": "MRI装置のインタラクティブモデル", + "gauge": "撮影中のリアルタイム計測" + }, + "timeline": { + "label": "繰り返し1回分の時間", + "phases": "ステップへ移動", + "speed": "再生速度", + "value": "ステップ{{step}}/{{total}}", + "during": "ステップ{{step}}/{{total}}:{{phase}}", + "speedValue": "{{value}}回/分", + "speedFormat": "1分あたり{{value}}回の繰り返し", + "phase": { + "excite": "90°パルス", + "encode": "エンコード", + "refocus": "180°パルス", + "echo": "エコー", + "recover": "回復" + }, + "jump": { + "excite": "90°パルスへ移動", + "encode": "エンコードへ移動", + "refocus": "180°パルスへ移動", + "echo": "エコーへ移動", + "recover": "回復へ移動" + } + }, + "controls": { + "label": "モデルの操作", + "field": "磁場の強さ", + "weighting": "画像の種類", + "fieldOptions": { + "field15": "1.5T", + "field30": "3T" + }, + "weightingOptions": { + "t1": "T1強調", + "t2": "T2強調" + }, + "tissueOptions": { + "fat": "脂肪", + "whiteMatter": "白質", + "greyMatter": "灰白質", + "fluid": "脳脊髄液" + }, + "axisOptions": { + "x": "xコイル", + "y": "yコイル", + "z": "zコイル", + "sequence": "撮影の流れを追う" + }, + "momentOptions": { + "pulse90": "90°パルス", + "pulse180": "180°パルス", + "echoPeak": "エコーのピーク", + "repetitionEnd": "繰り返しの終わり" + }, + "linesOptions": { + "8": "8ライン", + "16": "16ライン", + "32": "32ライン", + "64": "64ライン" + }, + "groups": { + "field": "磁場の強さ", + "weighting": "画像の種類", + "tissue": "組織", + "axis": "傾斜磁場コイル", + "moment": "止める瞬間", + "lines": "k空間のライン数" + }, + "views": { + "cutaway": { + "name": "断面表示", + "short": "断面", + "hint": "断面表示(C)" + }, + "fieldLines": { + "name": "磁力線", + "short": "磁場", + "hint": "磁力線(F)" + }, + "labels": { + "name": "ラベル", + "short": "ラベル", + "hint": "ラベル(L)" + } + } + }, + "readouts": { + "larmor": "ラーモア周波数", + "time": "繰り返しの経過時間", + "lines": "k空間のライン" + }, + "units": { + "mhz": "{{value}}MHz", + "msOf": "{{value}}/{{total}}ミリ秒", + "linesOf": "{{value}}/{{total}}", + "degrees": "{{value}}°", + "percent": "{{value}}%", + "ms": "{{value}}ミリ秒", + "tesla": "{{value}}T", + "kelvin": "{{kelvin}}K、{{celsius}}°C", + "kHz": "{{value}}kHz", + "mT": "{{value}}mT", + "cm": "{{value}}cm" + }, + "chapters": { + "overview": { + "fieldLabel": "主磁場", + "field": "{{tesla}}、常時オン", + "boreLabel": "トンネルの幅", + "bore": "直径{{cm}}" + }, + "magnet": { + "earth": "地球の磁場と比べると", + "earthValue": "{{min}}倍から{{max}}倍の強さ", + "helium": "液体ヘリウム", + "fringe": "0.5mTの安全ライン", + "fringeValue": "トンネルの方向に{{along}}m、横に{{side}}m" + }, + "spins": { + "larmor": "ラーモア周波数", + "larmorBand": "{{value}}、{{band}}", + "band": { + "belowFm": "FMラジオのすぐ下", + "aboveFm": "FMラジオのすぐ上" + }, + "surplus": "磁場の向きに傾く、余分な原子核", + "surplusValue": "100万個あたり約{{perMillion}}個", + "count": "水1mm³の中の余分な原子核", + "countValue": "約{{mantissa}} × 10{{exponent}}個", + "slowdown": "絵の首振りの速さ", + "slowdownValue": "本物の約{{factor}}分の1" + }, + "resonance": { + "tip": "倒す角度", + "across": "横向きの部分(信号)", + "along": "磁場に沿った部分", + "t1": "T1(戻る時間)", + "t2": "T2(弱まる時間)" + }, + "gradients": { + "edge": "頭の端での変化", + "share": "主磁場に対する割合", + "spread": "頭の端での周波数のずれ", + "rise": "オンになるまでの時間", + "role": "この撮影での役目", + "roleValue": { + "x": "周波数:左右を見分ける", + "y": "位相:繰り返しごとに新しいねじれ", + "z": "スライス:薄い1枚を選ぶ", + "sequence": "ステップごとに変わる" + } + }, + "picture": { + "kspace": "k空間(記録したエコー)", + "image": "画像", + "scanTime": "1回の繰り返しで1ライン", + "scanTimeValue": "{{lines}}ラインで{{minutes}}分", + "turbo": "ターボスピンエコー(繰り返し1回で16ライン)", + "turboValue": "約{{seconds}}秒", + "empty": "まだラインはありません。次のエコーが最初のラインを埋めます。", + "kspaceAlt": "{{total}}ラインのうち{{lines}}ラインが埋まった、k空間の格子", + "imageAlt": "{{total}}ラインのうち{{lines}}ラインから組み立てた、頭のスライス画像" + } + }, + "parts": { + "room": "銅張りの部屋", + "cover": "カバー", + "vacuumVessel": "真空容器", + "radiationShield": "熱シールド", + "heliumVessel": "ヘリウム容器", + "mainCoils": "主磁石のコイル", + "shieldCoils": "シールドコイル", + "shims": "シム", + "coldHead": "コールドヘッド", + "quenchPipe": "クエンチ管", + "gradientX": "傾斜磁場コイルx", + "gradientY": "傾斜磁場コイルy", + "gradientZ": "傾斜磁場コイルz", + "bodyCoil": "ボディコイル", + "bore": "ボア", + "table": "寝台", + "patient": "患者", + "headCoil": "頭部コイル", + "sliceSlab": "スライス", + "spinArrows": "水素の原子核", + "netMagnet": "正味の磁石", + "mainField": "主磁場", + "rfWave": "電波パルス", + "echoWave": "エコー", + "fieldLinesGroup": "磁力線", + "fringeLine": "0.5mTのライン", + "screen": "画像" + }, + "sections": { + "overview": { + "title": "体の声に耳をすますトンネル", + "p1": "これはMRI装置です。MRIは「磁気共鳴画像法」の英語名の頭文字です。磁石の強さは1.5テスラです。テスラは、磁場の強さを表す単位です。寝台に横になると、寝台ごとボアの中へ運ばれます。ボアは幅約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.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.5テスラでは1秒に約6400万回、つまり63.9MHz(メガヘルツ)で首を振ります。この首振りの速さを、ラーモア周波数といいます。磁場が強いほど高くなり、3テスラでは127.7MHzになります。どちらもFMラジオに近い周波数です。1.5テスラの値はFMの帯域のすぐ下、3テスラの値はすぐ上にあります。", + "p3": "原子核がみんな同じ向きにそろうわけではありません。体温で揺さぶられ、ほぼばらばらの方向を向いています。磁場と同じ向きに傾くものは、逆向きのものより100万個あたり約5個多いだけです。それでも水1立方ミリメートルの中では、この余分な原子核が約3.3 × 10¹⁴個にもなります。合わせると、小さな正味の磁石になります。装置が相手にするのは、この矢印です。", + "p4": "挿入図の矢印は、何倍にも拡大した絵です。頭の中の実際の一点ではありません。傾きも、見えるように本物よりずっと大きく描いています。再生を押すと、矢印が回るのが見られます。絵の動きは、ものすごくゆっくりにしてあります。どれだけ遅くしたかは、計測値に出ます。この数字は、再生速度に合わせて変わります。3テスラに切り替えてみてください。本物の周波数も余分な原子核の数も2倍になりますが、絵の速さは変わりません。そのため、絵をゆっくりにした倍率も2倍になります。" + }, + "resonance": { + "title": "電波パルスとエコー", + "p1": "正味の磁石から信号を引き出すため、装置はラーモア周波数ぴったりの短い電波のパルスを送ります。このように首振りに合わせることを共鳴といいます。ブランコに乗った子どもを、ちょうどいいタイミングで押すのと同じです。パルスを出すのはボディコイルで、トンネルのすぐ内側にある、銅の棒を並べたかごです。パルスの長さは、長くても数ミリ秒です。", + "p2": "90°パルスは、正味の磁石を真横に寝るまで倒します。磁石はそこで首を振り続けます。頭部コイルのそばを通るたびに、マイクロボルト単位のごく小さな電圧を生みます。この電圧が信号です。倒す角度のスライダーを動かしてみてください。信号を出すのは横向きの部分だけで、磁場に沿った部分は信号を出しません。", + "p3": "小さな磁石たちは、すぐに足並みが乱れます。ほかより少し速く首を振るものがあるからです。そのため信号は弱まっていきます。90°パルスとエコーの中間で、180°パルスが磁石たちをひっくり返します。今度は速いものが後ろになり、やがて追いつきます。磁石たちは再び足並みをそろえ、エコーを出します。90°パルスからエコーまでの時間が、エコー時間です。下のボタンで、それぞれの瞬間に止められます。", + "p4": "パルスのあと、磁石たちは少しずつ元の状態に戻ります。これを緩和といいます。それぞれの組織がどれくらい明るく見えるかは、緩和を表す2つの時計で決まります。T1は、正味の磁石が磁場に沿った向きに、元の約63%まで戻るのにかかる時間です。T2は、横向きの信号が約37%まで弱まるのにかかる時間です。180°パルスで元に戻せるのは、いつも変わらない速さの違いだけです。となり合う原子核どうしのでたらめな押し合いによるずれは残るので、エコーは少し弱くなります。この弱まりを表すのがT2です。脂肪は早く戻り、脳脊髄液(脳と脊髄を包む液体)はゆっくり戻ります。組織を選んで比べてみましょう。組織ごとの値は、このモデル用に選んだおおよその数字です。パルスは体を少しあたためるので、装置はその発熱を安全な範囲に抑えています。" + }, + "gradients": { + "title": "場所を見分けるコイル", + "p1": "ここまでは、頭のどの場所も同じ周波数で答えるので、エコーがどこから来たのかわかりません。これを解決するのが、磁石の内側にある傾斜磁場コイルです。x、y、zの3つがあります。傾斜磁場とは、片側から反対側へ、強さが一定の割合で変わっていく磁場です。それぞれのコイルは、中心より片側で主磁場を少し強め、反対側では少し弱めます。", + "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ミリ秒で聞き取ります。今度は信号がゆっくり弱まる液体が明るく光り、灰白質は白質より明るく見えます。計測値は、いちばん明るい組織を100%として、それぞれの明るさを示します。ここでの頭は、楕円を組み合わせた簡単な絵です。本物の画像ではありません。このモデルでは、どの組織も水素の量は同じで、骨と空気は黒いままです。", + "p4": "1回の繰り返しで1ラインずつだと、256ラインにかかる時間は、T1強調で約2.1分、T2強調で約10.7分です。いまの脳の撮影では、ほとんどがターボスピンエコーを使います。1回の繰り返しで4ラインから32ラインを読み取る撮り方です。16ラインずつなら、T2強調の撮影は約40秒で終わります。このモデルが撮るのは1枚のスライスだけですが、実際の頭の撮影では何枚も撮ります。ポール・ラウターバーは、1973年に最初のMR画像を発表しました。ラウターバーとピーター・マンスフィールドは、この功績で2003年のノーベル賞を共同受賞しました。" + } + } +} diff --git a/explainers/mri-scanner/locales/pt.json b/explainers/mri-scanner/locales/pt.json new file mode 100644 index 00000000..953b5c78 --- /dev/null +++ b/explainers/mri-scanner/locales/pt.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "Como funciona um aparelho de ressonância magnética", + "eyebrow": "Diagnóstico por imagem", + "tagline": "Um ímã gigante, um pulso de rádio e o hidrogênio do seu corpo: como o aparelho escuta um eco 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 barulhentas e os ecos que viram imagem.", + "summary": "O ímã supercondutor, o hidrogênio que se inclina, o pulso de rádio e seu eco, as bobinas de gradiente e o espaço k.", + "socialAlt": "Um aparelho de ressonância magnética em corte, com as camadas do ímã umas dentro das outras em volta do túnel. Ao lado, a imagem de um corte da cabeça e o título “Como funciona um aparelho de ressonância magnética”" + }, + "stage": { + "label": "Modelo interativo de um aparelho de ressonância magnética", + "gauge": "Leituras do exame em tempo real" + }, + "timeline": { + "label": "Tempo dentro de uma repetição", + "phases": "Ir para uma etapa", + "speed": "Velocidade de reprodução", + "value": "Etapa {{step}} de {{total}}", + "during": "Etapa {{step}} de {{total}}, {{phase}}", + "speedValue": "{{value}}/min", + "speedFormat": "{{value}} repetições por minuto", + "phase": { + "excite": "Pulso de 90°", + "encode": "Codificação", + "refocus": "Pulso de 180°", + "echo": "Eco", + "recover": "Recuperação" + }, + "jump": { + "excite": "Ir para o pulso de 90°", + "encode": "Ir para a codificação", + "refocus": "Ir para o pulso de 180°", + "echo": "Ir para o eco", + "recover": "Ir para a recuperação" + } + }, + "controls": { + "label": "Controles do modelo", + "field": "Intensidade do campo", + "weighting": "Tipo de imagem", + "fieldOptions": { + "field15": "1,5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "Ponderada em T1", + "t2": "Ponderada em T2" + }, + "tissueOptions": { + "fat": "Gordura", + "whiteMatter": "Substância branca", + "greyMatter": "Substância cinzenta", + "fluid": "Líquido cerebral" + }, + "axisOptions": { + "x": "Bobina x", + "y": "Bobina y", + "z": "Bobina z", + "sequence": "Seguir o exame" + }, + "momentOptions": { + "pulse90": "Pulso de 90°", + "pulse180": "Pulso de 180°", + "echoPeak": "Pico do eco", + "repetitionEnd": "Fim da repetição" + }, + "linesOptions": { + "8": "8 linhas", + "16": "16 linhas", + "32": "32 linhas", + "64": "64 linhas" + }, + "groups": { + "field": "Intensidade do campo", + "weighting": "Tipo de imagem", + "tissue": "Tecido", + "axis": "Bobina de gradiente", + "moment": "Momento da repetição", + "lines": "Linhas do espaço k" + }, + "views": { + "cutaway": { + "name": "Vista em corte", + "short": "Corte", + "hint": "Vista em corte (C)" + }, + "fieldLines": { + "name": "Linhas de campo", + "short": "Campo", + "hint": "Linhas de campo (F)" + }, + "labels": { + "name": "Rótulos", + "short": "Rótulos", + "hint": "Rótulos (L)" + } + } + }, + "readouts": { + "larmor": "Frequência de Larmor", + "time": "Tempo na repetição", + "lines": "Linhas do espaço k" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}} de {{total}} ms", + "linesOf": "{{value}} de {{total}}", + "degrees": "{{value}}°", + "percent": "{{value}} %", + "ms": "{{value}} ms", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K, {{celsius}} °C", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}} cm" + }, + "chapters": { + "overview": { + "fieldLabel": "Campo principal", + "field": "{{tesla}}, sempre ligado", + "boreLabel": "Largura do túnel", + "bore": "{{cm}} de diâmetro" + }, + "magnet": { + "earth": "Comparado ao campo da Terra", + "earthValue": "de {{min}} a {{max}} vezes mais forte", + "helium": "Hélio líquido", + "fringe": "Linha de segurança de 0,5 mT", + "fringeValue": "{{along}} m ao longo do túnel, {{side}} m para o lado" + }, + "spins": { + "larmor": "Frequência de Larmor", + "larmorBand": "{{value}}, {{band}}", + "band": { + "belowFm": "logo abaixo da faixa de rádio FM", + "aboveFm": "logo acima da faixa de rádio FM" + }, + "surplus": "Núcleos a mais inclinados a favor do campo", + "surplusValue": "cerca de {{perMillion}} a cada milhão", + "count": "Excedente em 1 mm³ de água", + "countValue": "cerca de {{mantissa}} × 10{{exponent}}", + "slowdown": "Desenho mais lento", + "slowdownValue": "cerca de {{factor}} de vezes" + }, + "resonance": { + "tip": "Ângulo de inclinação", + "across": "Parte lateral (sinal)", + "along": "Parte ao longo do campo", + "t1": "T1, recuperação", + "t2": "T2, decaimento" + }, + "gradients": { + "edge": "Variação na borda da cabeça", + "share": "Fração do campo principal", + "spread": "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 nova a cada repetição", + "z": "Fatia: escolhe uma camada fina", + "sequence": "Muda a cada etapa" + } + }, + "picture": { + "kspace": "Espaço k, os ecos guardados", + "image": "A imagem", + "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": "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": "Carcaça", + "vacuumVessel": "Câmara de vácuo", + "radiationShield": "Escudo térmico", + "heliumVessel": "Reservatório de hélio", + "mainCoils": "Bobinas do ímã principal", + "shieldCoils": "Bobinas de blindagem", + "shims": "Shims", + "coldHead": "Cabeçote de refrigeração", + "quenchPipe": "Tubo de quench", + "gradientX": "Bobina de gradiente x", + "gradientY": "Bobina de gradiente y", + "gradientZ": "Bobina de gradiente z", + "bodyCoil": "Bobina de corpo", + "bore": "Túnel", + "table": "Mesa", + "patient": "Paciente", + "headCoil": "Bobina de cabeça", + "sliceSlab": "Fatia", + "spinArrows": "Núcleos de hidrogênio", + "netMagnet": "Ímã resultante", + "mainField": "Campo principal", + "rfWave": "Pulso de rádio", + "echoWave": "Eco", + "fieldLinesGroup": "Linhas de campo", + "fringeLine": "Linha de 0,5 mT", + "screen": "Imagem" + }, + "sections": { + "overview": { + "title": "Um túnel que escuta o seu corpo", + "p1": "Este é um aparelho de ressonância magnética. O ímã dele tem uma força 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 tem 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 fica em volta da sua cabeça como uma gaiola. É uma antena com 20 a 64 pequenos receptores. Eles escutam um eco de rádio bem fraco que vem do seu corpo. A sala é revestida com chapas de cobre, para que as ondas de rádio de fora não abafem 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 peças ficam umas dentro das outras, como anéis. De fora para dentro, você encontra o ímã envolto 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 abrir uma cunha no ímã e ver essas peças. Os próximos capítulos mostram uma de cada vez.", + "p4": "O modelo mostra uma repetição. É o ciclo curto que o exame repete muitas vezes para montar uma imagem. Aqui, uma repetição dura de 0,5 a 2,5 s em 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, e o longo descanso fica espremido no último terço. A linha do tempo mostra qual etapa está em andamento, 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 esse fio fica frio o bastante, ele se torna supercondutor. Isso quer dizer que 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 consome energia nenhuma. O campo 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. O kelvin é uma escala de temperatura que começa no zero absoluto, a temperatura mais baixa que existe. Ou seja, o hélio fica 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. Em volta do reservatório de hélio, um escudo térmico e um vácuo barram o calor, como numa garrafa térmica. A cunha aberta mostra essas camadas. Uma pequena geladeira no topo, o cabeçote de refrigeração, volta a transformar em líquido o hélio que evaporou. 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 número exato depende de onde você está, porque o próprio campo da Terra varia de um lugar para outro. Escolha 3 T para comparar com um ímã mais forte. As linhas em volta do ímã mostram a forma do campo dele. Do lado de fora ficam bobinas extras, as bobinas de blindagem. Elas conduzem 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 dessa zona, um marca-passo pode deixar de funcionar com segurança. O anel tracejado no chão marca o limite dessa zona.", + "p4": "O ímã nunca desliga, por isso uma placa iluminada na porta avisa que ele está sempre ligado. Qualquer coisa com ferro pode voar para dentro do túnel, e cilindros de oxigênio já mataram pessoas assim. É por isso que a equipe confere todo mundo e tudo o que passa pela porta. Se o fio esquentar 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, e o hélio ferve e escapa com um rugido pelo tubo de quench, para fora do prédio." + }, + "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, esses ímãs minúsculos não apontam simplesmente na direção dele. Em vez disso, cada um entra em precessão: oscila em volta dessa direção, como um pião que gira inclinado. A 1,5 T, ele oscila cerca de 64 milhões de vezes por segundo, ou 63,9 MHz (megahertz). Esse ritmo é a frequência de Larmor, o tom da oscilação. Ela sobe junto com o campo e chega a 127,7 MHz a 3 T. As duas ficam perto da faixa de rádio FM: uma logo abaixo, a outra logo acima.", + "p3": "Os núcleos não ficam todos alinhados. O calor do corpo os sacode, e eles apontam para quase todos os lados. A cada milhão de núcleos, 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. É essa a seta com que o aparelho trabalha.", + "p4": "As setas no quadro em destaque são um desenho, ampliado muitas vezes. Elas não mostram um ponto real da cabeça e 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 acompanha 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, então a diferença de velocidade também dobra." + }, + "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. Acertar o ritmo da 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 que reveste o túnel por dentro. 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. Ao passar pela bobina de cabeça, ele gera uma tensão minúscula, medida em microvolts. Essa tensão é 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 ímãs minúsculos logo perdem a sincronia, porque alguns oscilam um pouco mais rápido que outros. O sinal enfraquece. Na metade do caminho até o eco, um pulso de 180° inverte todos eles. Agora os mais rápidos ficam para trás e alcançam os outros. Os ímãs voltam a se sincronizar 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 ficam sempre iguais. 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 esquentam um pouco o seu corpo, por isso 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. Outras três bobinas dentro do ímã resolvem isso: as bobinas de gradiente x, y e z. Um gradiente é um campo que muda aos poucos e 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: até 45 mT por metro. Na borda da cabeça, isso chega a cerca de 5,4 mT, um terço de um por cento 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. Isso se chama seleção de fatia. A bobina y dá a cada repetição uma torção diferente, de cima para baixo, e isso se chama codificação de fase. A bobina x fica ligada enquanto o eco chega, então o tom do eco separa a esquerda da direita. Essa é 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. Daí vêm as batidas altas que você ouve durante o exame. O barulho pode passar de 100 decibéis, tão alto quanto 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 pixels, então ela precisa de 64 repetições. Exames reais da cabeça costumam usar 256 linhas. Aqui o preenchimento vai do meio para fora, por isso uma imagem borrada já aparece logo no começo.", + "p2": "Um cálculo chamado transformada de Fourier converte essa grade na imagem. Ela descobre quais listras, largas e finas, é preciso somar para formar a imagem. O meio do espaço k guarda as listras largas: as grandes áreas de claro e escuro. As bordas guardam os detalhes finos. A imagem aparece no monitor da parede, ao lado da janela da sala de controle. Escolha 8, 16, 32 ou 64 linhas: o exame entra em pausa, e você vê a imagem ganhar nitidez.", + "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. Cada repetição termina antes que os tecidos lentos tenham se recuperado, 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 quem brilha é o líquido, que perde o sinal devagar, e 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 o osso e o ar ficam pretos.", + "p4": "Com uma linha por repetição, 256 linhas levam cerca de 2,1 minutos no exame T1 e 10,7 minutos 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, enquanto 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 esse trabalho." + } + } +} diff --git a/explainers/mri-scanner/locales/uk.json b/explainers/mri-scanner/locales/uk.json new file mode 100644 index 00000000..c9a58688 --- /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": "Крок {{step}} з {{total}}", + "during": "Крок {{step}} з {{total}}, {{phase}}", + "speedValue": "{{value}}/хв", + "speedFormat": "Повторень за хвилину: {{value}}", + "phase": { + "excite": "Імпульс 90°", + "encode": "Кодування", + "refocus": "Імпульс 180°", + "echo": "Відлуння", + "recover": "Відновлення" + }, + "jump": { + "excite": "Перейти до імпульсу 90°", + "encode": "Перейти до кодування", + "refocus": "Перейти до імпульсу 180°", + "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", + "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": "приблизно в {{factor}} разів" + }, + "resonance": { + "tip": "Кут нахилу", + "across": "Поперечна частина (сигнал)", + "along": "Частина вздовж поля", + "t1": "T1, відновлення", + "t2": "T2, згасання" + }, + "gradients": { + "edge": "Зміна на краю голови", + "share": "Частка основного поля", + "spread": "Зсув частоти на краю голови", + "rise": "Час увімкнення", + "role": "Роль у цьому скануванні", + "roleValue": { + "x": "Частота: зліва направо", + "y": "Фаза: нове закручування в кожному повторенні", + "z": "Зріз: вибирає один тонкий шар", + "sequence": "Змінюється з кожним кроком" + } + }, + "picture": { + "kspace": "k-простір, збережені відлуння", + "image": "Зображення", + "scanTime": "Один рядок за повторення", + "scanTimeValue": "{{minutes}} хв на {{lines}} рядків", + "turbo": "Турбо спін-ехо, 16 рядків за повторення", + "turboValue": "близько {{seconds}} с", + "empty": "Рядків ще немає. Наступне відлуння заповнить перший.", + "kspaceAlt": "Сітка k-простору, заповнено рядків: {{lines}} з {{total}}", + "imageAlt": "Зріз голови, відновлений із рядків k-простору: {{lines}} з {{total}}" + } + }, + "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,3 × 10¹⁴ ядер в одному кубічному міліметрі води. Разом вони утворюють крихітний сумарний магніт. Саме з цією стрілкою й працює апарат.", + "p4": "Стрілки у вставці намальовано й дуже збільшено. Вони не показують справжньої точки в голові, а нахил на них набагато сильніший, ніж насправді, щоб його було видно. Натисніть кнопку відтворення, і стрілки закружляють. Малюнок дуже сильно сповільнено, а в показниках видно, у скільки разів. Це число залежить від швидкості відтворення. Перемкніть на 3 Тл: справжня частота й надлишок подвояться, а малюнок кружлятиме так само, тож і сповільнення подвоїться." + }, + "resonance": { + "title": "Радіоімпульс і його відлуння", + "p1": "Щоб сумарний магніт заговорив, апарат посилає короткий радіоімпульс точно на частоті Лармора. Коли імпульс отак потрапляє в такт кружлянню, це називають резонансом. Так само ви розгойдуєте дитину на гойдалці, коли підштовхуєте її саме вчасно. Імпульс посилає котушка для тіла, клітка з мідних перекладин одразу за стінкою тунелю. Він триває щонайбільше кілька мілісекунд.", + "p2": "Імпульс 90° нахиляє сумарний магніт, доки той не ляже набік. Лежачи так, він і далі кружляє. Щоразу, коли він проходить повз котушку для голови, у ній виникає крихітна напруга, яку вимірюють у мікровольтах. Ця напруга і є сигналом. Посуньте повзунок кута нахилу: сигнал дає лише поперечна частина, а частина вздовж поля не дає жодного.", + "p3": "Крихітні магніти ядер швидко збиваються з такту, бо деякі кружляють трохи швидше за інші. Сигнал згасає. На півдорозі до відлуння імпульс 180° перевертає їх. Тепер швидкі опиняються позаду й наздоганяють решту. Магніти знову входять у такт і дають відлуння. Час від імпульсу 90° до відлуння називають часом відлуння. Натискайте кнопки нижче, щоб зупинити модель у кожну з цих митей.", + "p4": "Яскравість кожної тканини визначають два «годинники». T1 показує, за скільки часу сумарний магніт відновлюється вздовж поля приблизно на 63 %. T2 показує, за скільки часу поперечний сигнал згасає приблизно до 37 %. Імпульс 180° усуває лише ті відмінності, що лишаються незмінними. Випадкові поштовхи між сусідніми ядрами нікуди не зникають, тож відлуння трохи слабшає. Саме цю втрату й описує T2. Жир відновлюється швидко, а ліквор, тобто мозкова рідина, повільно. Оберіть тканину, щоб порівняти. Значення для кожної тканини приблизні: їх підібрано для цієї моделі. Імпульси ще й трохи вас нагрівають, тому апарат тримає це нагрівання в безпечних межах." + }, + "gradients": { + "title": "Котушки, що розрізняють місця", + "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 мс. Тепер сяє рідина, сигнал якої згасає повільно, а сіра речовина здається світлішою за білу. У показниках видно, наскільки яскрава кожна тканина, а найяскравіша має 100 %. Голову тут намальовано простими овалами, це не справжній знімок. У цій моделі в кожній тканині однаково водню, а кістки й повітря лишаються чорними.", + "p4": "Якщо зчитувати один рядок за повторення, 256 рядків займуть приблизно 2,1 хвилини для T1-сканування й 10,7 хвилини для T2. Більшість сучасних знімків мозку роблять методом турбо спін-ехо: він зчитує від 4 до 32 рядків за одне повторення. Якщо зчитувати по 16 рядків, T2-сканування триває близько 40 секунд. Ця модель знімає один зріз, а справжнє сканування голови охоплює багато зрізів. Пол Лаутербур опублікував перше МР-зображення 1973 року. У 2003 році він разом із Пітером Менсфілдом отримав за це Нобелівську премію." + } + } +} diff --git a/explainers/mri-scanner/locales/zh.json b/explainers/mri-scanner/locales/zh.json new file mode 100644 index 00000000..e6954f54 --- /dev/null +++ b/explainers/mri-scanner/locales/zh.json @@ -0,0 +1,250 @@ +{ + "meta": { + "title": "磁共振扫描仪是怎样工作的", + "eyebrow": "医学影像", + "tagline": "一块巨大的磁体,一道无线电脉冲,再加上你体内的氢:扫描仪怎样听见微弱的回波,又怎样把它变成图像。", + "description": "用3D模型看懂磁共振扫描仪:永不关机的磁体、氢怎样回应无线电脉冲、咚咚作响的线圈,以及回波怎样变成图像。", + "summary": "超导磁体、倾斜的氢原子核、无线电脉冲与回波、梯度线圈,还有k空间。", + "socialAlt": "一台剖开的磁共振扫描仪,隧道外是层层相套的磁体结构,旁边是一张头部断层图像,还有标题“磁共振扫描仪是怎样工作的”。" + }, + "stage": { + "label": "磁共振扫描仪互动模型", + "gauge": "实时扫描读数" + }, + "timeline": { + "label": "一个重复周期内的时间", + "phases": "跳到某一步", + "speed": "播放速度", + "value": "第{{step}}步,共{{total}}步", + "during": "第{{step}}步,共{{total}}步:{{phase}}", + "speedValue": "{{value}}次/分钟", + "speedFormat": "每分钟重复{{value}}次", + "phase": { + "excite": "90°脉冲", + "encode": "编码", + "refocus": "180°脉冲", + "echo": "回波", + "recover": "恢复" + }, + "jump": { + "excite": "跳到90°脉冲", + "encode": "跳到编码", + "refocus": "跳到180°脉冲", + "echo": "跳到回波", + "recover": "跳到恢复阶段" + } + }, + "controls": { + "label": "模型控制", + "field": "磁场强度", + "weighting": "图像类型", + "fieldOptions": { + "field15": "1.5 T", + "field30": "3 T" + }, + "weightingOptions": { + "t1": "T1加权", + "t2": "T2加权" + }, + "tissueOptions": { + "fat": "脂肪", + "whiteMatter": "白质", + "greyMatter": "灰质", + "fluid": "脑脊液" + }, + "axisOptions": { + "x": "x线圈", + "y": "y线圈", + "z": "z线圈", + "sequence": "按扫描顺序" + }, + "momentOptions": { + "pulse90": "90°脉冲", + "pulse180": "180°脉冲", + "echoPeak": "回波峰值", + "repetitionEnd": "重复周期结束" + }, + "linesOptions": { + "8": "8行", + "16": "16行", + "32": "32行", + "64": "64行" + }, + "groups": { + "field": "磁场强度", + "weighting": "图像类型", + "tissue": "组织", + "axis": "梯度线圈", + "moment": "重复周期中的时刻", + "lines": "k空间行数" + }, + "views": { + "cutaway": { + "name": "剖面", + "short": "剖面", + "hint": "剖面(C)" + }, + "fieldLines": { + "name": "磁力线", + "short": "磁场", + "hint": "磁力线(F)" + }, + "labels": { + "name": "标注", + "short": "标注", + "hint": "标注(L)" + } + } + }, + "readouts": { + "larmor": "拉莫尔频率", + "time": "重复周期内的时间", + "lines": "k空间行数" + }, + "units": { + "mhz": "{{value}} MHz", + "msOf": "{{value}}/{{total}}毫秒", + "linesOf": "{{value}}/{{total}}", + "degrees": "{{value}}°", + "percent": "{{value}}%", + "ms": "{{value}}毫秒", + "tesla": "{{value}} T", + "kelvin": "{{kelvin}} K,{{celsius}} °C", + "kHz": "{{value}} kHz", + "mT": "{{value}} mT", + "cm": "{{value}}厘米" + }, + "chapters": { + "overview": { + "fieldLabel": "主磁场", + "field": "{{tesla}},一直开着", + "boreLabel": "隧道宽度", + "bore": "直径{{cm}}" + }, + "magnet": { + "earth": "与地球磁场相比", + "earthValue": "强{{min}}到{{max}}倍", + "helium": "液氦", + "fringe": "0.5 mT安全线", + "fringeValue": "沿隧道方向{{along}}米,侧面{{side}}米" + }, + "spins": { + "larmor": "拉莫尔频率", + "larmorBand": "{{value}},{{band}}", + "band": { + "belowFm": "略低于调频广播", + "aboveFm": "略高于调频广播" + }, + "surplus": "顺着磁场多出的核", + "surplusValue": "约百万分之{{perMillion}}", + "count": "1 mm³水中多出的核", + "countValue": "约{{mantissa}} × 10{{exponent}}个", + "slowdown": "画面放慢了", + "slowdownValue": "约{{factor}}倍" + }, + "resonance": { + "tip": "翻转角", + "across": "横向分量(信号)", + "along": "沿磁场的分量", + "t1": "T1,恢复", + "t2": "T2,衰减" + }, + "gradients": { + "edge": "头部边缘的变化", + "share": "占主磁场的比例", + "spread": "头部边缘的频率偏移", + "rise": "开启时间", + "role": "在扫描中的分工", + "roleValue": { + "x": "频率:分出左右", + "y": "相位:每个重复周期换一种扭转", + "z": "层面:选出薄薄一层", + "sequence": "每一步都在变" + } + }, + "picture": { + "kspace": "k空间,存下的回波", + "image": "图像", + "scanTime": "每个重复周期一行", + "scanTimeValue": "{{lines}}行需{{minutes}}分钟", + "turbo": "快速自旋回波,每个重复周期16行", + "turboValue": "约{{seconds}}秒", + "empty": "一行都还没填。下一个回波会填进第一行。", + "kspaceAlt": "k空间网格,已填入{{lines}}行,共{{total}}行", + "imageAlt": "头部断层图像,由{{total}}行中的{{lines}}行重建而成" + } + }, + "parts": { + "room": "铜板屏蔽室", + "cover": "外壳", + "vacuumVessel": "真空容器", + "radiationShield": "隔热屏", + "heliumVessel": "液氦容器", + "mainCoils": "主磁体线圈", + "shieldCoils": "屏蔽线圈", + "shims": "匀场片", + "coldHead": "冷头", + "quenchPipe": "失超管", + "gradientX": "x梯度线圈", + "gradientY": "y梯度线圈", + "gradientZ": "z梯度线圈", + "bodyCoil": "体线圈", + "bore": "孔腔", + "table": "检查床", + "patient": "患者", + "headCoil": "头线圈", + "sliceSlab": "层面", + "spinArrows": "氢原子核", + "netMagnet": "净磁化矢量", + "mainField": "主磁场", + "rfWave": "无线电脉冲", + "echoWave": "回波", + "fieldLinesGroup": "磁力线", + "fringeLine": "0.5 mT线", + "screen": "图像" + }, + "sections": { + "overview": { + "title": "一条倾听你身体的隧道", + "p1": "这是一台磁共振扫描仪,英文简称MRI,意思是“磁共振成像”。它的磁体强度是1.5特斯拉。特斯拉是衡量磁场强弱的单位。你躺在检查床上,床会把你送进孔腔,也就是一条宽约70厘米的隧道。包在隧道外面的机器长约1.6到1.9米,重3到6吨。", + "p2": "做脑部扫描时,头线圈会像笼子一样罩住你的头。它其实是一副天线,装着20到64个小接收器,专门收听你身体发出的微弱无线电回波。房间内壁衬着铜板,这样外面的无线电波就盖不过这个回波。这里也不用X射线。扫描仪用的无线电波能量很低,大约只有把电子从原子里打出来所需能量的五千万分之一。", + "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特斯拉,和更强的磁体比一比。磁体周围的磁力线画出了磁场的形状。外侧还绕着屏蔽线圈,电流方向相反,把漏到外面的磁场拉回来。即便这样,沿隧道方向直到约4米远的地方,磁场仍高于0.5 mT,也就是两千分之一特斯拉。在这个范围内,心脏起搏器可能无法安全工作。地上的虚线圈就是它的边界。", + "p4": "磁体永远不关,所以门口有一块常亮的警示牌,提醒大家磁场一直都在。任何含铁的东西都可能被猛地吸进隧道,氧气瓶就曾这样夺去人命。因此工作人员会在门口检查每个人、每样东西。要是导线某处升温,那里就不再超导,电流会变成热量。这叫失超。磁场在2到5秒内降到零,沸腾的氦顺着失超管呼啸着冲出楼外。" + }, + "spins": { + "title": "氢核会倾斜,也会摆动", + "p1": "你体内大约六成的原子是氢,大多在水和脂肪里。氢原子的核心,也就是它的原子核,只是一个质子。它就像一块小磁铁。磁共振专门听氢的信号,因为氢到处都有,信号也最强。", + "p2": "放进磁场后,这些小磁铁并不只是顺着磁场方向摆好。它们会进动:像一只倾斜着旋转的陀螺,绕着磁场方向摆动。在1.5特斯拉的磁场里,它们每秒摆动约6400万次,也就是63.9 MHz(兆赫)。这个速率叫拉莫尔频率,可以把它想成摆动的音高。磁场越强,频率越高,到3特斯拉时升到127.7 MHz。这两个频率都挨着调频广播:一个略低于调频波段,一个略高于它。", + "p3": "这些原子核并没有全都排整齐。体温让它们不停地晃动,所以几乎朝向四面八方。顺着磁场倾斜的核,只比逆着磁场的多出约百万分之五。不过,在一立方毫米的水里,多出来的核加起来仍有约3.3 × 10¹⁴个。它们合起来,形成一个微小的净磁化矢量,你可以把它看成一个箭头。扫描仪要用的就是这个箭头。", + "p4": "插图里的箭头只是示意,放大了很多倍,画的并不是头部某个真实的位置。为了让你看清,倾斜也画得比实际明显得多。按播放键,看它们转起来。画面大大放慢了,放慢了多少倍,看读数就知道。这个倍数会随播放速度变化。切换到3特斯拉:真实的频率和多出的核都会翻一倍,但画面的节奏不变,所以放慢的倍数也翻一倍。" + }, + "resonance": { + "title": "无线电脉冲与回波", + "p1": "要让净磁化矢量“开口说话”,扫描仪会发出一道短促的无线电脉冲(也叫射频脉冲),频率正好等于拉莫尔频率。这样踩准摆动的节拍,就叫共振。就像推荡秋千的孩子,要在恰好的时刻推一把。脉冲由体线圈发出。体线圈是一圈铜条围成的笼子,就装在隧道内侧。脉冲最多持续几毫秒。", + "p2": "一道90°脉冲会把净磁化矢量放倒,让它横躺下来,继续摆动。它扫过头线圈时,会在线圈里产生微小的电压,小到要用微伏来计量。这个电压就是信号。拖动翻转角滑块看看:只有横向分量会产生信号,沿磁场的分量不会。", + "p3": "这些小磁铁很快就会乱了步调,因为有些摆得稍快,有些稍慢。信号随之减弱。在90°脉冲和回波的正中间,一道180°脉冲把它们整个翻过来。这下快的反而落在后面,于是又追了上来。小磁铁们重新同步,发出一个回波。从90°脉冲到回波的这段时间,叫回波时间。点下面的按钮,可以停在每个时刻看看。", + "p4": "有两个“时钟”决定每种组织看上去有多亮。T1是净磁化矢量沿磁场方向恢复约63%所需的时间。T2是横向信号衰减到约37%所需的时间。180°翻转只能抵消始终不变的快慢差别。相邻原子核之间随机的推搡却还在,所以回波会弱一些。这部分损失就是T2。脂肪恢复得快,脑脊液恢复得慢。选一种组织比较一下。每种组织的数值都是为这个模型取的粗略数字。脉冲还会让你的身体微微发热,所以扫描仪会把升温控制在安全范围内。" + }, + "gradients": { + "title": "分辨位置的线圈", + "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加权扫描让T1的差别主导亮暗:它每500毫秒重复一次,在15毫秒时就早早去听回波。每个重复周期结束时,恢复慢的组织还没恢复过来,所以脂肪亮,脑脊液暗。T2加权扫描则让T2主导:它要等2500毫秒,到100毫秒时才听。这时衰减得慢的脑脊液亮了起来,灰质也比白质显得亮。读数显示每种组织看上去有多亮,最亮的记为100%。这里的头部是用椭圆拼成的简单示意图,不是真实的扫描。在这个模型里,每种组织含的氢一样多,骨头和空气始终是黑的。", + "p4": "如果每个重复周期只读一行,拍满256行,T1扫描约需2.1分钟,T2扫描约需10.7分钟。如今大多数脑部扫描都用快速自旋回波,每个重复周期读4到32行。每次读16行时,T2扫描只需约40秒。这个模型只拍一层,而真实的头部扫描要拍很多层。保罗·劳特伯在1973年发表了第一张磁共振图像。他和彼得·曼斯菲尔德因这项工作,共同获得了2003年诺贝尔奖。" + } + } +} diff --git a/explainers/mri-scanner/public/cover.webp b/explainers/mri-scanner/public/cover.webp new file mode 100644 index 00000000..28f1569c Binary files /dev/null and b/explainers/mri-scanner/public/cover.webp differ diff --git a/explainers/mri-scanner/public/social/og-image.png b/explainers/mri-scanner/public/social/og-image.png new file mode 100644 index 00000000..13d65ac5 Binary files /dev/null and b/explainers/mri-scanner/public/social/og-image.png differ diff --git a/explainers/mri-scanner/src/ids.test.ts b/explainers/mri-scanner/src/ids.test.ts new file mode 100644 index 00000000..2e6cf648 --- /dev/null +++ b/explainers/mri-scanner/src/ids.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + AXIS_CHOICE_IDS, + FOLLOW_SEQUENCE, + GRADIENT_AXIS_IDS, + MOMENT_IDS, + PART_IDS, + PHASE_IDS, + PRESET_IDS, +} from './ids'; + +describe('ids', () => { + it('names every part once', () => { + expect(PART_IDS).toHaveLength(27); + expect(new Set(PART_IDS).size).toBe(PART_IDS.length); + }); + + it('orders the steps of one repetition and the chapters', () => { + expect(PHASE_IDS).toEqual(['excite', 'encode', 'refocus', 'echo', 'recover']); + expect(MOMENT_IDS).toEqual(['pulse90', 'pulse180', 'echoPeak', 'repetitionEnd']); + expect(PRESET_IDS).toEqual([ + 'overview', + 'magnet', + 'spins', + 'resonance', + 'gradients', + 'picture', + ]); + }); + + it('offers the three axes plus following the sequence', () => { + expect(AXIS_CHOICE_IDS).toEqual([...GRADIENT_AXIS_IDS, FOLLOW_SEQUENCE]); + }); +}); diff --git a/explainers/mri-scanner/src/ids.ts b/explainers/mri-scanner/src/ids.ts new file mode 100644 index 00000000..2d1e911a --- /dev/null +++ b/explainers/mri-scanner/src/ids.ts @@ -0,0 +1,143 @@ +export const PART_IDS = [ + 'room', + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'shims', + 'coldHead', + 'quenchPipe', + 'gradientX', + 'gradientY', + 'gradientZ', + 'bodyCoil', + 'bore', + 'table', + 'patient', + 'headCoil', + 'sliceSlab', + 'spinArrows', + 'netMagnet', + 'mainField', + 'rfWave', + 'echoWave', + 'fieldLinesGroup', + 'fringeLine', + 'screen', +] as const; + +export type PartId = (typeof PART_IDS)[number]; + +export const PHASE_IDS = ['excite', 'encode', 'refocus', 'echo', 'recover'] as const; + +export type PhaseId = (typeof PHASE_IDS)[number]; + +export const MOMENT_IDS = ['pulse90', 'pulse180', 'echoPeak', 'repetitionEnd'] as const; + +export type MomentId = (typeof MOMENT_IDS)[number]; + +export const PRESET_IDS = [ + 'overview', + 'magnet', + 'spins', + 'resonance', + 'gradients', + 'picture', +] as const; + +export type PresetId = (typeof PRESET_IDS)[number]; + +export const FIELD_IDS = ['field15', 'field30'] as const; + +export type FieldId = (typeof FIELD_IDS)[number]; + +export const WEIGHTING_IDS = ['t1', 't2'] as const; + +export type WeightingId = (typeof WEIGHTING_IDS)[number]; + +export const TISSUE_IDS = ['fat', 'whiteMatter', 'greyMatter', 'fluid'] as const; + +export type TissueId = (typeof TISSUE_IDS)[number]; + +export const GRADIENT_AXIS_IDS = ['x', 'y', 'z'] as const; + +export type GradientAxisId = (typeof GRADIENT_AXIS_IDS)[number]; + +export const CHOICE_IDS = ['field', 'weighting'] as const; + +export type ChoiceId = (typeof CHOICE_IDS)[number]; + +export const FOLLOW_SEQUENCE = 'sequence'; + +export const AXIS_CHOICE_IDS = [...GRADIENT_AXIS_IDS, FOLLOW_SEQUENCE] as const; + +export type AxisChoiceId = (typeof AXIS_CHOICE_IDS)[number]; + +export const LINE_CHOICE_IDS = ['8', '16', '32', '64'] as const; + +export type LineChoiceId = (typeof LINE_CHOICE_IDS)[number]; + +export const CHAPTER_ACTION_IDS = [ + 'field', + 'weighting', + 'tissue', + 'gradientAxis', + 'moment', + 'lines', +] as const; + +export type ChapterActionId = (typeof CHAPTER_ACTION_IDS)[number]; + +export type CameraView = 'room' | 'cryostat' | 'voxel' | 'coil' | 'gradient' | 'console'; + +export type RegionId = 'room' | 'scanner' | 'layers' | 'voxel' | 'bore' | 'console'; + +export type AnchorId = 'isocentre' | 'voxel' | 'headCoil' | 'screen' | 'coldHead'; + +export type Point = readonly [x: number, y: number, z: number]; + +export type RfPulse = 0 | 90 | 180; + +export interface Fringe { + along: number; + side: number; +} + +export interface SequenceReading { + phase: number; + step: PhaseId; + rf: RfPulse | null; + gradient: GradientAxisId | null; + gradientLevel: number; + echo: number; +} + +export interface SpinReading { + net: Point; + arrows: readonly Point[]; + precession: number; +} + +export interface ViewOptions { + cutaway: boolean; + fieldLines: boolean; + voxel: boolean; + labels: boolean; +} + +export interface AssemblyState { + phase: number; + playing: boolean; + field: FieldId; + weighting: WeightingId; + sequence: SequenceReading; + spins: SpinReading; + tissue: TissueId; + gradientAxis: GradientAxisId | null; + picture: Float32Array; + pictureVersion: number; + fringe: Fringe; + view: ViewOptions; +} diff --git a/explainers/mri-scanner/src/index.ts b/explainers/mri-scanner/src/index.ts new file mode 100644 index 00000000..006f86c5 --- /dev/null +++ b/explainers/mri-scanner/src/index.ts @@ -0,0 +1,28 @@ +import './style.css'; +import { defineExplainer } from '@core/explainer'; +import { SCENE_OPTIONS, mountMriScannerScene } from './scene'; +import { PART_INFO } from './scene/partInfo'; +import { PRESETS, createMriScannerStore } from './state'; +import type { MriScannerStoreState } from './state'; +import { MRI_SCANNER_TIMELINE } from './timeline'; +import { + CHAPTER_ACTIONS, + MRI_SCANNER_CHOICES, + MRI_SCANNER_READOUTS, + VIEW_TOGGLES, + mountMriScannerUi, +} from './ui'; + +export default defineExplainer({ + id: 'mri-scanner', + timeline: MRI_SCANNER_TIMELINE, + presets: PRESETS, + parts: PART_INFO, + createStore: () => createMriScannerStore(), + dock: { choices: MRI_SCANNER_CHOICES, toggles: VIEW_TOGGLES }, + readouts: MRI_SCANNER_READOUTS, + actions: CHAPTER_ACTIONS, + scene: SCENE_OPTIONS, + mountScene: mountMriScannerScene, + mountUi: mountMriScannerUi, +}); diff --git a/explainers/mri-scanner/src/model/constants.test.ts b/explainers/mri-scanner/src/model/constants.test.ts new file mode 100644 index 00000000..1657a8a4 --- /dev/null +++ b/explainers/mri-scanner/src/model/constants.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + BODY_K, + EARTH_UT, + GAMMA_MHZ_PER_T, + HELIUM_K, + KELVIN_AT_ZERO_CELSIUS, + MODEL_SIZE, + REAL_LINES, +} from './constants'; + +describe('constants', () => { + it('takes the gyromagnetic ratio of hydrogen from the facts sheet', () => { + expect(GAMMA_MHZ_PER_T).toBeCloseTo(42.58, 2); + }); + + it('puts the body at 37 °C and liquid helium at about −269 °C', () => { + expect(BODY_K - KELVIN_AT_ZERO_CELSIUS).toBeCloseTo(37, 9); + expect(HELIUM_K - KELVIN_AT_ZERO_CELSIUS).toBeCloseTo(-268.95, 2); + }); + + it('keeps the Earth field band and the picture sizes', () => { + expect(EARTH_UT).toEqual({ min: 25, typical: 50, max: 65 }); + expect(REAL_LINES).toBe(256); + expect(MODEL_SIZE).toBe(64); + }); +}); diff --git a/explainers/mri-scanner/src/model/constants.ts b/explainers/mri-scanner/src/model/constants.ts new file mode 100644 index 00000000..818d5e25 --- /dev/null +++ b/explainers/mri-scanner/src/model/constants.ts @@ -0,0 +1,19 @@ +export const GAMMA_MHZ_PER_T = 42.577478; +export const PLANCK = 6.62607015e-34; +export const BOLTZMANN = 1.380649e-23; +export const BODY_K = 310.15; +export const EARTH_UT = { min: 25, typical: 50, max: 65 } as const; +export const HELIUM_K = 4.2; +export const HYDROGEN_PER_MM3 = 6.7e19; +export const GRADIENT_MT_PER_M = 45; +export const SLEW_T_PER_M_S = 200; +export const HEAD_HALF_FOV_M = 0.12; +export const REAL_LINES = 256; +export const MODEL_SIZE = 64; +export const DISPLAY_TURNS_PER_S = 0.5; + +export const HZ_PER_MHZ = 1e6; +export const TESLA_PER_UT = 1e-6; +export const MS_PER_S = 1000; +export const SECONDS_PER_MINUTE = 60; +export const KELVIN_AT_ZERO_CELSIUS = 273.15; diff --git a/explainers/mri-scanner/src/model/field.test.ts b/explainers/mri-scanner/src/model/field.test.ts new file mode 100644 index 00000000..a9d3ea17 --- /dev/null +++ b/explainers/mri-scanner/src/model/field.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { FIELDS, earthMultiple, larmorMHz, slowdown, spinSurplus, surplusPerMm3 } from './field'; + +describe('main field', () => { + it('compares a 1.5 T and a 3 T magnet with their 0.5 mT lines', () => { + expect(FIELDS.field15).toEqual({ tesla: 1.5, fringe: { along: 4.0, side: 2.5 } }); + expect(FIELDS.field30).toEqual({ tesla: 3, fringe: { along: 5.2, side: 2.8 } }); + }); + + it('makes hydrogen precess at 63.87 MHz and 127.73 MHz', () => { + expect(larmorMHz('field15')).toBeCloseTo(63.87, 2); + expect(larmorMHz('field30')).toBeCloseTo(127.73, 2); + }); + + it('leans about 4.9 and 9.9 spins in a million the field way', () => { + expect(spinSurplus('field15') * 1e6).toBeCloseTo(4.9, 1); + expect(spinSurplus('field30') * 1e6).toBeCloseTo(9.9, 1); + }); + + it('still adds to about 3 × 10¹⁴ spare spins per cubic millimetre at 1.5 T', () => { + expect(surplusPerMm3('field15')).toBeGreaterThan(3e14); + expect(surplusPerMm3('field15')).toBeLessThan(3.5e14); + expect(surplusPerMm3('field30')).toBeCloseTo(2 * surplusPerMm3('field15'), -10); + }); + + it('is tens of thousands of times the Earth field', () => { + const times = earthMultiple('field15'); + expect(times.min).toBeCloseTo(23077, 0); + expect(times.typical).toBeCloseTo(30000, 6); + expect(times.max).toBeCloseTo(60000, 6); + expect(earthMultiple('field30').typical).toBeCloseTo(60000, 6); + }); + + it('draws the precession about 128 million times slower than real at 1.5 T', () => { + expect(slowdown('field15') / 1e6).toBeCloseTo(127.7, 1); + expect(slowdown('field30') / 1e6).toBeCloseTo(255.5, 1); + }); +}); diff --git a/explainers/mri-scanner/src/model/field.ts b/explainers/mri-scanner/src/model/field.ts new file mode 100644 index 00000000..56a65ad5 --- /dev/null +++ b/explainers/mri-scanner/src/model/field.ts @@ -0,0 +1,53 @@ +import type { FieldId, Fringe } from '../ids'; +import { + BODY_K, + BOLTZMANN, + DISPLAY_TURNS_PER_S, + EARTH_UT, + GAMMA_MHZ_PER_T, + HYDROGEN_PER_MM3, + HZ_PER_MHZ, + PLANCK, + TESLA_PER_UT, +} from './constants'; + +export interface FieldSpec { + tesla: number; + fringe: Fringe; +} + +export interface EarthMultiple { + min: number; + typical: number; + max: number; +} + +export const FIELDS: Readonly> = { + field15: { tesla: 1.5, fringe: { along: 4.0, side: 2.5 } }, + field30: { tesla: 3, fringe: { along: 5.2, side: 2.8 } }, +}; + +export function larmorMHz(field: FieldId): number { + return GAMMA_MHZ_PER_T * FIELDS[field].tesla; +} + +export function larmorHz(field: FieldId): number { + return larmorMHz(field) * HZ_PER_MHZ; +} + +export function spinSurplus(field: FieldId): number { + return (PLANCK * larmorHz(field)) / (2 * BOLTZMANN * BODY_K); +} + +export function surplusPerMm3(field: FieldId): number { + return HYDROGEN_PER_MM3 * spinSurplus(field); +} + +export function earthMultiple(field: FieldId): EarthMultiple { + const times = (microtesla: number): number => FIELDS[field].tesla / (microtesla * TESLA_PER_UT); + return { min: times(EARTH_UT.max), typical: times(EARTH_UT.typical), max: times(EARTH_UT.min) }; +} + +export function slowdown(field: FieldId): number { + return larmorHz(field) / DISPLAY_TURNS_PER_S; +} diff --git a/explainers/mri-scanner/src/model/gradient.test.ts b/explainers/mri-scanner/src/model/gradient.test.ts new file mode 100644 index 00000000..698ea069 --- /dev/null +++ b/explainers/mri-scanner/src/model/gradient.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + edgeShiftMT, + frequencySpreadKHz, + gradientLevel, + RAMP_UNITS, + riseTimeMs, + shareOfField, +} from './gradient'; +import { PHASE_RANGES } from './sequence'; + +describe('gradient figures', () => { + it('shifts the field by 5.4 mT at the edge of a 24 cm head', () => { + expect(edgeShiftMT()).toBeCloseTo(5.4, 10); + }); + + it('is a small share of the main field', () => { + expect(shareOfField('field15')).toBeCloseTo(0.36, 10); + expect(shareOfField('field30')).toBeCloseTo(0.18, 10); + }); + + it('spreads the frequency by about 230 kHz', () => { + expect(frequencySpreadKHz()).toBeCloseTo(229.9, 1); + }); + + it('rises in 0.225 ms', () => { + expect(riseTimeMs()).toBeCloseTo(0.225, 10); + }); +}); + +describe('gradient level', () => { + it('is off while the spins recover', () => { + expect(gradientLevel(800)).toBe(0); + }); + + it('ramps up and down at the edges of each step', () => { + const [start, end] = PHASE_RANGES.encode; + expect(gradientLevel(start)).toBe(0); + expect(gradientLevel(start + RAMP_UNITS / 2)).toBeCloseTo(0.5, 10); + expect(gradientLevel((start + end) / 2)).toBe(1); + expect(gradientLevel(end - RAMP_UNITS / 4)).toBeCloseTo(0.25, 10); + }); +}); diff --git a/explainers/mri-scanner/src/model/gradient.ts b/explainers/mri-scanner/src/model/gradient.ts new file mode 100644 index 00000000..0330df14 --- /dev/null +++ b/explainers/mri-scanner/src/model/gradient.ts @@ -0,0 +1,40 @@ +import { clamp } from '@core/math'; +import type { FieldId } from '../ids'; +import { + GAMMA_MHZ_PER_T, + GRADIENT_MT_PER_M, + HEAD_HALF_FOV_M, + MS_PER_S, + SLEW_T_PER_M_S, +} from './constants'; +import { FIELDS } from './field'; +import { activeGradient, PHASE_RANGES, phaseOf } from './sequence'; + +const MT_PER_T = 1000; +const KHZ_PER_MHZ = 1000; +const PERCENT = 100; +export const RAMP_UNITS = 12; + +export function edgeShiftMT(): number { + return GRADIENT_MT_PER_M * HEAD_HALF_FOV_M; +} + +export function shareOfField(field: FieldId): number { + return (edgeShiftMT() / (FIELDS[field].tesla * MT_PER_T)) * PERCENT; +} + +export function frequencySpreadKHz(): number { + return GAMMA_MHZ_PER_T * (edgeShiftMT() / MT_PER_T) * KHZ_PER_MHZ; +} + +export function riseTimeMs(): number { + return (GRADIENT_MT_PER_M / MT_PER_T / SLEW_T_PER_M_S) * MS_PER_S; +} + +export function gradientLevel(phase: number): number { + if (activeGradient(phase) === null) return 0; + const [start, end] = PHASE_RANGES[phaseOf(phase)]; + const rampUp = (phase - start) / RAMP_UNITS; + const rampDown = (end - phase) / RAMP_UNITS; + return clamp(Math.min(rampUp, rampDown), 0, 1); +} diff --git a/explainers/mri-scanner/src/model/index.ts b/explainers/mri-scanner/src/model/index.ts new file mode 100644 index 00000000..8505d250 --- /dev/null +++ b/explainers/mri-scanner/src/model/index.ts @@ -0,0 +1,11 @@ +export * from './constants'; +export * from './field'; +export * from './gradient'; +export * from './kspace'; +export * from './layout'; +export * from './phantom'; +export * from './scale'; +export * from './sequence'; +export * from './signal'; +export * from './spins'; +export * from './tissues'; diff --git a/explainers/mri-scanner/src/model/kspace.test.ts b/explainers/mri-scanner/src/model/kspace.test.ts new file mode 100644 index 00000000..22c27b24 --- /dev/null +++ b/explainers/mri-scanner/src/model/kspace.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { MODEL_SIZE } from './constants'; +import { kspaceDisplay, kspaceOf, lineOrder, pictureFor, reconstruct } from './kspace'; +import { phantomImage } from './phantom'; + +const PARTIAL_LINES = 16; +const TOLERANCE = 1e-5; +const CENTROID_TOLERANCE = 0.5; +const HALF = MODEL_SIZE / 2; + +function normalised(image: Float32Array): Float32Array { + const brightest = Math.max(...image); + return image.map((value) => value / brightest); +} + +function edgeEnergy(image: Float32Array): number { + let energy = 0; + for (let row = 0; row < MODEL_SIZE; row += 1) { + for (let column = 1; column < MODEL_SIZE; column += 1) { + const pixel = row * MODEL_SIZE + column; + energy += (image[pixel] - image[pixel - 1]) ** 2; + if (row > 0) energy += (image[pixel] - image[pixel - MODEL_SIZE]) ** 2; + } + } + return energy; +} + +function centroid(image: Float32Array): [x: number, y: number] { + let total = 0; + let x = 0; + let y = 0; + image.forEach((value, pixel) => { + total += value; + x += value * (pixel % MODEL_SIZE); + y += value * Math.floor(pixel / MODEL_SIZE); + }); + return [x / total, y / total]; +} + +describe('line order', () => { + it('fills k-space from the centre outwards', () => { + const order = lineOrder(); + expect(order.slice(0, 5)).toEqual([0, 1, -1, 2, -2]); + expect(order).toHaveLength(MODEL_SIZE); + expect(new Set(order).size).toBe(MODEL_SIZE); + expect(order.at(-1)).toBe(-HALF); + }); +}); + +describe('reconstruction', () => { + const phantom = phantomImage('field15', 't2'); + const kspace = kspaceOf(phantom); + + it('gives back the phantom from a full k-space', () => { + const picture = reconstruct(kspace, MODEL_SIZE); + const expected = normalised(phantom); + picture.forEach((value, pixel) => + expect(Math.abs(value - expected[pixel])).toBeLessThan(TOLERANCE), + ); + }); + + it('blurs but does not move the head with only the middle lines', () => { + const full = reconstruct(kspace, MODEL_SIZE); + const partial = reconstruct(kspace, PARTIAL_LINES); + expect(edgeEnergy(partial)).toBeLessThan(edgeEnergy(full)); + const [fullX, fullY] = centroid(full); + const [partialX, partialY] = centroid(partial); + expect(Math.abs(partialX - fullX)).toBeLessThan(CENTROID_TOLERANCE); + expect(Math.abs(partialY - fullY)).toBeLessThan(CENTROID_TOLERANCE); + }); + + it('stays dark before the first line', () => { + expect(Math.max(...reconstruct(kspace, 0))).toBe(0); + }); +}); + +describe('memoised pictures', () => { + it('returns the same array for the same settings', () => { + expect(pictureFor('field30', 't1', PARTIAL_LINES)).toBe( + pictureFor('field30', 't1', PARTIAL_LINES), + ); + expect(kspaceDisplay('field30', 't1', PARTIAL_LINES)).toBe( + kspaceDisplay('field30', 't1', PARTIAL_LINES), + ); + }); + + it('shows filled lines around the centre on a 0 to 1 scale', () => { + const display = kspaceDisplay('field15', 't2', 1); + expect(display[HALF * MODEL_SIZE + HALF]).toBe(1); + expect(display[0]).toBe(0); + expect(display[(HALF + 1) * MODEL_SIZE + HALF]).toBe(0); + for (const value of display) expect(value).toBeLessThanOrEqual(1); + }); +}); diff --git a/explainers/mri-scanner/src/model/kspace.ts b/explainers/mri-scanner/src/model/kspace.ts new file mode 100644 index 00000000..21b86659 --- /dev/null +++ b/explainers/mri-scanner/src/model/kspace.ts @@ -0,0 +1,148 @@ +import { clamp } from '@core/math'; +import FFT from 'fft.js'; +import type { FieldId, WeightingId } from '../ids'; +import { MODEL_SIZE } from './constants'; +import { phantomImage } from './phantom'; + +interface ComplexTransform { + transform(out: Float64Array, data: Float64Array): void; + inverseTransform(out: Float64Array, data: Float64Array): void; +} + +type Direction = 'forward' | 'inverse'; + +const SIZE = MODEL_SIZE; +const HALF = SIZE / 2; +const PARTS = 2; +const PIXELS = SIZE * SIZE; + +const transformer: ComplexTransform = new FFT(SIZE); +const lineIn = new Float64Array(SIZE * PARTS); +const lineOut = new Float64Array(SIZE * PARTS); + +const kspaceCache = new Map(); +const pictureCache = new Map(); +const displayCache = new Map(); + +function memoised(cache: Map, key: string, build: () => T): T { + const cached = cache.get(key); + if (cached !== undefined) return cached; + const built = build(); + cache.set(key, built); + return built; +} + +function transformLine( + data: Float64Array, + start: number, + stride: number, + direction: Direction, +): void { + for (let step = 0; step < SIZE; step += 1) { + const at = (start + step * stride) * PARTS; + lineIn[step * PARTS] = data[at]; + lineIn[step * PARTS + 1] = data[at + 1]; + } + if (direction === 'forward') transformer.transform(lineOut, lineIn); + else transformer.inverseTransform(lineOut, lineIn); + for (let step = 0; step < SIZE; step += 1) { + const at = (start + step * stride) * PARTS; + data[at] = lineOut[step * PARTS]; + data[at + 1] = lineOut[step * PARTS + 1]; + } +} + +function transform2d(source: Float64Array, direction: Direction): Float64Array { + const data = Float64Array.from(source); + for (let row = 0; row < SIZE; row += 1) transformLine(data, row * SIZE, 1, direction); + for (let column = 0; column < SIZE; column += 1) transformLine(data, column, SIZE, direction); + return data; +} + +function magnitudeAt(data: Float64Array, pixel: number): number { + return Math.hypot(data[pixel * PARTS], data[pixel * PARTS + 1]); +} + +function storedRow(offset: number): number { + return (offset + SIZE) % SIZE; +} + +function filledRows(linesFilled: number): ReadonlySet { + const count = clamp(Math.floor(linesFilled), 0, SIZE); + return new Set(lineOrder().slice(0, count).map(storedRow)); +} + +function rowOf(pixel: number): number { + return Math.floor(pixel / SIZE); +} + +function keyOf(...parts: readonly (string | number)[]): string { + return parts.join('|'); +} + +export function kspaceOf(image: Float32Array): Float64Array { + const complex = new Float64Array(PIXELS * PARTS); + image.forEach((value, pixel) => { + complex[pixel * PARTS] = value; + }); + return transform2d(complex, 'forward'); +} + +export function lineOrder(): number[] { + const pairs = Array.from({ length: HALF - 1 }, (_, index) => [index + 1, -(index + 1)]); + return [0, ...pairs.flat(), -HALF]; +} + +export function reconstruct(kspace: Float64Array, linesFilled: number): Float32Array { + const rows = filledRows(linesFilled); + const masked = kspace.map((value, index) => + rows.has(rowOf(Math.floor(index / PARTS))) ? value : 0, + ); + const image = transform2d(masked, 'inverse'); + const picture = Float32Array.from({ length: PIXELS }, (_, pixel) => magnitudeAt(image, pixel)); + const brightest = Math.max(...picture); + return brightest === 0 ? picture : picture.map((value) => value / brightest); +} + +function kspaceFor(field: FieldId, weighting: WeightingId): Float64Array { + return memoised(kspaceCache, keyOf(field, weighting), () => + kspaceOf(phantomImage(field, weighting)), + ); +} + +export function pictureFor( + field: FieldId, + weighting: WeightingId, + linesFilled: number, +): Float32Array { + return memoised(pictureCache, keyOf(field, weighting, linesFilled), () => + reconstruct(kspaceFor(field, weighting), linesFilled), + ); +} + +function shiftedPixel(pixel: number): number { + const row = (rowOf(pixel) + HALF) % SIZE; + const column = ((pixel % SIZE) + HALF) % SIZE; + return row * SIZE + column; +} + +function logDisplay(kspace: Float64Array, rows: ReadonlySet): Float32Array { + const levels = Float32Array.from({ length: PIXELS }, (_, pixel) => + Math.log1p(magnitudeAt(kspace, pixel)), + ); + const top = Math.max(...levels); + return Float32Array.from({ length: PIXELS }, (_, pixel) => { + const source = shiftedPixel(pixel); + return rows.has(rowOf(source)) && top > 0 ? levels[source] / top : 0; + }); +} + +export function kspaceDisplay( + field: FieldId, + weighting: WeightingId, + linesFilled: number, +): Float32Array { + return memoised(displayCache, keyOf(field, weighting, linesFilled), () => + logDisplay(kspaceFor(field, weighting), filledRows(linesFilled)), + ); +} diff --git a/explainers/mri-scanner/src/model/layout.test.ts b/explainers/mri-scanner/src/model/layout.test.ts new file mode 100644 index 00000000..cf4ac4d8 --- /dev/null +++ b/explainers/mri-scanner/src/model/layout.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTROL_WINDOW, + BODY_COIL, + BORE, + COLD_HEAD, + HEAD_COIL, + ISOCENTRE, + LAYERS, + MAGNET, + MAIN_COILS, + PATIENT, + QUENCH_PIPE, + REGIONS, + ROOM, + SCREEN, + SHIELD_COILS, + SHIMS, + SLICE_THICKNESS, + TABLE, + VOXEL, + VOXEL_ARROWS, + gradientShell, +} from './layout'; + +describe('scanner layout', () => { + it('builds a 1.7 m long magnet, 2.33 m tall, around a 70 cm bore at the isocentre', () => { + expect(ISOCENTRE).toEqual([0, 1.05, 0]); + expect(2 * MAGNET.halfLength).toBeCloseTo(1.7, 9); + expect(MAGNET.top).toBe(2.33); + expect(ISOCENTRE[1] - MAGNET.radius).toBe(0); + expect(2 * BORE.radius).toBeCloseTo(0.7, 9); + }); + + it('nests every layer inside the one around it', () => { + const order = [LAYERS.cover, LAYERS.vacuumVessel, LAYERS.radiationShield, LAYERS.heliumVessel]; + order.slice(1).forEach((shell, index) => { + expect(shell.outer).toBeLessThan(order[index].outer); + expect(shell.inner).toBeGreaterThanOrEqual(order[index].inner); + expect(shell.halfLength).toBeLessThan(order[index].halfLength); + }); + expect(LAYERS.gradientCoil.outer).toBe(LAYERS.vacuumVessel.inner); + expect(BORE.radius).toBeLessThan(BODY_COIL.radius); + expect(BODY_COIL.radius).toBeLessThan(LAYERS.gradientCoil.inner); + expect(SHIMS.radius).toBeGreaterThan(LAYERS.gradientCoil.outer); + expect(BODY_COIL.rungs).toBe(16); + }); + + it('keeps the coil windings inside the helium vessel', () => { + const helium = LAYERS.heliumVessel; + for (const coils of [MAIN_COILS, SHIELD_COILS]) { + expect(coils.inner).toBeGreaterThan(helium.inner); + expect(coils.outer).toBeLessThan(helium.outer); + coils.z.forEach((z) => expect(Math.abs(z) + coils.width / 2).toBeLessThan(helium.halfLength)); + } + expect(MAIN_COILS.z).toHaveLength(6); + expect(SHIELD_COILS.z).toEqual([-0.5, 0.5]); + }); + + it('stacks the gradient shells x outside, y in the middle, z inside', () => { + const [x, y, z] = (['x', 'y', 'z'] as const).map(gradientShell); + expect(x.outer).toBeCloseTo(LAYERS.gradientCoil.outer, 9); + expect(x.inner).toBeCloseTo(y.outer, 9); + expect(y.inner).toBeCloseTo(z.outer, 9); + expect(z.inner).toBeCloseTo(LAYERS.gradientCoil.inner, 9); + }); + + it('puts the cold head on top and the quench pipe up to the ceiling', () => { + expect(COLD_HEAD.centre).toEqual([0, 2.05, -0.35]); + expect(COLD_HEAD.top).toBe(MAGNET.top); + expect(QUENCH_PIPE.to).toBe(ROOM.height); + }); + + it('lays the head at the isocentre inside the head coil, feet out of the bore', () => { + expect(TABLE.top).toBe(0.92); + expect(TABLE.z).toEqual([0.85, 3.1]); + expect(PATIENT.head[1] - PATIENT.headRadius).toBeGreaterThan(TABLE.top); + expect(PATIENT.feetZ).toBe(1.75); + expect(HEAD_COIL).toMatchObject({ centre: ISOCENTRE, radius: 0.15, length: 0.3 }); + expect(HEAD_COIL.radius).toBeGreaterThan(PATIENT.headRadius); + expect(SLICE_THICKNESS).toBeCloseTo(0.005, 9); + }); + + it('places the screen, the voxel inset and the regions inside the room', () => { + [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)) { + expect(region.x[0]).toBeGreaterThanOrEqual(ROOM.x[0]); + expect(region.x[1]).toBeLessThanOrEqual(ROOM.x[1]); + expect(region.z[0]).toBeGreaterThanOrEqual(ROOM.z[0]); + expect(region.z[1]).toBeLessThanOrEqual(ROOM.z[1]); + expect(region.y[1]).toBeLessThanOrEqual(ROOM.height); + } + }); +}); diff --git a/explainers/mri-scanner/src/model/layout.ts b/explainers/mri-scanner/src/model/layout.ts new file mode 100644 index 00000000..db0783ad --- /dev/null +++ b/explainers/mri-scanner/src/model/layout.ts @@ -0,0 +1,160 @@ +import { toRadians } from '@core/math'; +import type { Extent, RegionSpec } from '@core/scene/regions'; +import type { GradientAxisId, Point, RegionId } from '../ids'; +import { GRADIENT_AXIS_IDS } from '../ids'; +import { MILLIMETRES_PER_METRE } from './scale'; + +export interface Shell { + inner: number; + outer: number; + halfLength: number; +} + +export interface CoilRings { + inner: number; + outer: number; + width: number; + z: readonly number[]; +} + +export type LayerId = + 'cover' | 'vacuumVessel' | 'radiationShield' | 'heliumVessel' | 'gradientCoil'; + +export const FLOOR_Y = 0; +export const CEILING_Y = 3.0; +export const ISOCENTRE: Point = [0, 1.05, 0]; +export const FIELD_DIRECTION: Point = [0, 0, -1]; + +export const MAGNET = { + halfLength: 0.85, + radius: 1.05, + top: 2.33, + baseHalfWidth: 0.75, +} as const; + +export const BORE = { radius: 0.35, halfLength: MAGNET.halfLength } as const; + +export const LAYERS: Readonly> = { + cover: { inner: BORE.radius, outer: MAGNET.radius, halfLength: MAGNET.halfLength }, + vacuumVessel: { inner: 0.46, outer: 0.98, halfLength: 0.8 }, + radiationShield: { inner: 0.52, outer: 0.9, halfLength: 0.76 }, + heliumVessel: { inner: 0.56, outer: 0.85, halfLength: 0.72 }, + gradientCoil: { inner: 0.39, outer: 0.46, halfLength: 0.7 }, +}; + +export const MAIN_COILS: CoilRings = { + inner: 0.62, + outer: 0.7, + width: 0.08, + z: [-0.62, -0.38, -0.12, 0.12, 0.38, 0.62], +}; + +export const SHIELD_COILS: CoilRings = { inner: 0.8, outer: 0.84, width: 0.08, z: [-0.5, 0.5] }; + +export const SHIMS = { + radius: 0.47, + count: 12, + width: 0.04, + thickness: 0.01, + halfLength: 0.6, +} as const; + +const GRADIENT_SHELL_ORDER: readonly GradientAxisId[] = ['z', 'y', 'x']; + +export function gradientShell(axis: GradientAxisId): Shell { + const { inner, outer, halfLength } = LAYERS.gradientCoil; + const thickness = (outer - inner) / GRADIENT_AXIS_IDS.length; + const from = inner + GRADIENT_SHELL_ORDER.indexOf(axis) * thickness; + return { inner: from, outer: from + thickness, halfLength }; +} + +export const BODY_COIL = { radius: 0.37, rungs: 16, halfLength: 0.3, rungRadius: 0.006 } as const; + +export const COLD_HEAD = { centre: [0, 2.05, -0.35] as Point, top: MAGNET.top, radius: 0.16 }; + +export const QUENCH_PIPE = { + x: COLD_HEAD.centre[0], + z: COLD_HEAD.centre[2], + from: COLD_HEAD.top, + to: CEILING_Y, + radius: 0.06, +} as const; + +export const TABLE = { + top: 0.92, + z: [0.85, 3.1] as Extent, + halfWidth: 0.3, + cradleZ: [-0.35, 3.1] as Extent, + cradleHalfWidth: 0.22, + cradleThickness: 0.04, +}; + +export const PATIENT = { head: ISOCENTRE, headRadius: 0.1, feetZ: 1.75, halfWidth: 0.22 } as const; + +export const HEAD_COIL = { centre: ISOCENTRE, radius: 0.15, length: 0.3 } as const; + +export const SLICE_THICKNESS = 5 / MILLIMETRES_PER_METRE; + +export const ROOM = { x: [-3, 3] as Extent, z: [-2, 3] as Extent, height: CEILING_Y }; + +export const CONTROL_WINDOW = { x: ROOM.x[1], centreZ: 1.0, width: 1.4, sill: 0.9, top: 2.0 }; + +const SCREEN_MOUNT = { wallStandoff: 0.34, windowGap: 0.6, height: 1.55 } as const; + +export const SCREEN = { + 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), +}; + +export const VOXEL = { centre: [0, 1.75, 1.35] as Point, size: 0.36, perSide: 4 } as const; + +export const VOXEL_ARROWS = VOXEL.perSide ** 3; + +export const VOXEL_LEADER: { from: Point; to: Point } = { + from: [VOXEL.centre[0], VOXEL.centre[1] - VOXEL.size / 2, VOXEL.centre[2]], + to: [ISOCENTRE[0], ISOCENTRE[1] + PATIENT.headRadius, ISOCENTRE[2]], +}; + +export const MAIN_FIELD_ARROW = { + tail: [VOXEL.centre[0] + 0.32, VOXEL.centre[1], VOXEL.centre[2] + 0.2] as Point, + length: 0.4, +}; + +export const REGIONS: Readonly> = { + room: { + x: [-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] - BORE.radius, MAGNET.radius], + y: [ISOCENTRE[1] - BORE.radius, MAGNET.top], + z: [-MAGNET.halfLength, MAGNET.halfLength], + }, + voxel: { + x: [VOXEL.centre[0] - VOXEL.size / 2, MAIN_FIELD_ARROW.tail[0]], + y: [VOXEL.centre[1] - VOXEL.size / 2, VOXEL.centre[1] + VOXEL.size / 2], + z: [VOXEL.centre[2] - VOXEL.size / 2, VOXEL.centre[2] + VOXEL.size / 2], + }, + bore: { + x: [-BORE.radius, BORE.radius], + y: [ISOCENTRE[1] - BORE.radius, ISOCENTRE[1] + BORE.radius], + z: [-BORE.halfLength, BORE.halfLength], + }, + console: { + x: [SCREEN.centre[0] - SCREEN.height, ROOM.x[1]], + y: [CONTROL_WINDOW.sill, CONTROL_WINDOW.top], + z: [SCREEN.centre[2] - SCREEN.width / 2, CONTROL_WINDOW.centreZ - CONTROL_WINDOW.width / 4], + }, +}; diff --git a/explainers/mri-scanner/src/model/phantom.test.ts b/explainers/mri-scanner/src/model/phantom.test.ts new file mode 100644 index 00000000..96e99739 --- /dev/null +++ b/explainers/mri-scanner/src/model/phantom.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { TISSUE_IDS } from '../ids'; +import { MODEL_SIZE } from './constants'; +import { phantomImage, phantomTissue } from './phantom'; +import { tissueSignals } from './signal'; + +const MIDDLE = MODEL_SIZE / 2; + +function columnThroughCentre(): (string | null)[] { + return Array.from({ length: MODEL_SIZE }, (_, y) => phantomTissue(MIDDLE, y)); +} + +describe('phantom head', () => { + it('leaves the corners as air', () => { + expect(phantomTissue(0, 0)).toBeNull(); + expect(phantomTissue(MODEL_SIZE - 1, MODEL_SIZE - 1)).toBeNull(); + }); + + it('nests scalp, skull, fluid, cortex and white matter from the edge inwards', () => { + const layers = columnThroughCentre().filter( + (tissue, index, all) => index === 0 || tissue !== all[index - 1], + ); + expect(layers.slice(0, 6)).toEqual([null, 'fat', null, 'fluid', 'greyMatter', 'whiteMatter']); + }); + + it('holds two fluid ventricles left and right of the middle', () => { + expect(phantomTissue(MIDDLE - 4, MIDDLE)).toBe('fluid'); + expect(phantomTissue(MIDDLE + 3, MIDDLE)).toBe('fluid'); + expect(phantomTissue(MIDDLE, MIDDLE)).toBe('whiteMatter'); + }); + + it('contains every tissue', () => { + const tissues = new Set( + columnThroughCentre().concat( + Array.from({ length: MODEL_SIZE }, (_, x) => phantomTissue(x, MIDDLE)), + ), + ); + for (const tissue of TISSUE_IDS) expect(tissues.has(tissue)).toBe(true); + }); + + it('paints each tissue with its signal', () => { + const image = phantomImage('field15', 't2'); + const signals = tissueSignals('field15', 't2'); + expect(image).toHaveLength(MODEL_SIZE * MODEL_SIZE); + expect(image[0]).toBe(0); + expect(image[MIDDLE * MODEL_SIZE + MIDDLE]).toBeCloseTo(signals.whiteMatter, 6); + }); +}); diff --git a/explainers/mri-scanner/src/model/phantom.ts b/explainers/mri-scanner/src/model/phantom.ts new file mode 100644 index 00000000..c6b0f164 --- /dev/null +++ b/explainers/mri-scanner/src/model/phantom.ts @@ -0,0 +1,60 @@ +import type { FieldId, TissueId, WeightingId } from '../ids'; +import { MODEL_SIZE } from './constants'; +import { tissueSignals } from './signal'; + +interface Ellipse { + centre: readonly [x: number, y: number]; + radii: readonly [x: number, y: number]; +} + +interface Layer { + shape: Ellipse; + tissue: TissueId | null; +} + +const HALF_SIZE = MODEL_SIZE / 2; +const PIXEL_CENTRE = 0.5; +const CENTRE = [0, 0] as const; + +const LAYERS: readonly Layer[] = [ + { shape: { centre: CENTRE, radii: [0.72, 0.9] }, tissue: 'fat' }, + { shape: { centre: CENTRE, radii: [0.66, 0.84] }, tissue: null }, + { shape: { centre: CENTRE, radii: [0.6, 0.78] }, tissue: 'fluid' }, + { shape: { centre: CENTRE, radii: [0.55, 0.73] }, tissue: 'greyMatter' }, + { shape: { centre: CENTRE, radii: [0.45, 0.62] }, tissue: 'whiteMatter' }, + { shape: { centre: [-0.11, -0.04], radii: [0.07, 0.22] }, tissue: 'fluid' }, + { shape: { centre: [0.11, -0.04], radii: [0.07, 0.22] }, tissue: 'fluid' }, +]; + +function toUnit(pixel: number): number { + return (pixel + PIXEL_CENTRE) / HALF_SIZE - 1; +} + +function contains({ centre, radii }: Ellipse, u: number, v: number): boolean { + const across = (u - centre[0]) / radii[0]; + const along = (v - centre[1]) / radii[1]; + return across * across + along * along <= 1; +} + +export function phantomTissue(x: number, y: number): TissueId | null { + const u = toUnit(x); + const v = toUnit(y); + return LAYERS.reduce( + (tissue, layer) => (contains(layer.shape, u, v) ? layer.tissue : tissue), + null, + ); +} + +let tissueMap: readonly (TissueId | null)[] | null = null; + +function phantomMap(): readonly (TissueId | null)[] { + tissueMap ??= Array.from({ length: MODEL_SIZE * MODEL_SIZE }, (_, index) => + phantomTissue(index % MODEL_SIZE, Math.floor(index / MODEL_SIZE)), + ); + return tissueMap; +} + +export function phantomImage(field: FieldId, weighting: WeightingId): Float32Array { + const signals = tissueSignals(field, weighting); + return Float32Array.from(phantomMap(), (tissue) => (tissue === null ? 0 : signals[tissue])); +} diff --git a/explainers/mri-scanner/src/model/scale.test.ts b/explainers/mri-scanner/src/model/scale.test.ts new file mode 100644 index 00000000..19abc4b4 --- /dev/null +++ b/explainers/mri-scanner/src/model/scale.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { BORE } from './layout'; +import { METRES_PER_UNIT, metresToCentimetres, metresToUnits } from './scale'; + +describe('scale', () => { + it('draws the machine at one metre per unit', () => { + expect(METRES_PER_UNIT).toBe(1); + expect(metresToUnits(1.7)).toBe(1.7); + }); + + it('turns the bore radius into a 70 cm tunnel', () => { + expect(metresToCentimetres(2 * BORE.radius)).toBeCloseTo(70, 9); + }); +}); diff --git a/explainers/mri-scanner/src/model/scale.ts b/explainers/mri-scanner/src/model/scale.ts new file mode 100644 index 00000000..1ba8ddd1 --- /dev/null +++ b/explainers/mri-scanner/src/model/scale.ts @@ -0,0 +1,11 @@ +export const METRES_PER_UNIT = 1; +export const CENTIMETRES_PER_METRE = 100; +export const MILLIMETRES_PER_METRE = 1000; + +export function metresToUnits(metres: number): number { + return metres / METRES_PER_UNIT; +} + +export function metresToCentimetres(metres: number): number { + return metres * CENTIMETRES_PER_METRE; +} diff --git a/explainers/mri-scanner/src/model/sequence.test.ts b/explainers/mri-scanner/src/model/sequence.test.ts new file mode 100644 index 00000000..0b4598c5 --- /dev/null +++ b/explainers/mri-scanner/src/model/sequence.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS, WEIGHTING_IDS } from '../ids'; +import { + CYCLE_UNITS, + DEFAULT_SPEED, + LINE_DONE_UNITS, + MOMENTS, + PHASE_RANGES, + PICTURE_SPEED, + SPEED_RANGE, + WEIGHTINGS, + activeGradient, + phaseAt, + phaseOf, + rate, + realMs, + rfPulse, + scanSeconds, +} from './sequence'; +import { MODEL_SIZE } from './constants'; + +const KNOT_UNITS = [0, 150, 300, 420, 535, 650, 1000]; +const KNOT_MS = { + t1: [0, 3, 6.5, 8.5, 15, 19, 500], + t2: [0, 3, 49, 51, 100, 104, 2500], +} as const; + +describe('spin echo sequence', () => { + it('uses TR 500 ms, TE 15 ms for T1 and TR 2,500 ms, TE 100 ms for T2', () => { + expect(WEIGHTINGS).toEqual({ t1: { tr: 500, te: 15 }, t2: { tr: 2500, te: 100 } }); + }); + + it('splits one repetition into five touching steps', () => { + expect(PHASE_RANGES.excite[0]).toBe(0); + PHASE_IDS.slice(1).forEach((id, index) => { + expect(PHASE_RANGES[id][0]).toBe(PHASE_RANGES[PHASE_IDS[index]][1]); + }); + expect(PHASE_RANGES.recover[1]).toBe(CYCLE_UNITS); + expect(LINE_DONE_UNITS).toBe(650); + }); + + it('puts each moment in its step', () => { + expect(MOMENTS).toEqual({ pulse90: 75, pulse180: 360, echoPeak: 535, repetitionEnd: 990 }); + expect(phaseOf(MOMENTS.pulse90)).toBe('excite'); + expect(phaseOf(MOMENTS.pulse180)).toBe('refocus'); + expect(phaseOf(MOMENTS.echoPeak)).toBe('echo'); + expect(phaseOf(MOMENTS.repetitionEnd)).toBe('recover'); + }); + + it.each(WEIGHTING_IDS)('maps the %s knots to real milliseconds and back', (weighting) => { + KNOT_UNITS.forEach((units, index) => { + expect(realMs(units, weighting)).toBeCloseTo(KNOT_MS[weighting][index], 9); + expect(phaseAt(KNOT_MS[weighting][index], weighting)).toBeCloseTo(units, 9); + }); + expect(realMs(MOMENTS.echoPeak, weighting)).toBe(WEIGHTINGS[weighting].te); + expect(realMs(MOMENTS.pulse180, weighting)).toBeCloseTo(WEIGHTINGS[weighting].te / 2, 9); + }); + + it.each(WEIGHTING_IDS)('keeps the %s map rising and its inverse exact', (weighting) => { + let previous = -1; + for (let units = 0; units <= CYCLE_UNITS; units += 5) { + const ms = realMs(units, weighting); + expect(ms).toBeGreaterThan(previous); + expect(phaseAt(ms, weighting)).toBeCloseTo(units, 6); + previous = ms; + } + }); + + it('clamps outside one repetition', () => { + expect(realMs(-10, 't1')).toBe(0); + expect(realMs(CYCLE_UNITS + 10, 't2')).toBe(2500); + expect(phaseAt(9000, 't2')).toBe(CYCLE_UNITS); + }); + + it('names the step, the gradient and the radio pulse at each unit', () => { + expect([0, 149, 150, 300, 420, 649, 650, 1000].map(phaseOf)).toEqual([ + 'excite', + 'excite', + 'encode', + 'refocus', + 'echo', + 'echo', + 'recover', + 'recover', + ]); + expect([75, 200, 360, 535, 800].map(activeGradient)).toEqual(['z', 'y', 'z', 'x', null]); + expect([75, 200, 360, 535, 800].map(rfPulse)).toEqual([90, null, 180, null, null]); + }); + + it('runs 6 repetitions a minute by default and fills the 64 lines in about two minutes at 30', () => { + expect(SPEED_RANGE).toEqual({ min: 2, max: 60, step: 1, default: 6 }); + expect(DEFAULT_SPEED).toBe(6); + expect(rate(DEFAULT_SPEED)).toBe(100); + expect((MODEL_SIZE * CYCLE_UNITS) / rate(PICTURE_SPEED)).toBe(128); + }); + + it('takes 128 s for a T1 scan and 640 s for a T2 scan of 256 lines', () => { + expect(scanSeconds('t1')).toBe(128); + expect(scanSeconds('t2')).toBe(640); + }); +}); diff --git a/explainers/mri-scanner/src/model/sequence.ts b/explainers/mri-scanner/src/model/sequence.ts new file mode 100644 index 00000000..39fdae58 --- /dev/null +++ b/explainers/mri-scanner/src/model/sequence.ts @@ -0,0 +1,117 @@ +import { clamp, lerp } from '@core/math'; +import type { Extent } from '@core/scene/regions'; +import type { GradientAxisId, MomentId, PhaseId, RfPulse, WeightingId } from '../ids'; +import { PHASE_IDS } from '../ids'; +import { MS_PER_S, REAL_LINES, SECONDS_PER_MINUTE } from './constants'; + +export interface Weighting { + tr: number; + te: number; +} + +export const WEIGHTINGS: Readonly> = { + t1: { tr: 500, te: 15 }, + t2: { tr: 2500, te: 100 }, +}; + +export const CYCLE_UNITS = 1000; + +export const PHASE_RANGES: Readonly> = { + excite: [0, 150], + encode: [150, 300], + refocus: [300, 420], + echo: [420, 650], + recover: [650, CYCLE_UNITS], +}; + +export const MOMENTS: Readonly> = { + pulse90: 75, + pulse180: 360, + echoPeak: 535, + repetitionEnd: 990, +}; + +export const LINE_DONE_UNITS = PHASE_RANGES.echo[1]; + +export const PULSE_MS = 3; +export const REFOCUS_HALF_MS = 1; +export const READOUT_HALF_MS = 4; + +export const DEFAULT_SPEED = 6; +export const PICTURE_SPEED = 30; +export const SPEED_RANGE = { min: 2, max: 60, step: 1, default: DEFAULT_SPEED } as const; + +const GRADIENTS: Readonly> = { + excite: 'z', + encode: 'y', + refocus: 'z', + echo: 'x', + recover: null, +}; + +const RF_PULSES: Readonly> = { + excite: 90, + encode: null, + refocus: 180, + echo: null, + recover: null, +}; + +const KNOT_UNITS = [ + PHASE_RANGES.excite[0], + PHASE_RANGES.encode[0], + PHASE_RANGES.refocus[0], + PHASE_RANGES.echo[0], + MOMENTS.echoPeak, + PHASE_RANGES.recover[0], + CYCLE_UNITS, +] as const; + +function knotMs({ tr, te }: Weighting): readonly number[] { + return [ + 0, + PULSE_MS, + te / 2 - REFOCUS_HALF_MS, + te / 2 + REFOCUS_HALF_MS, + te, + te + READOUT_HALF_MS, + tr, + ]; +} + +function piecewise(value: number, from: readonly number[], to: readonly number[]): number { + const last = from.length - 1; + const clamped = clamp(value, from[0], from[last]); + const index = from.findIndex((knot, at) => at > 0 && clamped <= knot); + const start = index - 1; + const span = from[index] - from[start]; + return lerp(to[start], to[index], (clamped - from[start]) / span); +} + +export function realMs(phase: number, weighting: WeightingId): number { + return piecewise(phase, KNOT_UNITS, knotMs(WEIGHTINGS[weighting])); +} + +export function phaseAt(ms: number, weighting: WeightingId): number { + return piecewise(ms, knotMs(WEIGHTINGS[weighting]), KNOT_UNITS); +} + +export function phaseOf(phase: number): PhaseId { + return PHASE_IDS.find((id) => phase < PHASE_RANGES[id][1]) ?? 'recover'; +} + +export function activeGradient(phase: number): GradientAxisId | null { + return GRADIENTS[phaseOf(phase)]; +} + +export function rfPulse(phase: number): RfPulse | null { + return RF_PULSES[phaseOf(phase)]; +} + +export function rate(speed: number): number { + return (CYCLE_UNITS * speed) / SECONDS_PER_MINUTE; +} + +export function scanSeconds(weighting: WeightingId): number { + return (WEIGHTINGS[weighting].tr * REAL_LINES) / MS_PER_S; +} diff --git a/explainers/mri-scanner/src/model/signal.test.ts b/explainers/mri-scanner/src/model/signal.test.ts new file mode 100644 index 00000000..4dfddec6 --- /dev/null +++ b/explainers/mri-scanner/src/model/signal.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_IDS } from '../ids'; +import { + brightnessOrder, + relativeBrightness, + tipComponents, + tissueSignal, + tissueSignals, +} from './signal'; + +describe('tissue signal', () => { + it('follows the spin echo signal equation', () => { + const expected = (1 - Math.exp(-500 / 260)) * Math.exp(-15 / 80); + expect(tissueSignal('fat', 'field15', 't1')).toBeCloseTo(expected, 10); + }); + + it.each(FIELD_IDS)('makes fat brightest and fluid darkest on T1 at %s', (field) => { + expect(brightnessOrder(field, 't1')).toEqual(['fat', 'whiteMatter', 'greyMatter', 'fluid']); + }); + + it.each(FIELD_IDS)('makes fluid brightest and white matter dark on T2 at %s', (field) => { + const signals = tissueSignals(field, 't2'); + expect(brightnessOrder(field, 't2')[0]).toBe('fluid'); + expect(signals.fluid).toBeGreaterThan(signals.greyMatter); + expect(signals.greyMatter).toBeGreaterThan(signals.whiteMatter); + }); + + it('scales every tissue against the brightest one', () => { + const relative = relativeBrightness('field30', 't2'); + expect(relative.fluid).toBe(1); + for (const share of Object.values(relative)) { + expect(share).toBeGreaterThan(0); + expect(share).toBeLessThanOrEqual(1); + } + }); +}); + +describe('tip components', () => { + it('splits the net magnet along and across the field', () => { + expect(tipComponents(0)).toEqual({ along: 1, across: 0 }); + expect(tipComponents(90).along).toBeCloseTo(0, 12); + expect(tipComponents(90).across).toBeCloseTo(1, 12); + expect(tipComponents(180).along).toBeCloseTo(-1, 12); + }); +}); diff --git a/explainers/mri-scanner/src/model/signal.ts b/explainers/mri-scanner/src/model/signal.ts new file mode 100644 index 00000000..ec09be4f --- /dev/null +++ b/explainers/mri-scanner/src/model/signal.ts @@ -0,0 +1,46 @@ +import { toRadians } from '@core/math'; +import type { FieldId, TissueId, WeightingId } from '../ids'; +import { TISSUE_IDS } from '../ids'; +import { WEIGHTINGS } from './sequence'; +import { RELAXATION } from './tissues'; + +export interface TipComponents { + along: number; + across: number; +} + +export function tissueSignal(tissue: TissueId, field: FieldId, weighting: WeightingId): number { + const { t1, t2 } = RELAXATION[field][tissue]; + const { tr, te } = WEIGHTINGS[weighting]; + return (1 - Math.exp(-tr / t1)) * Math.exp(-te / t2); +} + +function mapTissues(value: (tissue: TissueId) => number): Record { + return Object.fromEntries(TISSUE_IDS.map((tissue) => [tissue, value(tissue)])) as Record< + TissueId, + number + >; +} + +export function tissueSignals(field: FieldId, weighting: WeightingId): Record { + return mapTissues((tissue) => tissueSignal(tissue, field, weighting)); +} + +export function brightnessOrder(field: FieldId, weighting: WeightingId): TissueId[] { + const signals = tissueSignals(field, weighting); + return [...TISSUE_IDS].sort((first, second) => signals[second] - signals[first]); +} + +export function relativeBrightness( + field: FieldId, + weighting: WeightingId, +): Record { + const signals = tissueSignals(field, weighting); + const brightest = Math.max(...Object.values(signals)); + return mapTissues((tissue) => signals[tissue] / brightest); +} + +export function tipComponents(angleDeg: number): TipComponents { + const angle = toRadians(angleDeg); + return { along: Math.cos(angle), across: Math.sin(angle) }; +} diff --git a/explainers/mri-scanner/src/model/spins.test.ts b/explainers/mri-scanner/src/model/spins.test.ts new file mode 100644 index 00000000..938fbc8e --- /dev/null +++ b/explainers/mri-scanner/src/model/spins.test.ts @@ -0,0 +1,151 @@ +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); +} + +function expectPointClose(actual: Point, expected: Point, digits = LOOSE): void { + actual.forEach((value, axis) => expect(value).toBeCloseTo(expected[axis], digits)); +} + +describe('net magnetisation', () => { + it('rests along the main field, which points along minus z', () => { + const [x, y, z] = magnetisation(0, 't2', 'whiteMatter', 'field15', RIGHT_ANGLE); + expect(x).toBe(0); + expect(y).toBe(0); + expect(z).toBeLessThan(0); + }); + + it('lies across the field after a 90 degree pulse', () => { + const [across, , along] = magnetisation( + PHASE_RANGES.excite[1], + 't1', + 'fat', + 'field15', + RIGHT_ANGLE, + ); + expect(across).toBeGreaterThan(0.5); + expect(Math.abs(along)).toBeLessThan(0.05); + }); + + it('loses its sideways part while the spins fan out and gets it back at the echo', () => { + const fanned = magnetisation(MOMENTS.pulse180, 't2', 'greyMatter', 'field30', RIGHT_ANGLE); + const echo = magnetisation(MOMENTS.echoPeak, 't2', 'greyMatter', 'field30', RIGHT_ANGLE); + expect(fanned[0]).toBeCloseTo(0, 6); + expectNearSignal(echo[0], tissueSignal('greyMatter', 'field30', 't2')); + }); + + it('regrows along the field during recovery', () => { + const early = magnetisation( + PHASE_RANGES.recover[0], + 't1', + 'whiteMatter', + 'field15', + RIGHT_ANGLE, + ); + const late = magnetisation(MOMENTS.repetitionEnd, 't1', 'whiteMatter', 'field15', RIGHT_ANGLE); + expect(late[2]).toBeLessThan(early[2]); + }); + + it('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', () => { + it('draws the same 64 unit arrows every time', () => { + const arrows = spinArrows(MOMENTS.pulse180, 't2', 'fluid', 'field15', RIGHT_ANGLE); + expect(arrows).toHaveLength(SPIN_COUNT); + for (const arrow of arrows) expect(length(arrow)).toBeCloseTo(1, 10); + expect(spinArrows(MOMENTS.pulse180, 't2', 'fluid', 'field15', RIGHT_ANGLE)).toEqual(arrows); + }); + + it('gathers around the net vector at the echo', () => { + const net = magnetisation(MOMENTS.echoPeak, 't2', 'fluid', 'field15', RIGHT_ANGLE); + const arrows = spinArrows(MOMENTS.echoPeak, 't2', 'fluid', 'field15', RIGHT_ANGLE); + const mean = arrows.reduce( + (sum, arrow) => [sum[0] + arrow[0], sum[1] + arrow[1], sum[2] + arrow[2]], + [0, 0, 0], + ); + const cosine = + (mean[0] * net[0] + mean[1] * net[1] + mean[2] * net[2]) / (length(mean) * length(net)); + expect(cosine).toBeGreaterThan(0.95); + }); + + it('matches at both ends of the loop', () => { + const start = spinArrows(0, 't2', 'fluid', 'field15', RIGHT_ANGLE); + const end = spinArrows(CYCLE_UNITS, 't2', 'fluid', 'field15', RIGHT_ANGLE); + start.forEach((arrow, index) => expectPointClose(arrow, end[index])); + }); +}); + +describe('echo amplitude', () => { + it('peaks at the echo with the tissue signal and is silent outside the echo', () => { + const peak = echoAmplitude(MOMENTS.echoPeak, 't1', 'fat', 'field15', RIGHT_ANGLE); + expectNearSignal(peak, tissueSignal('fat', 'field15', 't1')); + expect(echoAmplitude(MOMENTS.echoPeak - 40, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeLessThan( + peak, + ); + expect(echoAmplitude(MOMENTS.echoPeak + 40, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeLessThan( + peak, + ); + expect(echoAmplitude(PHASE_RANGES.echo[0], 't1', 'fat', 'field15', RIGHT_ANGLE)).toBeCloseTo( + 0, + 6, + ); + expect(echoAmplitude(MOMENTS.pulse90, 't1', 'fat', 'field15', RIGHT_ANGLE)).toBe(0); + }); + + it.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); + expect(small).toBeLessThan(full); + }); +}); diff --git a/explainers/mri-scanner/src/model/spins.ts b/explainers/mri-scanner/src/model/spins.ts new file mode 100644 index 00000000..d654acad --- /dev/null +++ b/explainers/mri-scanner/src/model/spins.ts @@ -0,0 +1,167 @@ +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'; + +interface SpinInput { + weighting: WeightingId; + tissue: TissueId; + field: FieldId; + tipDeg: number; +} + +interface SpinState { + along: number; + across: number; + spread: number; + flip: number; +} + +export const SPIN_COUNT = 64; +const SPIN_SEED = 20_260_312; +const JITTER = 0.18; +const FADE_START_UNITS = 800; +const UINT32_RANGE = 2 ** 32; +const MULBERRY = { increment: 0x6d2b79f5, shiftA: 15, shiftB: 7, shiftC: 14, mixB: 61 } as const; + +const RESTING: Point = [0, 0, -1]; + +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + MULBERRY.increment) >>> 0; + let value = Math.imul(state ^ (state >>> MULBERRY.shiftA), state | 1); + value ^= value + Math.imul(value ^ (value >>> MULBERRY.shiftB), value | MULBERRY.mixB); + return ((value ^ (value >>> MULBERRY.shiftC)) >>> 0) / UINT32_RANGE; + }; +} + +function randomUnit(random: () => number): Point { + const z = random() * 2 - 1; + const angle = random() * Math.PI * 2; + const ring = Math.sqrt(1 - z * z); + return [ring * Math.cos(angle), ring * Math.sin(angle), z]; +} + +const random = seededRandom(SPIN_SEED); +const OFFSETS = Array.from( + { length: SPIN_COUNT }, + (_, index) => ((index + 0.5) / SPIN_COUNT) * 2 - 1, +); +const JITTERS = OFFSETS.map(() => randomUnit(random)); + +function steadyAlong(t1: number, 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 { + const [encodeStart, encodeEnd] = PHASE_RANGES.encode; + const [echoStart, echoEnd] = PHASE_RANGES.echo; + if (phase < encodeStart) return 0; + if (phase < encodeEnd) return (phase - encodeStart) / (encodeEnd - encodeStart); + if (phase < echoStart) return 1; + if (phase < MOMENTS.echoPeak) return (MOMENTS.echoPeak - phase) / (MOMENTS.echoPeak - echoStart); + return -clamp((phase - MOMENTS.echoPeak) / (echoEnd - MOMENTS.echoPeak), 0, 1); +} + +function spinState(phase: number, input: SpinInput): SpinState { + const { t1, t2 } = RELAXATION[input.field][input.tissue]; + const tip = toRadians(input.tipDeg); + const resting = steadyAlong(t1, 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: alongField(resting, tip, { t1, tipped, elapsed, refocusMs, flip }), + across: resting * Math.sin(tipped) * Math.exp(-elapsed / t2) * fade, + spread: Math.PI * dephasing(phase), + flip, + }; +} + +function coherence(spread: number): number { + return spread === 0 ? 1 : Math.sin(spread) / spread; +} + +function transverse({ across, spread }: SpinState): number { + return across * coherence(Math.abs(spread)); +} + +export function magnetisation( + phase: number, + weighting: WeightingId, + tissue: TissueId, + field: FieldId, + tipDeg: number, +): Point { + const state = spinState(phase, { weighting, tissue, field, tipDeg }); + return [transverse(state), 0, -state.along]; +} + +function normalise([x, y, z]: Point): Point { + const length = Math.hypot(x, y, z); + return length === 0 ? RESTING : [x / length, y / length, z / length]; +} + +function spinArrow(state: SpinState, offset: number, jitter: Point): Point { + const angle = offset * state.spread; + const lift = Math.sin(angle) * state.across; + const base = normalise([ + Math.cos(angle) * state.across, + lift * Math.cos(state.flip), + lift * Math.sin(state.flip) - state.along, + ]); + return normalise([ + base[0] + jitter[0] * JITTER, + base[1] + jitter[1] * JITTER, + base[2] + jitter[2] * JITTER, + ]); +} + +export function spinArrows( + phase: number, + weighting: WeightingId, + tissue: TissueId, + field: FieldId, + tipDeg: number, +): readonly Point[] { + const state = spinState(phase, { weighting, tissue, field, tipDeg }); + return OFFSETS.map((offset, index) => spinArrow(state, offset, JITTERS[index])); +} + +export function echoAmplitude( + phase: number, + weighting: WeightingId, + tissue: TissueId, + field: FieldId, + tipDeg: number, +): number { + const [echoStart, echoEnd] = PHASE_RANGES.echo; + if (phase < echoStart || phase >= echoEnd) return 0; + return clamp(transverse(spinState(phase, { weighting, tissue, field, tipDeg })), 0, 1); +} diff --git a/explainers/mri-scanner/src/model/tissues.test.ts b/explainers/mri-scanner/src/model/tissues.test.ts new file mode 100644 index 00000000..8591dec2 --- /dev/null +++ b/explainers/mri-scanner/src/model/tissues.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_IDS, TISSUE_IDS } from '../ids'; +import { RELAXATION } from './tissues'; + +describe('tissue relaxation', () => { + it('takes the 1.5 T and 3 T times from the facts sheet', () => { + expect(RELAXATION.field15.whiteMatter).toEqual({ t1: 884, t2: 72 }); + expect(RELAXATION.field15.greyMatter).toEqual({ t1: 1124, t2: 95 }); + expect(RELAXATION.field15.fat).toEqual({ t1: 260, t2: 80 }); + expect(RELAXATION.field30.whiteMatter).toEqual({ t1: 1084, t2: 69 }); + expect(RELAXATION.field30.greyMatter).toEqual({ t1: 1820, t2: 99 }); + expect(RELAXATION.field30.fat).toEqual({ t1: 382, t2: 68 }); + }); + + it('keeps fluid the slowest at both fields, with T2 shorter than T1 everywhere', () => { + for (const field of FIELD_IDS) { + expect(RELAXATION[field].fluid).toEqual({ t1: 4300, t2: 2000 }); + for (const tissue of TISSUE_IDS) { + expect(RELAXATION[field][tissue].t2).toBeLessThan(RELAXATION[field][tissue].t1); + } + } + }); +}); diff --git a/explainers/mri-scanner/src/model/tissues.ts b/explainers/mri-scanner/src/model/tissues.ts new file mode 100644 index 00000000..b1aad38e --- /dev/null +++ b/explainers/mri-scanner/src/model/tissues.ts @@ -0,0 +1,21 @@ +import type { FieldId, TissueId } from '../ids'; + +export interface Relaxation { + t1: number; + t2: number; +} + +export const RELAXATION: Readonly>>> = { + field15: { + fat: { t1: 260, t2: 80 }, + whiteMatter: { t1: 884, t2: 72 }, + greyMatter: { t1: 1124, t2: 95 }, + fluid: { t1: 4300, t2: 2000 }, + }, + field30: { + fat: { t1: 382, t2: 68 }, + whiteMatter: { t1: 1084, t2: 69 }, + greyMatter: { t1: 1820, t2: 99 }, + fluid: { t1: 4300, t2: 2000 }, + }, +}; diff --git a/explainers/mri-scanner/src/scene/assembly.ts b/explainers/mri-scanner/src/scene/assembly.ts new file mode 100644 index 00000000..2aef2db7 --- /dev/null +++ b/explainers/mri-scanner/src/scene/assembly.ts @@ -0,0 +1,25 @@ +import type { Box3, Group, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import type { SceneTextures } from '@core/scene/textures'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { createMriScannerAssembly } from './mriScannerAssembly'; + +export interface AssemblyResources { + materials: MaterialLibrary; + textures: SceneTextures; +} + +export interface Assembly { + readonly root: Group; + setState(state: AssemblyState): void; + update(deltaSeconds: number, cameraDistance: number): boolean; + labelAnchors(): ReadonlyMap; + anchor(id: AnchorId): Object3D; + region(id: RegionId): Box3; + warmUp?(compile: (object: Object3D) => void): void; + dispose(): void; +} + +export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { + return createMriScannerAssembly(resources, state); +} diff --git a/explainers/mri-scanner/src/scene/bindings.test.ts b/explainers/mri-scanner/src/scene/bindings.test.ts new file mode 100644 index 00000000..0b760799 --- /dev/null +++ b/explainers/mri-scanner/src/scene/bindings.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { FIELDS } from '../model'; +import { createMriScannerStore, pictureVersionOf } from '../state'; +import { bindStore } from './bindings'; +import type { SceneTargets } from './bindings'; + +const LATER_PHASE = 0.4; +const FILLED_LINES = 32; + +function fakeTargets() { + const received: AssemblyState[] = []; + const snapshots: AssemblyState[] = []; + const record = (state: AssemblyState) => { + received.push(state); + snapshots.push(structuredClone(state)); + }; + const frame = vi.fn(); + const targets: SceneTargets = { + mriScanner: { build: record, setState: record, views: { frame } }, + labelVisibility: { setWanted: vi.fn() }, + highlighter: { setHighlight: vi.fn() }, + labels: { show: vi.fn() }, + }; + return { received, snapshots, frame, targets }; +} + +describe('mri scanner scene bindings', () => { + it('builds the scene from the store and frames the first chapter', () => { + const { received, frame, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + const { phase, field, weighting, linesFilled, view } = store.getState(); + expect(received[0]).toMatchObject({ + phase, + field, + weighting, + view, + fringe: FIELDS[field].fringe, + pictureVersion: pictureVersionOf(field, weighting, linesFilled), + }); + expect(frame).toHaveBeenCalledWith('room', false, undefined); + }); + + it('hands the next chapter its view flags before framing its camera', () => { + const { received, frame, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + store.getState().applyPreset('magnet'); + expect(frame).toHaveBeenLastCalledWith('cryostat', true, undefined); + expect(received.at(-1)?.view).toMatchObject({ cutaway: true, fieldLines: true }); + }); + + it('alternates two buffers so the held state is never rewritten', () => { + const { received, snapshots, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + store.getState().pause(); + store.getState().setPhase(LATER_PHASE); + store.getState().setTissue('fluid'); + const [first, second, third] = received.slice(-3); + expect(first).not.toBe(second); + expect(third).toBe(first); + expect(received.at(-2)).toEqual(snapshots.at(-2)); + expect(third?.tissue).toBe('fluid'); + }); + + it('bumps the picture version only when the picture changes', () => { + const { received, targets } = fakeTargets(); + const store = createMriScannerStore(); + bindStore(store, targets); + const built = { picture: received[0]?.picture, version: received[0]?.pictureVersion }; + store.getState().setPhase(LATER_PHASE); + store.getState().setTissue('fat'); + const unchanged = received.at(-1); + expect(unchanged?.pictureVersion).toBe(built.version); + expect(unchanged?.picture).toBe(built.picture); + store.getState().setLinesFilled(FILLED_LINES); + const { field, weighting } = store.getState(); + const changed = received.at(-1); + expect(changed?.pictureVersion).toBe(pictureVersionOf(field, weighting, FILLED_LINES)); + expect(changed?.pictureVersion).not.toBe(built.version); + expect(changed?.picture).not.toBe(built.picture); + }); + + it('stops handing over states once unbound', () => { + const { received, targets } = fakeTargets(); + const store = createMriScannerStore(); + const unbind = bindStore(store, targets); + unbind(); + const count = received.length; + store.getState().setPhase(LATER_PHASE); + expect(received).toHaveLength(count); + }); +}); diff --git a/explainers/mri-scanner/src/scene/bindings.ts b/explainers/mri-scanner/src/scene/bindings.ts new file mode 100644 index 00000000..f7cdf092 --- /dev/null +++ b/explainers/mri-scanner/src/scene/bindings.ts @@ -0,0 +1,51 @@ +import { bindPresets } from '@core/scene/presetBinder'; +import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder'; +import type { AssemblyState } from '../ids'; +import { PRESETS, createAssemblyState, writeAssemblyState } from '../state'; +import type { AssemblySource, MriScannerStore, MriScannerStoreState, Preset } from '../state'; +import type { MriScannerController } from './controller'; + +export interface SceneTargets extends PresetTargets { + mriScanner: Pick; + labelVisibility: LabelPolicy; +} + +class DoubleBufferedAssemblyState { + private heldByAssembly: AssemblyState; + private free: AssemblyState; + + constructor(source: AssemblySource) { + this.heldByAssembly = createAssemblyState(source); + this.free = createAssemblyState(source); + } + + handOver(source: AssemblySource): AssemblyState { + const next = writeAssemblyState(this.carryPicture(this.free), source); + this.free = this.heldByAssembly; + this.heldByAssembly = next; + return next; + } + + private carryPicture(target: AssemblyState): AssemblyState { + target.picture = this.heldByAssembly.picture; + target.pictureVersion = this.heldByAssembly.pictureVersion; + return target; + } +} + +export function bindStore(store: MriScannerStore, targets: SceneTargets): () => void { + const { mriScanner, labelVisibility } = targets; + const assemblyState = new DoubleBufferedAssemblyState(store.getState()); + const push = (state: AssemblySource) => mriScanner.setState(assemblyState.handOver(state)); + mriScanner.build(assemblyState.handOver(store.getState())); + const unsubscribers = [ + store.subscribe(push), + bindPresets(targets, store, { + presets: PRESETS, + views: mriScanner.views, + labels: labelVisibility, + prepare: push, + }), + ]; + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); +} diff --git a/explainers/mri-scanner/src/scene/cameraViews.test.ts b/explainers/mri-scanner/src/scene/cameraViews.test.ts new file mode 100644 index 00000000..44bc485e --- /dev/null +++ b/explainers/mri-scanner/src/scene/cameraViews.test.ts @@ -0,0 +1,97 @@ +import { Vector3 } from 'three'; +import { describe, expect, it } from 'vitest'; +import { frameBox } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import { regionFromSpec } from '@core/scene/regions'; +import type { CameraView } from '../ids'; +import { CONTROL_WINDOW, FLOOR_Y, MAGNET, REGIONS } from '../model'; +import { CAMERA_VIEWS, STAGE_VARIANTS, stageVariant } from './cameraViews'; +import type { StageVariant } from './cameraViews'; + +const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; +const TABLE_END_VIEWS: readonly CameraView[] = ['cryostat', 'voxel', 'coil']; +const SLOPES: Readonly> = { + phone: { vertical: 0.75, horizontal: 0.38 }, + desktop: { vertical: 0.42, horizontal: 0.75 }, +}; + +function cameraOf(view: CameraView, variant: StageVariant): Vector3 { + const spec = CAMERA_VIEWS[view]; + const direction = new Vector3(...spec.direction[variant]).normalize(); + const box = regionFromSpec(REGIONS[spec.region]); + return frameBox(box, direction, SLOPES[variant], spec.margin).position; +} + +function eachVariant(check: (variant: StageVariant) => void): void { + STAGE_VARIANTS.forEach(check); +} + +describe('camera views', () => { + it('offers the six fixed views, none following anything', () => { + expect(VIEWS).toEqual(['room', 'cryostat', 'voxel', 'coil', 'gradient', 'console']); + VIEWS.forEach((view) => { + expect(CAMERA_VIEWS[view].follow, view).toBeUndefined(); + expect(REGIONS[CAMERA_VIEWS[view].region], view).toBeDefined(); + }); + }); + + it('picks the phone framing below 600 pixels of stage width', () => { + expect(stageVariant(599)).toBe('phone'); + expect(stageVariant(600)).toBe('desktop'); + }); + + it('keeps every camera above the floor', () => { + VIEWS.forEach((view) => + eachVariant((variant) => expect(cameraOf(view, variant).y, view).toBeGreaterThan(FLOOR_Y)), + ); + }); + + it('looks from the table end on the window side for the scanner views', () => { + TABLE_END_VIEWS.forEach((view) => + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS[view].direction[variant]; + expect(x, view).toBeGreaterThanOrEqual(0); + expect(z, view).toBeGreaterThan(0); + expect(cameraOf(view, variant).z, view).toBeGreaterThan(MAGNET.halfLength); + }), + ); + }); + + it('looks 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))); + }); + + 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); + }); + }); + + it('looks from behind the table toward the screen at the control window', () => { + eachVariant((variant) => { + const camera = cameraOf('console', variant); + expect(camera.x).toBeLessThan(CONTROL_WINDOW.x); + expect(camera.z).toBeGreaterThan(CONTROL_WINDOW.centreZ); + }); + }); +}); diff --git a/explainers/mri-scanner/src/scene/cameraViews.ts b/explainers/mri-scanner/src/scene/cameraViews.ts new file mode 100644 index 00000000..3d67c6e0 --- /dev/null +++ b/explainers/mri-scanner/src/scene/cameraViews.ts @@ -0,0 +1,49 @@ +import type { Direction, FramedView } from '@core/scene/cameraViews'; +import type { CameraView, RegionId } from '../ids'; + +export const STAGE_VARIANTS = ['phone', 'desktop'] as const; + +export type StageVariant = (typeof STAGE_VARIANTS)[number]; + +export interface StageView extends FramedView { + direction: Readonly>; +} + +const PHONE_STAGE_WIDTH_PX = 600; + +export function stageVariant(width: number): StageVariant { + return width < PHONE_STAGE_WIDTH_PX ? 'phone' : 'desktop'; +} + +export const CAMERA_VIEWS: Readonly> = { + room: { + region: 'room', + direction: { phone: [-0.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.55, + }, + voxel: { + region: 'voxel', + direction: { phone: [1, 0.6, 0.35], desktop: [1, 0.6, 0.35] }, + margin: 1.35, + }, + coil: { + region: 'bore', + direction: { phone: [0.25, 0.7, 1], desktop: [0.25, 0.7, 1] }, + margin: 1.7, + }, + gradient: { + region: 'bore', + direction: { phone: [1, 0.6, 0.25], desktop: [1, 0.6, 0.25] }, + margin: 2.2, + }, + console: { + region: 'console', + direction: { phone: [-0.8, 0.45, 0.9], desktop: [-1, 0.3, 0.45] }, + margin: 1.5, + }, +}; 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/controller.dom.test.ts b/explainers/mri-scanner/src/scene/controller.dom.test.ts new file mode 100644 index 00000000..2191db36 --- /dev/null +++ b/explainers/mri-scanner/src/scene/controller.dom.test.ts @@ -0,0 +1,126 @@ +import { Box3, Vector3 } from 'three'; +import type { Object3D } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import type { CameraPose } from '@core/scene/frameBox'; +import { MaterialLibrary } from '@core/scene/materials'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState, CameraView } from '../ids'; +import { createAssemblyState, createMriScannerStore } from '../state'; +import { createAssembly } from './assembly'; +import { CAMERA_VIEWS, STAGE_VARIANTS } from './cameraViews'; +import type { StageVariant } from './cameraViews'; +import { MriScannerController } from './controller'; +import type { MriScannerControllerDependencies } from './controller'; + +const FRAMING = { vertical: 0.2, horizontal: 0.27 }; +const STAGE_WIDTH_PX: Readonly> = { phone: 390, desktop: 835 }; +const DIRECTION_DIGITS = 6; +const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; + +function assemblyState(changes: Partial = {}): AssemblyState { + return { ...createAssemblyState(createMriScannerStore().getState()), ...changes }; +} + +function regionBox(view: CameraView): Box3 { + const resources = { materials: new MaterialLibrary(), textures: createSceneTextures() }; + return createAssembly(resources, assemblyState()).region(CAMERA_VIEWS[view].region); +} + +function fakeShell(stageWidth: number = STAGE_WIDTH_PX.desktop) { + const rig = { + framing: () => FRAMING, + follow: vi.fn(), + setDistanceLimits: vi.fn(), + jumpTo: vi.fn<(pose: CameraPose) => void>(), + tweenTo: vi.fn<(pose: CameraPose) => void>(), + setBounds: vi.fn<(bounds: Box3, floor: number) => void>(), + camera: { position: new Vector3(0, 1, 6) }, + controls: { target: new Vector3(0, 1, 0) }, + }; + const scene = { add: vi.fn<(object: Object3D) => void>() }; + const shell = { + scene, + materials: new MaterialLibrary(), + textures: createSceneTextures(), + labels: { attach: vi.fn(), show: vi.fn() }, + rig, + viewport: { + renderer: { compileAsync: vi.fn(() => Promise.resolve()) }, + element: { clientWidth: stageWidth }, + }, + }; + return { rig, scene, shell: shell as unknown as MriScannerControllerDependencies }; +} + +function viewDirection(pose: CameraPose): number[] { + return roundAll(pose.position.clone().sub(pose.target).normalize().toArray()); +} + +function roundAll(values: readonly number[]): number[] { + return values.map((value) => Number(value.toFixed(DIRECTION_DIGITS))); +} + +describe('mri scanner controller', () => { + it('adds the assembly, attaches its labels and bounds the camera by the room', () => { + const { rig, scene, shell } = fakeShell(); + new MriScannerController(shell).build(assemblyState()); + expect(scene.add).toHaveBeenCalledTimes(1); + expect(shell.labels.attach).toHaveBeenCalledTimes(1); + const [bounds, floor] = rig.setBounds.mock.lastCall ?? []; + expect(bounds).toBeInstanceOf(Box3); + expect(floor).toBe(bounds?.min.y); + }); + + it('frames nothing before the assembly exists', () => { + const { rig, shell } = fakeShell(); + new MriScannerController(shell).views.frame('room', false); + expect(rig.jumpTo).not.toHaveBeenCalled(); + }); + + it.each(STAGE_VARIANTS)('frames every fixed view on its region for a %s stage', (variant) => { + const { rig, shell } = fakeShell(STAGE_WIDTH_PX[variant]); + const controller = new MriScannerController(shell); + controller.build(assemblyState()); + for (const view of VIEWS) { + controller.views.frame(view, false); + const pose = rig.jumpTo.mock.lastCall?.[0]; + expect(pose?.target).toEqual(regionBox(view).getCenter(new Vector3())); + const direction = new Vector3(...CAMERA_VIEWS[view].direction[variant]).normalize(); + expect(pose && viewDirection(pose)).toEqual(roundAll(direction.toArray())); + expect(rig.follow).toHaveBeenLastCalledWith(null, undefined); + } + }); + + it('tweens when animated and honours an explicit variant', () => { + const { rig, shell } = fakeShell(STAGE_WIDTH_PX.phone); + const controller = new MriScannerController(shell); + controller.build(assemblyState()); + controller.views.frame('console', true, 'desktop'); + const pose = rig.tweenTo.mock.lastCall?.[0]; + const desktop = new Vector3(...CAMERA_VIEWS.console.direction.desktop).normalize(); + expect(pose && viewDirection(pose)).toEqual(roundAll(desktop.toArray())); + expect(rig.jumpTo).not.toHaveBeenCalled(); + }); + + it('reports no motion without an assembly and removes it on dispose', () => { + const { scene, shell } = fakeShell(); + const controller = new MriScannerController(shell); + expect(controller.update(1)).toBe(false); + controller.build(assemblyState()); + const removed = vi.spyOn(scene.add.mock.lastCall?.[0] as Object3D, 'removeFromParent'); + controller.dispose(); + expect(removed).toHaveBeenCalledTimes(1); + expect(controller.update(1)).toBe(false); + }); + + it('rebuilds without leaving the previous assembly behind', () => { + const { scene, shell } = fakeShell(); + const controller = new MriScannerController(shell); + controller.build(assemblyState()); + const first = scene.add.mock.lastCall?.[0]; + const removed = vi.spyOn(first as Object3D, 'removeFromParent'); + controller.build(assemblyState()); + expect(removed).toHaveBeenCalledTimes(1); + expect(scene.add).toHaveBeenCalledTimes(2); + }); +}); diff --git a/explainers/mri-scanner/src/scene/controller.ts b/explainers/mri-scanner/src/scene/controller.ts new file mode 100644 index 00000000..f73a5109 --- /dev/null +++ b/explainers/mri-scanner/src/scene/controller.ts @@ -0,0 +1,80 @@ +import type { Object3D } from 'three'; +import { CameraViews } from '@core/scene/cameraViews'; +import type { SceneShell } from '@core/scene/shell'; +import { whenIdle } from '@core/scene/warmUp'; +import type { AssemblyState, CameraView, RegionId } from '../ids'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { CAMERA_VIEWS, stageVariant } from './cameraViews'; +import type { StageVariant } from './cameraViews'; + +export type MriScannerControllerDependencies = Pick< + SceneShell, + 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport' +>; + +export type ViewFramer = Pick, 'frame'>; + +const BOUNDS_REGION: RegionId = 'room'; + +export class MriScannerController { + readonly views: ViewFramer; + private readonly dependencies: MriScannerControllerDependencies; + private readonly cameraViews: CameraViews; + private assembly: Assembly | null = null; + private cancelWarmUp: () => void = () => {}; + + constructor(dependencies: MriScannerControllerDependencies) { + this.dependencies = dependencies; + this.cameraViews = new CameraViews(dependencies.rig, { + views: CAMERA_VIEWS, + region: (id) => this.assembly?.region(id) ?? null, + }); + this.views = { + frame: (view, animate, variant) => + this.cameraViews.frame(view, animate, variant ?? this.stageVariant()), + }; + } + + build(state: AssemblyState): void { + const { scene, materials, textures, labels, rig } = this.dependencies; + this.dispose(); + const assembly = createAssembly({ materials, textures }, state); + this.assembly = assembly; + scene.add(assembly.root); + labels.attach(assembly.labelAnchors()); + const bounds = assembly.region(BOUNDS_REGION); + rig.setBounds(bounds, bounds.min.y); + this.cancelWarmUp = whenIdle(() => assembly.warmUp?.((object) => this.compileHidden(object))); + } + + setState(state: AssemblyState): void { + this.assembly?.setState(state); + } + + update(deltaSeconds: number): boolean { + const { camera, controls } = this.dependencies.rig; + return ( + this.assembly?.update(deltaSeconds, camera.position.distanceTo(controls.target)) ?? false + ); + } + + dispose(): void { + this.cancelWarmUp(); + this.assembly?.root.removeFromParent(); + this.assembly?.dispose(); + this.assembly = null; + } + + private stageVariant(): StageVariant { + return stageVariant(this.dependencies.viewport.element.clientWidth); + } + + private compileHidden(object: Object3D): void { + const { viewport, rig, scene } = this.dependencies; + const shown = object.visible; + object.visible = true; + void viewport.renderer.compileAsync(object, rig.camera, scene); + object.visible = shown; + } +} diff --git a/explainers/mri-scanner/src/scene/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/index.ts b/explainers/mri-scanner/src/scene/index.ts new file mode 100644 index 00000000..54bfa763 --- /dev/null +++ b/explainers/mri-scanner/src/scene/index.ts @@ -0,0 +1,28 @@ +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneShell } from '@core/scene/shell'; +import type { MriScannerStore } from '../state'; +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'; +export { SCENE_OPTIONS } from './sceneOptions'; + +export function mountMriScannerScene(shell: SceneShell, store: MriScannerStore): () => void { + const restoreLight = mriScannerLight(shell.lighting); + const mriScanner = new MriScannerController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => mriScanner.update(deltaSeconds)); + const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); + const unbind = bindStore(store, { mriScanner, labelVisibility, ...shell }); + return () => { + removeFrame(); + labelVisibility.dispose(); + unbind(); + mriScanner.dispose(); + restoreLight(); + }; +} 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/mriScannerAssembly.dom.test.ts b/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts new file mode 100644 index 00000000..1c1e0fe3 --- /dev/null +++ b/explainers/mri-scanner/src/scene/mriScannerAssembly.dom.test.ts @@ -0,0 +1,102 @@ +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, 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'; + +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([...SCREEN.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..3a345740 --- /dev/null +++ b/explainers/mri-scanner/src/scene/mriScannerAssembly.ts @@ -0,0 +1,118 @@ +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.materials.clearRegistered(); + this.tracker.dispose(); + } +} + +export function createMriScannerAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new MriScannerAssembly(resources, state); +} diff --git a/explainers/mri-scanner/src/scene/partInfo.test.ts b/explainers/mri-scanner/src/scene/partInfo.test.ts new file mode 100644 index 00000000..a6af3126 --- /dev/null +++ b/explainers/mri-scanner/src/scene/partInfo.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS } from '../ids'; +import { LABEL_PRIORITY, PART_INFO } from './partInfo'; + +describe('part labels', () => { + it('names every part from its locale key', () => { + PART_IDS.forEach((id) => expect(PART_INFO[id].labelKey).toBe(`parts.${id}`)); + expect(Object.keys(PART_INFO)).toHaveLength(PART_IDS.length); + }); + + it('ranks every part exactly once, the net magnet first and the screen second', () => { + expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort()); + expect(LABEL_PRIORITY.slice(0, 3)).toEqual(['netMagnet', 'screen', 'headCoil']); + expect(LABEL_PRIORITY.indexOf('spinArrows')).toBe(13); + }); + + it('keeps the unranked parts in their part order after the ranked ones', () => { + const rest = LABEL_PRIORITY.slice(14); + expect(rest).toEqual(PART_IDS.filter((id) => rest.includes(id))); + expect(rest[0]).toBe('room'); + }); + + it('labels the magnet layers, the gradient shells and the patient on the left', () => { + ( + [ + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'table', + 'patient', + 'gradientX', + 'gradientY', + 'gradientZ', + 'spinArrows', + 'mainField', + 'rfWave', + 'fringeLine', + 'room', + ] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('left')); + ( + ['netMagnet', 'screen', 'headCoil', 'bodyCoil', 'coldHead', 'bore', 'echoWave'] as const + ).forEach((id) => expect(PART_INFO[id].side, id).toBe('right')); + }); +}); diff --git a/explainers/mri-scanner/src/scene/partInfo.ts b/explainers/mri-scanner/src/scene/partInfo.ts new file mode 100644 index 00000000..6c424f00 --- /dev/null +++ b/explainers/mri-scanner/src/scene/partInfo.ts @@ -0,0 +1,53 @@ +import type { PartInfo } from '@core/explainer'; +import type { LabelSide } from '@core/scene/labelLayout'; +import { PART_IDS } from '../ids'; +import type { PartId } from '../ids'; + +const LEFT_PARTS: ReadonlySet = new Set([ + 'cover', + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'table', + 'patient', + 'gradientX', + 'gradientY', + 'gradientZ', + 'spinArrows', + 'mainField', + 'rfWave', + 'fringeLine', + 'room', +]); + +const RANKED_FIRST: readonly PartId[] = [ + 'netMagnet', + 'screen', + 'headCoil', + 'mainCoils', + 'gradientZ', + 'gradientY', + 'gradientX', + 'bodyCoil', + 'coldHead', + 'bore', + 'echoWave', + 'rfWave', + 'heliumVessel', + 'spinArrows', +]; + +function labelSide(id: PartId): LabelSide { + return LEFT_PARTS.has(id) ? 'left' : 'right'; +} + +export const PART_INFO: Record = Object.fromEntries( + PART_IDS.map((id) => [id, { labelKey: `parts.${id}`, side: labelSide(id) }]), +) as Record; + +export const LABEL_PRIORITY: readonly PartId[] = [ + ...RANKED_FIRST, + ...PART_IDS.filter((id) => !RANKED_FIRST.includes(id)), +]; diff --git a/explainers/mri-scanner/src/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; +} 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..b4272a5e --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/effects.dom.test.ts @@ -0,0 +1,161 @@ +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('holds the pulse rings still while paused', () => { + 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(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); + }); + + 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 }, + }), + ); + 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..327b3ec4 --- /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, this.playing); + 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..b31d89fa --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/looks.ts @@ -0,0 +1,115 @@ +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.026, + headRadius: 0.062, + headLength: 0.12, + segments: 20, + emissiveIntensity: 0.3, + ghostOpacity: 0.75, + 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..c6e11799 --- /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, 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); + 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..d9a3201c --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/effects/voxel.ts @@ -0,0 +1,371 @@ +import { + BackSide, + BoxGeometry, + ConeGeometry, + CylinderGeometry, + GreaterDepth, + 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 { + shaft: Object3D; + 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 SHAFT_MIDDLE = 0.5; +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 = solidFinish(NET_ARROW); +const NET_GHOST_FINISH: MaterialFinish = { + ...NET_FINISH, + transparent: true, + opacity: NET_ARROW.ghostOpacity, + depthFunc: GreaterDepth, + depthWrite: 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, + ghostFinish?: MaterialFinish, +): Arrow { + const pivot = new Group(); + const shaft = partMesh( + context, + new CylinderGeometry(look.shaftRadius, look.shaftRadius, 1, look.segments).translate( + 0, + SHAFT_MIDDLE, + 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); + if (ghostFinish) { + const ghost = context.materials.get(group, ghostFinish); + shaft.add(new Mesh(shaft.geometry, ghost)); + head.add(new Mesh(head.geometry, ghost)); + } + return { + pivot, + shaft, + 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, NET_GHOST_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.netArrow.shaft, 0, SHAFT_MIDDLE, 0), + 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; + } +} 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..df366117 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/cover.ts @@ -0,0 +1,130 @@ +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 = { 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; + +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 outer = MAGNET.radius + BAND.lift; + const loop = rectLoop(outer - BAND.depth, 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..52639ea7 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/gradients.ts @@ -0,0 +1,202 @@ +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); + 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: tone }, + 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, + }); + return Object.assign(material, { color: tone }); +} + +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..79f0f343 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/magnet/magnet.dom.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { InstancedMesh, Mesh, ShaderMaterial } 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(materials = new MaterialLibrary()) { + return createMagnetModule({ + materials, + 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); + }); + + 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); + }); +}); 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..7189b404 --- /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: [BORE.radius, 0, 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; +} 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..c7a43d72 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/monitor.ts @@ -0,0 +1,96 @@ +import { + BoxGeometry, + DataTexture, + LinearFilter, + MathUtils, + Matrix4, + PlaneGeometry, + RGBAFormat, + SRGBColorSpace, + Vector3, +} from 'three'; +import type { BufferGeometry } from 'three'; +import { 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 = { + 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; + +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(...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; + 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), + 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..3acb35fd --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/room.dom.test.ts @@ -0,0 +1,145 @@ +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 { 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([...SCREEN.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, 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, 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', () => { + 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..f2995c54 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/room.ts @@ -0,0 +1,339 @@ +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, SCREEN } 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 { monitorGeometry, pictureTexture, screenSideEdge, 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 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 }, + { 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, ...SCREEN.centre); + this.labels = new Map([ + ['room', anchorAt(room, ...ROOM_LABEL_POINT)], + ['screen', anchorAt(screen, ...screenSideEdge().toArray())], + ]); + 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..17804dd9 --- /dev/null +++ b/explainers/mri-scanner/src/scene/parts/room/shell.ts @@ -0,0 +1,58 @@ +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; + mesh.frustumCulled = false; + 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); +} 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..3a2f51e8 --- /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, blanketHeight(0, PATIENT.feetZ), 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); +} 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 }, +}; diff --git a/explainers/mri-scanner/src/state/derived.test.ts b/explainers/mri-scanner/src/state/derived.test.ts new file mode 100644 index 00000000..4ea44d99 --- /dev/null +++ b/explainers/mri-scanner/src/state/derived.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { FIELD_IDS, WEIGHTING_IDS } from '../ids'; +import { + CYCLE_UNITS, + FIELDS, + MOMENTS, + PHASE_RANGES, + SPIN_COUNT, + echoAmplitude, + gradientLevel, + magnetisation, + pictureFor, +} from '../model'; +import { + CHOSEN_AXIS_LEVEL, + PRECESSION_TURNS_PER_CYCLE, + axisLevel, + createAssemblyState, + followedAxis, + lineShare, + pictureVersionOf, + precessionAngle, + timeGauge, + writeAssemblyState, +} from './derived'; +import type { AssemblySource } from './derived'; +import { DEFAULT_VIEW } from './store'; + +const ENCODING = (PHASE_RANGES.encode[0] + PHASE_RANGES.encode[1]) / 2; +const RECOVERING = (PHASE_RANGES.recover[0] + PHASE_RANGES.recover[1]) / 2; + +function sourceAt(changes: Partial = {}): AssemblySource { + return { + phase: MOMENTS.echoPeak, + playing: true, + field: 'field15', + weighting: 't2', + tipAngle: 90, + tissue: 'whiteMatter', + gradientAxis: null, + linesFilled: 24, + view: { ...DEFAULT_VIEW }, + ...changes, + }; +} + +describe('readings', () => { + it('follows the sequence gradient unless an axis is chosen', () => { + expect(followedAxis(null, ENCODING)).toBe('y'); + expect(followedAxis(null, RECOVERING)).toBeNull(); + expect(followedAxis('x', RECOVERING)).toBe('x'); + }); + + it('ramps a followed gradient and holds a chosen one fully on', () => { + expect(axisLevel(null, ENCODING)).toBe(gradientLevel(ENCODING)); + expect(axisLevel('z', RECOVERING)).toBe(CHOSEN_AXIS_LEVEL); + }); + + it('turns the spins a whole number of times per loop so the wrap is seamless', () => { + expect(Number.isInteger(PRECESSION_TURNS_PER_CYCLE)).toBe(true); + expect(precessionAngle(0)).toBeCloseTo(0, 9); + expect(Math.cos(precessionAngle(CYCLE_UNITS))).toBeCloseTo(1, 9); + }); + + it('gives each scan and line count its own picture version', () => { + const versions = new Set(); + for (const field of FIELD_IDS) { + for (const weighting of WEIGHTING_IDS) { + for (let lines = 0; lines <= 64; lines += 1) { + versions.add(pictureVersionOf(field, weighting, lines)); + } + } + } + expect(versions.size).toBe(FIELD_IDS.length * WEIGHTING_IDS.length * 65); + }); + + it('reads the time gauge in real milliseconds of the repetition', () => { + expect(timeGauge(MOMENTS.echoPeak, 't2')).toMatchObject({ ms: 100, totalMs: 2500 }); + expect(timeGauge(MOMENTS.echoPeak, 't1')).toMatchObject({ ms: 15, totalMs: 500 }); + expect(timeGauge(CYCLE_UNITS / 2, 't1').share).toBeCloseTo(0.5, 9); + }); + + it('shares the lines out of the full picture', () => { + expect(lineShare(16)).toBe(0.25); + expect(lineShare(64)).toBe(1); + }); +}); + +describe('assembly state', () => { + it('builds the sequence and spin readings from the model', () => { + const state = createAssemblyState(sourceAt({ view: { ...DEFAULT_VIEW, voxel: true } })); + expect(state.sequence).toMatchObject({ + phase: MOMENTS.echoPeak, + step: 'echo', + rf: null, + gradient: 'x', + echo: echoAmplitude(MOMENTS.echoPeak, 't2', 'whiteMatter', 'field15', 90), + }); + expect(state.spins.net).toEqual( + magnetisation(MOMENTS.echoPeak, 't2', 'whiteMatter', 'field15', 90), + ); + expect(state.spins.arrows).toHaveLength(SPIN_COUNT); + expect(state.fringe).toEqual(FIELDS.field15.fringe); + expect(state.fringe).not.toBe(FIELDS.field15.fringe); + expect(state.picture).toBe(pictureFor('field15', 't2', 24)); + }); + + it('writes into the same target objects frame after frame', () => { + const state = createAssemblyState(sourceAt()); + const { sequence, spins, fringe, view } = state; + const written = writeAssemblyState(state, sourceAt({ phase: ENCODING, field: 'field30' })); + expect(written).toBe(state); + expect(written.sequence).toBe(sequence); + expect(written.spins).toBe(spins); + expect(written.fringe).toBe(fringe); + expect(written.view).toBe(view); + expect(written.sequence.gradient).toBe('y'); + expect(written.fringe).toEqual(FIELDS.field30.fringe); + }); + + it('changes the picture version only when the scan or its lines change', () => { + const state = createAssemblyState(sourceAt()); + const first = state.pictureVersion; + writeAssemblyState(state, sourceAt({ phase: RECOVERING, tissue: 'fat', tipAngle: 30 })); + expect(state.pictureVersion).toBe(first); + writeAssemblyState(state, sourceAt({ linesFilled: 25 })); + expect(state.pictureVersion).not.toBe(first); + expect(state.picture).toBe(pictureFor('field15', 't2', 25)); + }); + + it('keeps two buffers consistent when they alternate', () => { + const front = createAssemblyState(sourceAt()); + const back = createAssemblyState(sourceAt()); + writeAssemblyState(back, sourceAt({ weighting: 't1' })); + writeAssemblyState(front, sourceAt({ weighting: 't1' })); + expect(front.pictureVersion).toBe(back.pictureVersion); + expect(front.picture).toBe(back.picture); + }); + + it('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 })); + expect(state.view).toEqual(view); + expect(state.view).not.toBe(view); + }); +}); diff --git a/explainers/mri-scanner/src/state/derived.ts b/explainers/mri-scanner/src/state/derived.ts new file mode 100644 index 00000000..5403e72b --- /dev/null +++ b/explainers/mri-scanner/src/state/derived.ts @@ -0,0 +1,182 @@ +import { FULL_TURN, wrapAngle } from '@core/math'; +import { FIELD_IDS, WEIGHTING_IDS } from '../ids'; +import type { + AssemblyState, + FieldId, + GradientAxisId, + Point, + SequenceReading, + SpinReading, + WeightingId, +} from '../ids'; +import { + CYCLE_UNITS, + DEFAULT_SPEED, + DISPLAY_TURNS_PER_S, + FIELDS, + SECONDS_PER_MINUTE, + WEIGHTINGS, + activeGradient, + echoAmplitude, + gradientLevel, + larmorHz, + magnetisation, + phaseOf, + pictureFor, + realMs, + rfPulse, + spinArrows, +} from '../model'; +import { LINES_RANGE } from './store'; +import type { MriScannerState } from './store'; + +export type AssemblySource = Pick< + MriScannerState, + | 'phase' + | 'playing' + | 'field' + | 'weighting' + | 'tipAngle' + | 'tissue' + | 'gradientAxis' + | 'linesFilled' + | 'view' +>; + +type SpinSource = Pick; + +export interface TimeGauge { + ms: number; + totalMs: number; + share: number; +} + +export const PRECESSION_TURNS_PER_CYCLE = + (DISPLAY_TURNS_PER_S * SECONDS_PER_MINUTE) / DEFAULT_SPEED; +export const CHOSEN_AXIS_LEVEL = 1; + +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]; + +const EMPTY_SEQUENCE: SequenceReading = { + phase: 0, + step: 'excite', + rf: null, + gradient: null, + gradientLevel: 0, + echo: 0, +}; + +const EMPTY_SPINS: SpinReading = { net: RESTING_NET, arrows: [], precession: 0 }; + +export function followedAxis( + gradientAxis: GradientAxisId | null, + phase: number, +): GradientAxisId | null { + return gradientAxis ?? activeGradient(phase); +} + +export function axisLevel(gradientAxis: GradientAxisId | null, phase: number): number { + return gradientAxis === null ? gradientLevel(phase) : CHOSEN_AXIS_LEVEL; +} + +export function precessionAngle(phase: number): number { + return wrapAngle((FULL_TURN * PRECESSION_TURNS_PER_CYCLE * phase) / CYCLE_UNITS); +} + +export function pictureVersionOf( + field: FieldId, + weighting: WeightingId, + linesFilled: number, +): number { + const scan = FIELD_IDS.indexOf(field) * WEIGHTING_IDS.length + WEIGHTING_IDS.indexOf(weighting); + return scan * PICTURE_STATES_PER_WEIGHTING + linesFilled; +} + +export function timeGauge(phase: number, weighting: WeightingId): TimeGauge { + return { + ms: realMs(phase, weighting), + totalMs: WEIGHTINGS[weighting].tr, + share: phase / CYCLE_UNITS, + }; +} + +export function lineShare(linesFilled: number): number { + return linesFilled / LINES_RANGE.max; +} + +function writeSequence( + target: SequenceReading, + source: SpinSource & Pick, +): void { + const { phase, weighting, tissue, field, tipAngle, gradientAxis } = source; + target.phase = phase; + target.step = phaseOf(phase); + target.rf = rfPulse(phase); + target.gradient = followedAxis(gradientAxis, phase); + target.gradientLevel = axisLevel(gradientAxis, phase); + target.echo = echoAmplitude(phase, weighting, tissue, field, tipAngle); +} + +function writeSpins(target: SpinReading, source: SpinSource & 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); + target.precession = precessionAngle(phase); +} + +function writeScan(target: AssemblyState, source: AssemblySource): void { + const { field, weighting, linesFilled } = source; + const version = pictureVersionOf(field, weighting, linesFilled); + if (target.pictureVersion !== version) { + target.picture = pictureFor(field, weighting, linesFilled); + target.pictureVersion = version; + } + target.fringe.along = FIELDS[field].fringe.along; + target.fringe.side = FIELDS[field].fringe.side; +} + +export function writeAssemblyState(target: AssemblyState, source: AssemblySource): AssemblyState { + target.phase = source.phase; + target.playing = source.playing; + target.field = source.field; + target.weighting = source.weighting; + target.tissue = source.tissue; + target.gradientAxis = source.gradientAxis; + writeSequence(target.sequence, source); + writeSpins(target.spins, source); + writeScan(target, source); + Object.assign(target.view, source.view); + return target; +} + +export function createAssemblyState(source: AssemblySource): AssemblyState { + const { phase, playing, field, weighting, tissue, gradientAxis, linesFilled, view } = source; + const target: AssemblyState = { + phase, + playing, + field, + weighting, + tissue, + gradientAxis, + sequence: { ...EMPTY_SEQUENCE }, + spins: { ...EMPTY_SPINS }, + picture: pictureFor(field, weighting, linesFilled), + pictureVersion: pictureVersionOf(field, weighting, linesFilled), + fringe: { ...FIELDS[field].fringe }, + view: { ...view }, + }; + return writeAssemblyState(target, source); +} diff --git a/explainers/mri-scanner/src/state/index.ts b/explainers/mri-scanner/src/state/index.ts new file mode 100644 index 00000000..029bc932 --- /dev/null +++ b/explainers/mri-scanner/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './derived'; +export * from './presets'; +export * from './store'; diff --git a/explainers/mri-scanner/src/state/presets.test.ts b/explainers/mri-scanner/src/state/presets.test.ts new file mode 100644 index 00000000..f2070258 --- /dev/null +++ b/explainers/mri-scanner/src/state/presets.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS, PRESET_IDS } from '../ids'; +import { PHASE_RANGES } from '../model'; +import { CAMERA_VIEWS } from '../scene/cameraViews'; +import { DEFAULT_SPEED, PICTURE_SPEED } from '../model'; +import { PRESETS } from './presets'; + +const SCENE_FLAGS = ['cutaway', 'fieldLines', 'voxel'] as const; + +describe('chapter presets', () => { + it('has the six chapters in reading order', () => { + expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]); + }); + + it('frames each chapter with a known camera view', () => { + expect(PRESET_IDS.map((id) => PRESETS[id].camera)).toEqual([ + 'room', + 'cryostat', + 'voxel', + 'coil', + 'gradient', + 'console', + ]); + for (const id of PRESET_IDS) expect(CAMERA_VIEWS).toHaveProperty(PRESETS[id].camera); + }); + + it('labels and highlights only known parts', () => { + for (const id of PRESET_IDS) { + const { labels, highlight } = PRESETS[id]; + for (const part of [...labels, ...highlight]) expect(PART_IDS).toContain(part); + } + }); + + it('highlights the shims with the magnet layers', () => { + expect(PRESETS.magnet.highlight).toEqual([...PRESETS.magnet.labels, 'shims']); + }); + + it('plays every chapter at the default speed except the picture', () => { + for (const id of PRESET_IDS) { + expect(PRESETS[id].speed).toBe(id === 'picture' ? PICTURE_SPEED : DEFAULT_SPEED); + } + }); + + it('holds the spins at the loop start and seeks the others', () => { + expect(PRESETS.spins).toMatchObject({ pauseAt: 0 }); + expect(PRESETS.spins.startAt).toBeUndefined(); + expect(PRESETS.overview.startAt).toBe(0); + expect(PRESETS.magnet.startAt).toBe(0); + expect(PRESETS.resonance.startAt).toBe(0); + expect(PRESETS.gradients.startAt).toBe(PHASE_RANGES.encode[0]); + expect(PRESETS.picture.startAt).toBe(PHASE_RANGES.echo[0]); + }); + + it('sets the cutaway, the field lines and the voxel for each chapter, never the labels', () => { + for (const id of PRESET_IDS) { + expect(Object.keys(PRESETS[id].view).sort()).toEqual([...SCENE_FLAGS].sort()); + } + expect(PRESETS.magnet.view).toEqual({ cutaway: true, fieldLines: true, voxel: false }); + expect(PRESETS.spins.view.voxel).toBe(true); + expect(PRESETS.resonance.view.voxel).toBe(true); + }); + + it('starts the chapter controls the spec names', () => { + expect(PRESETS.overview.start).toEqual({ linesFilled: 24 }); + expect(PRESETS.magnet.start).toBeUndefined(); + expect(PRESETS.spins.start).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 new file mode 100644 index 00000000..6c7af1ee --- /dev/null +++ b/explainers/mri-scanner/src/state/presets.ts @@ -0,0 +1,88 @@ +import type { ScenePreset } from '@core/scene/presetBinder'; +import type { CameraView, GradientAxisId, PartId, PresetId, TissueId, ViewOptions } from '../ids'; +import { DEFAULT_SPEED, PHASE_RANGES, PICTURE_SPEED } from '../model'; + +export interface ChapterControls { + tipAngle: number; + tissue: TissueId; + gradientAxis: GradientAxisId | null; + linesFilled: number; +} + +export interface Preset extends ScenePreset { + view: Partial; + start?: Partial; +} + +const LOOP_START = 0; +const OVERVIEW_LINES = 24; +const PICTURE_START_LINES = 8; + +const MAGNET_LABELS: readonly PartId[] = [ + 'vacuumVessel', + 'radiationShield', + 'heliumVessel', + 'mainCoils', + 'shieldCoils', + 'coldHead', + 'quenchPipe', + 'fringeLine', +]; + +const SPIN_PARTS: readonly PartId[] = ['spinArrows', 'netMagnet', 'mainField', 'patient']; + +const GRADIENT_COILS: readonly PartId[] = ['gradientX', 'gradientY', 'gradientZ', 'sliceSlab']; + +export const PRESETS: Record = { + overview: { + camera: 'room', + speed: DEFAULT_SPEED, + view: { cutaway: false, fieldLines: false, voxel: false }, + startAt: LOOP_START, + start: { linesFilled: OVERVIEW_LINES }, + labels: ['cover', 'bore', 'table', 'patient', 'headCoil', 'coldHead', 'screen', 'room'], + highlight: [], + }, + magnet: { + camera: 'cryostat', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: true, voxel: false }, + startAt: LOOP_START, + labels: MAGNET_LABELS, + highlight: [...MAGNET_LABELS, 'shims'], + }, + spins: { + camera: 'voxel', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: false, voxel: true }, + pauseAt: LOOP_START, + labels: SPIN_PARTS, + highlight: [...SPIN_PARTS, 'headCoil'], + }, + resonance: { + camera: 'coil', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: false, voxel: true }, + startAt: LOOP_START, + labels: ['bodyCoil', 'headCoil', 'rfWave', 'echoWave', 'netMagnet'], + highlight: ['bodyCoil', 'headCoil', 'rfWave', 'echoWave', 'spinArrows', 'netMagnet'], + }, + gradients: { + camera: 'gradient', + speed: DEFAULT_SPEED, + view: { cutaway: true, fieldLines: false, voxel: false }, + startAt: PHASE_RANGES.encode[0], + start: { gradientAxis: null }, + labels: [...GRADIENT_COILS, 'bodyCoil'], + highlight: GRADIENT_COILS, + }, + picture: { + camera: 'console', + speed: PICTURE_SPEED, + view: { cutaway: false, fieldLines: false, voxel: false }, + startAt: PHASE_RANGES.echo[0], + start: { linesFilled: PICTURE_START_LINES }, + labels: ['screen', 'headCoil', 'echoWave'], + highlight: ['screen', 'headCoil', 'patient', 'echoWave'], + }, +}; diff --git a/explainers/mri-scanner/src/state/store.test.ts b/explainers/mri-scanner/src/state/store.test.ts new file mode 100644 index 00000000..7e8b843e --- /dev/null +++ b/explainers/mri-scanner/src/state/store.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { LINE_DONE_UNITS, MOMENTS, rate } from '../model'; +import { PRESETS } from './presets'; +import { + CHAPTER_CONTROL_DEFAULTS, + DEFAULT_VIEW, + createMriScannerStore, + crossesLineDone, + freshScanLines, + nextLineCount, + snapToRange, + TIP_ANGLE_RANGE, +} from './store'; + +function storeOn(preset: Parameters[0] = 'magnet') { + const store = createMriScannerStore(); + store.getState().applyPreset(preset); + return store; +} + +function secondsFor(units: number, speed: number): number { + return units / rate(speed); +} + +describe('line crossing', () => { + it('counts a forward playback step that reaches the end of the echo', () => { + expect(crossesLineDone(600, 700)).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)).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)).toBe(true); + expect(crossesLineDone(900, 700)).toBe(true); + expect(crossesLineDone(900, 100)).toBe(false); + }); + + it('never counts a still phase', () => { + expect(crossesLineDone(600, 600)).toBe(false); + }); + + it('wraps to a new scan after the last line', () => { + expect(nextLineCount(0)).toBe(1); + expect(nextLineCount(63)).toBe(64); + expect(nextLineCount(64)).toBe(0); + }); +}); + +describe('snapToRange', () => { + it('clamps and rounds to the step', () => { + expect(snapToRange(43, TIP_ANGLE_RANGE)).toBe(45); + expect(snapToRange(-20, TIP_ANGLE_RANGE)).toBe(0); + expect(snapToRange(400, TIP_ANGLE_RANGE)).toBe(180); + }); +}); + +describe('mri scanner store', () => { + it('starts on the overview with its picture part filled', () => { + expect(createMriScannerStore().getState()).toMatchObject({ + phase: 0, + speed: 6, + preset: 'overview', + field: 'field15', + weighting: 't2', + tipAngle: 90, + tissue: 'whiteMatter', + gradientAxis: null, + linesFilled: 24, + view: { cutaway: false, fieldLines: false, voxel: false, labels: true }, + }); + }); + + it('opens with the view the overview chapter asks for', () => { + expect(DEFAULT_VIEW).toMatchObject(PRESETS.overview.view); + }); + + it('adds one line each time playback carries the phase past the echo', () => { + const store = createMriScannerStore({ phase: 600, linesFilled: 3 }); + const { speed } = store.getState(); + store.getState().tick(secondsFor(100, speed)); + expect(store.getState().linesFilled).toBe(4); + store.getState().tick(secondsFor(100, speed)); + expect(store.getState().linesFilled).toBe(4); + }); + + it('adds a line when a playback step spans the loop wrap', () => { + const store = createMriScannerStore({ phase: 900, linesFilled: 3 }); + store.getState().tick(secondsFor(800, store.getState().speed)); + expect(store.getState().phase).toBeCloseTo(700, 6); + expect(store.getState().linesFilled).toBe(4); + }); + + it('starts a new scan after a full picture', () => { + const store = createMriScannerStore({ phase: 600, linesFilled: 64 }); + store.getState().tick(secondsFor(100, store.getState().speed)); + expect(store.getState().linesFilled).toBe(0); + }); + + it('never adds a line while paused, on a scrub, a step, a jump or a seek', () => { + const store = createMriScannerStore({ phase: 600, linesFilled: 3, playing: false }); + store.getState().tick(secondsFor(100, store.getState().speed)); + store.getState().setPhase(700); + store.getState().setPhase(600); + store.getState().step(100); + store.getState().jumpToPhase('recover'); + store.getState().setPhase(600); + store.getState().seekMoment('echoPeak'); + store.getState().seekMoment('pulse90'); + expect(store.getState().linesFilled).toBe(3); + }); + + it('seeks a moment and pauses there', () => { + const store = createMriScannerStore(); + store.getState().seekMoment('pulse180'); + expect(store.getState()).toMatchObject({ phase: MOMENTS.pulse180, playing: false }); + }); + + it('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); + store.getState().setField('field30'); + expect(store.getState()).toMatchObject({ field: 'field30', linesFilled: 0 }); + store.getState().setLinesFilled(40); + store.getState().setWeighting('t1'); + expect(store.getState()).toMatchObject({ weighting: 't1', linesFilled: 0 }); + }); + + it('restarts a scan at the chapter start lines when the chapter sets them', () => { + const store = storeOn('picture'); + store.getState().setLinesFilled(40); + store.getState().setWeighting('t1'); + expect(store.getState().linesFilled).toBe(PRESETS.picture.start?.linesFilled); + }); + + it('keeps the scan when the same field or weighting is chosen again', () => { + const store = storeOn('magnet'); + store.getState().setLinesFilled(40); + store.getState().setField('field15'); + store.getState().setWeighting('t2'); + expect(store.getState().linesFilled).toBe(40); + }); + + it('clamps the lines and the tip angle', () => { + const store = createMriScannerStore(); + store.getState().setLinesFilled(70.4); + expect(store.getState().linesFilled).toBe(64); + store.getState().setLinesFilled(12.6); + expect(store.getState().linesFilled).toBe(13); + store.getState().setTipAngle(-3); + expect(store.getState().tipAngle).toBe(0); + store.getState().setTipAngle(122); + expect(store.getState().tipAngle).toBe(120); + }); + + it('keeps the field and weighting across chapters', () => { + const store = storeOn('magnet'); + store.getState().setField('field30'); + store.getState().setWeighting('t1'); + store.getState().applyPreset('picture'); + expect(store.getState()).toMatchObject({ field: 'field30', weighting: 't1' }); + }); + + it('resets the chapter controls to the next chapter start or the defaults', () => { + const store = storeOn('resonance'); + store.getState().setTipAngle(30); + store.getState().setTissue('fat'); + store.getState().setGradientAxis('x'); + store.getState().applyPreset('magnet'); + expect(store.getState()).toMatchObject(CHAPTER_CONTROL_DEFAULTS); + store.getState().applyPreset('picture'); + expect(store.getState()).toMatchObject({ ...CHAPTER_CONTROL_DEFAULTS, linesFilled: 8 }); + }); + + it('keeps the chapter controls when the same chapter is applied again', () => { + const store = storeOn('resonance'); + store.getState().setTipAngle(30); + store.getState().applyPreset('resonance'); + expect(store.getState().tipAngle).toBe(30); + }); + + it('sets the tissue and the gradient axis', () => { + const store = createMriScannerStore(); + store.getState().setTissue('fluid'); + store.getState().setGradientAxis('z'); + expect(store.getState()).toMatchObject({ tissue: 'fluid', gradientAxis: 'z' }); + store.getState().setGradientAxis(null); + expect(store.getState().gradientAxis).toBeNull(); + }); + + it('starts a scan from zero on chapters without start lines', () => { + expect(freshScanLines('magnet')).toBe(0); + expect(freshScanLines('overview')).toBe(24); + }); +}); diff --git a/explainers/mri-scanner/src/state/store.ts b/explainers/mri-scanner/src/state/store.ts new file mode 100644 index 00000000..fd9dd7d6 --- /dev/null +++ b/explainers/mri-scanner/src/state/store.ts @@ -0,0 +1,145 @@ +import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; +import { clamp } from '@core/math'; +import { createExplainerStore } from '@core/store'; +import type { + FieldId, + GradientAxisId, + MomentId, + PresetId, + TissueId, + ViewOptions, + WeightingId, +} from '../ids'; +import { LINE_DONE_UNITS, MODEL_SIZE, MOMENTS, SPEED_RANGE } from '../model'; +import { MRI_SCANNER_TIMELINE } from '../timeline'; +import { PRESETS } from './presets'; +import type { ChapterControls, Preset } from './presets'; + +export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] }; + +export interface MriScannerFields extends ChapterControls { + field: FieldId; + weighting: WeightingId; + view: ViewState; + preset: PresetId; +} + +export interface MriScannerOwnActions { + setField(field: FieldId): void; + setWeighting(weighting: WeightingId): void; + setLinesFilled(lines: number): void; + addLine(): void; + setTipAngle(degrees: number): void; + setTissue(tissue: TissueId): void; + setGradientAxis(axis: GradientAxisId | null): void; + seekMoment(moment: MomentId): void; +} + +export type MriScannerState = PlaybackState & MriScannerFields; +export type MriScannerStoreState = Playback & MriScannerFields & MriScannerOwnActions; +export type MriScannerStore = ExplainerStore; + +export interface SteppedRange { + min: number; + max: number; + step: number; +} + +export const TIP_ANGLE_RANGE = { min: 0, max: 180, step: 5, default: 90 } as const; +export const LINES_RANGE = { min: 0, max: MODEL_SIZE, step: 1, default: 0 } as const; + +export const DEFAULT_VIEW: ViewState = { + cutaway: false, + fieldLines: false, + voxel: false, + labels: true, +}; +export const DEFAULT_FIELD: FieldId = 'field15'; +export const DEFAULT_WEIGHTING: WeightingId = 't2'; + +export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { + tipAngle: TIP_ANGLE_RANGE.default, + tissue: 'whiteMatter', + gradientAxis: null, + linesFilled: LINES_RANGE.default, +}; + +const START_PRESET: PresetId = 'overview'; +const START_SPEED = PRESETS[START_PRESET].speed ?? SPEED_RANGE.default; + +export function snapToRange(value: number, { min, max, step }: SteppedRange): number { + return clamp(min + Math.round((value - min) / step) * step, min, max); +} + +export function crossesLineDone(previous: number, next: number): 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; +} + +export function nextLineCount(lines: number): number { + return lines >= LINES_RANGE.max ? LINES_RANGE.min : lines + 1; +} + +export function freshScanLines(preset: PresetId): number { + return PRESETS[preset].start?.linesFilled ?? LINES_RANGE.default; +} + +function presetFields(preset: Preset, state: MriScannerFields): Partial { + if (PRESETS[state.preset] === preset) return {}; + return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start }; +} + +function countLinesOnPlayback(store: MriScannerStore): MriScannerStore { + const advance = store.getState().tick; + store.setState({ + tick: (deltaSeconds) => { + const previous = store.getState().phase; + advance(deltaSeconds); + if (crossesLineDone(previous, store.getState().phase)) store.getState().addLine(); + }, + }); + return store; +} + +export function createMriScannerStore( + overrides: Partial = {}, +): MriScannerStore { + const store = createExplainerStore( + { + timeline: MRI_SCANNER_TIMELINE, + presets: PRESETS, + defaults: { preset: START_PRESET, speed: START_SPEED, view: DEFAULT_VIEW }, + extend: (set, get) => { + const newScan = () => ({ linesFilled: freshScanLines(get().preset) }); + return { + ...CHAPTER_CONTROL_DEFAULTS, + ...PRESETS[START_PRESET].start, + field: DEFAULT_FIELD, + weighting: DEFAULT_WEIGHTING, + setField: (field) => { + if (field !== get().field) set({ field, ...newScan() }); + }, + setWeighting: (weighting) => { + if (weighting !== get().weighting) set({ weighting, ...newScan() }); + }, + setLinesFilled: (lines) => { + 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 }), + setGradientAxis: (gradientAxis) => set({ gradientAxis }), + seekMoment: (moment) => { + get().pause(); + get().setPhase(MOMENTS[moment]); + }, + }; + }, + presetState: presetFields, + }, + overrides, + ); + return countLinesOnPlayback(store); +} diff --git a/explainers/mri-scanner/src/style.css b/explainers/mri-scanner/src/style.css new file mode 100644 index 00000000..b6393e52 --- /dev/null +++ b/explainers/mri-scanner/src/style.css @@ -0,0 +1,136 @@ +:root { + --rf: #ffb347; + --grad-x: #ff7a59; + --grad-y: #6fd38a; + --grad-z: #b48cff; + --echo: #4fd1c5; + --main-field: #5fa8ff; + --fringe: #ffd24c; + --fat: #ffd27a; + --white-matter: #f0ece4; + --grey-matter: #a7a2b4; + --fluid: #5fb8ff; + --screen: #1b1d20; +} + +.chapter .readouts { + --readout-min-width: 8rem; + margin-block: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; +} + +.chapter .readouts > div, +.chapter .readouts :is(dt, dd) { + min-width: 0; +} + +.chapter .readouts :is(dt, dd) { + margin: 0; + overflow-wrap: break-word; + hyphens: auto; +} + +.chapter .readouts dt { + color: var(--muted); + font-size: 0.8125rem; +} + +.chapter .readouts > div:last-child:nth-child(odd) { + grid-column: 1 / -1; +} + +.range-widget__header { + flex-wrap: wrap; +} + +.range-widget__header label { + min-width: 0; + overflow-wrap: break-word; +} + +.kspace-widget { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + margin: 4px 0 16px; +} + +.kspace-widget canvas { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1; + border-radius: var(--radius-small); + background: var(--screen); +} + +.kspace-widget figcaption { + display: contents; +} + +.kspace-widget figcaption span { + color: var(--muted); + font-size: 0.8125rem; + text-align: center; +} + +.chip[data-action='moment'] { + --tone: var(--rf); +} + +.chip[data-action='moment'][data-value='echoPeak'] { + --tone: var(--echo); +} + +.chip[data-action='field'] { + --tone: var(--main-field); +} + +.chip[data-action='gradientAxis'][data-value='x'] { + --tone: var(--grad-x); +} + +.chip[data-action='gradientAxis'][data-value='y'] { + --tone: var(--grad-y); +} + +.chip[data-action='gradientAxis'][data-value='z'] { + --tone: var(--grad-z); +} + +.chip[data-action='tissue'][data-value='fat'] { + --tone: var(--fat); +} + +.chip[data-action='tissue'][data-value='whiteMatter'] { + --tone: var(--white-matter); +} + +.chip[data-action='tissue'][data-value='greyMatter'] { + --tone: var(--grey-matter); +} + +.chip[data-action='tissue'][data-value='fluid'] { + --tone: var(--fluid); +} + +.chip:is([data-action='weighting'], [data-action='lines']) { + --tone: var(--echo); +} + +@media (pointer: coarse) { + .chip[data-action] { + height: 44px; + } +} + +@media (max-width: 480px) { + .chapter .readouts { + grid-template-columns: minmax(0, 1fr); + } + + .chapter .readouts > div:last-child:nth-child(odd) { + grid-column: auto; + } +} diff --git a/explainers/mri-scanner/src/theme.test.ts b/explainers/mri-scanner/src/theme.test.ts new file mode 100644 index 00000000..07251584 --- /dev/null +++ b/explainers/mri-scanner/src/theme.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { THEME as CORE_THEME } from '@core/theme'; +import { GRADIENT_AXIS_IDS, PHASE_IDS, TISSUE_IDS } from './ids'; +import { GRADIENT_TONES, PHASE_TONES, THEME, TISSUE_TONES } from './theme'; + +describe('theme', () => { + it('keeps the core tokens', () => { + expect(THEME).toMatchObject(CORE_THEME); + }); + + it('colours the five steps amber, green, amber, cyan and grey', () => { + expect(Object.keys(PHASE_TONES)).toEqual([...PHASE_IDS]); + expect(PHASE_TONES.excite).toBe(THEME.rf); + expect(PHASE_TONES.encode).toBe(THEME.gradY); + expect(PHASE_TONES.refocus).toBe(THEME.rf); + expect(PHASE_TONES.echo).toBe(THEME.echo); + expect(PHASE_TONES.recover).toBe(THEME.rest); + }); + + it('gives each gradient axis and each tissue its own colour', () => { + expect(Object.keys(GRADIENT_TONES)).toEqual([...GRADIENT_AXIS_IDS]); + expect(new Set(Object.values(GRADIENT_TONES)).size).toBe(GRADIENT_AXIS_IDS.length); + expect(Object.keys(TISSUE_TONES)).toEqual([...TISSUE_IDS]); + expect(new Set(Object.values(TISSUE_TONES)).size).toBe(TISSUE_IDS.length); + }); +}); diff --git a/explainers/mri-scanner/src/theme.ts b/explainers/mri-scanner/src/theme.ts new file mode 100644 index 00000000..67b959e4 --- /dev/null +++ b/explainers/mri-scanner/src/theme.ts @@ -0,0 +1,64 @@ +import { THEME as CORE_THEME } from '@core/theme'; +import type { GradientAxisId, PhaseId, TissueId } from './ids'; + +export const THEME = { + ...CORE_THEME, + rf: '#ffb347', + gradX: '#ff7a59', + gradY: '#6fd38a', + gradZ: '#b48cff', + echo: '#4fd1c5', + rest: '#8b929c', + cover: '#e8ecef', + coverTrim: '#c8cdd2', + vacuumVessel: '#9aa3ad', + radiationShield: '#c9cfd6', + helium: '#9fd8ff', + winding: '#c8783c', + shieldWinding: '#a9632f', + shim: '#5a6068', + boreLiner: '#f4f5f6', + table: '#d8dce0', + cradle: '#eef0f2', + blanket: '#7fa7c9', + skin: '#e0b69a', + headCoil: '#f2f3f5', + copper: '#b87333', + copperDark: '#7a4a22', + floor: '#d9d6cf', + ceiling: '#cfd3d8', + windowGlass: '#16222e', + consoleGlow: '#3a5a7a', + screenFrame: '#1b1d20', + fieldLine: '#5fa8ff', + fringe: '#ffd24c', + mainField: '#5fa8ff', + spinArrow: '#e6e9ee', + netMagnet: '#ff5d73', + slice: '#ffb347', + fat: '#ffd27a', + whiteMatter: '#f0ece4', + greyMatter: '#a7a2b4', + fluid: '#5fb8ff', +} as const; + +export const PHASE_TONES: Readonly> = { + excite: THEME.rf, + encode: THEME.gradY, + refocus: THEME.rf, + echo: THEME.echo, + recover: THEME.rest, +}; + +export const GRADIENT_TONES: Readonly> = { + x: THEME.gradX, + y: THEME.gradY, + z: THEME.gradZ, +}; + +export const TISSUE_TONES: Readonly> = { + fat: THEME.fat, + whiteMatter: THEME.whiteMatter, + greyMatter: THEME.greyMatter, + fluid: THEME.fluid, +}; diff --git a/explainers/mri-scanner/src/timeline.dom.test.ts b/explainers/mri-scanner/src/timeline.dom.test.ts new file mode 100644 index 00000000..818f3cf9 --- /dev/null +++ b/explainers/mri-scanner/src/timeline.dom.test.ts @@ -0,0 +1,83 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import en from '../locales/en.json'; +import { MOMENT_IDS, PHASE_IDS } from './ids'; +import type { PhaseId } from './ids'; +import { CYCLE_UNITS, PHASE_RANGES } from './model'; +import { PHASE_TONES } from './theme'; +import { DEFAULT_SPEED, MOMENTS, PICTURE_SPEED, SPEED_RANGE } from './model'; +import { + MRI_SCANNER_TIMELINE, + describePhase, + describeSpeed, + formatPhase, + formatSpeed, + momentAt, +} from './timeline'; + +describe('mri scanner timeline', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('loops one repetition through the five steps of the spin echo', () => { + const { phases, cycle, loop } = MRI_SCANNER_TIMELINE; + expect(cycle).toBe(CYCLE_UNITS); + expect(loop).toBe(true); + expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]); + phases.forEach((phase) => { + const [start, end] = PHASE_RANGES[phase.id as PhaseId]; + expect(phase).toMatchObject({ start, end }); + }); + expect(phases[0].start).toBe(0); + expect(phases.at(-1)?.end).toBe(cycle); + phases.slice(1).forEach((phase, index) => expect(phase.start).toBe(phases[index].end)); + }); + + it('colours each step with its theme tone and names it from the locale', () => { + expect(MRI_SCANNER_TIMELINE.phases.map((phase) => phase.tone)).toEqual( + PHASE_IDS.map((id) => PHASE_TONES[id]), + ); + MRI_SCANNER_TIMELINE.phases.forEach((phase) => { + expect(phase.labelKey).toBe(`timeline.phase.${phase.id}`); + expect(phase.jumpLabelKey).toBe(`timeline.jump.${phase.id}`); + }); + }); + + it('scrubs one unit at a time and nudges five, or fifty with shift', () => { + expect(MRI_SCANNER_TIMELINE.step).toBe(1); + expect(MRI_SCANNER_TIMELINE.nudge).toEqual({ fine: 5, coarse: 50 }); + expect(MRI_SCANNER_TIMELINE.labelKey).toBe('timeline.label'); + expect(MRI_SCANNER_TIMELINE.phasesLabelKey).toBe('timeline.phases'); + }); + + it('counts speed in repetitions per minute, six by default and thirty for the picture', () => { + const { rate, speed } = MRI_SCANNER_TIMELINE; + expect(speed).toMatchObject({ min: 2, max: 60, step: 1, labelKey: 'timeline.speed' }); + expect(DEFAULT_SPEED).toBe(6); + expect(SPEED_RANGE.default).toBe(DEFAULT_SPEED); + expect(PICTURE_SPEED).toBe(30); + expect(CYCLE_UNITS / rate(DEFAULT_SPEED)).toBe(10); + expect(CYCLE_UNITS / rate(PICTURE_SPEED)).toBe(2); + }); + + it('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', () => { + 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', () => { + expect(formatSpeed(6)).toBe('6/min'); + expect(describeSpeed(30)).toBe('30 repetitions per minute'); + }); + + it('finds the moment a paused phase sits on', () => { + MOMENT_IDS.forEach((id) => expect(momentAt(MOMENTS[id])).toBe(id)); + expect(momentAt(MOMENTS.pulse90 + 1)).toBeNull(); + }); +}); diff --git a/explainers/mri-scanner/src/timeline.ts b/explainers/mri-scanner/src/timeline.ts new file mode 100644 index 00000000..885b23f8 --- /dev/null +++ b/explainers/mri-scanner/src/timeline.ts @@ -0,0 +1,80 @@ +import type { Phase, Timeline } from '@core/explainer'; +import { formatNumber } from '@core/format'; +import { t } from '@core/i18n'; +import { MOMENT_IDS, PHASE_IDS } from './ids'; +import type { MomentId, PhaseId } from './ids'; +import { CYCLE_UNITS, MOMENTS, PHASE_RANGES, SPEED_RANGE, phaseOf, rate } from './model'; +import { PHASE_TONES } from './theme'; + +const SCRUBBER_STEP_UNITS = 1; +const FINE_STEP_UNITS = 5; +const COARSE_STEP_UNITS = 50; + +function phaseKey(id: PhaseId): string { + return `timeline.phase.${id}`; +} + +function jumpKey(id: PhaseId): string { + return `timeline.jump.${id}`; +} + +function phaseName(phase: number): string { + return t(phaseKey(phaseOf(phase))); +} + +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', stepCount(phase)); +} + +export function describePhase(phase: number): string { + return t('timeline.during', { ...stepCount(phase), phase: phaseName(phase) }); +} + +export function formatSpeed(speed: number): string { + return t('timeline.speedValue', { value: formatNumber(speed) }); +} + +export function describeSpeed(speed: number): string { + return t('timeline.speedFormat', { value: formatNumber(speed) }); +} + +export function momentAt(phase: number): MomentId | null { + return MOMENT_IDS.find((id) => MOMENTS[id] === phase) ?? null; +} + +const SEQUENCE_PHASES: readonly Phase[] = PHASE_IDS.map((id) => ({ + id, + start: PHASE_RANGES[id][0], + end: PHASE_RANGES[id][1], + labelKey: phaseKey(id), + jumpLabelKey: jumpKey(id), + tone: PHASE_TONES[id], +})); + +export const MRI_SCANNER_TIMELINE: Timeline = { + cycle: CYCLE_UNITS, + loop: true, + step: SCRUBBER_STEP_UNITS, + nudge: { fine: FINE_STEP_UNITS, coarse: COARSE_STEP_UNITS }, + labelKey: 'timeline.label', + phasesLabelKey: 'timeline.phases', + formatPhase, + describePhase, + rate, + phases: SEQUENCE_PHASES, + speed: { + min: SPEED_RANGE.min, + max: SPEED_RANGE.max, + step: SPEED_RANGE.step, + labelKey: 'timeline.speed', + format: formatSpeed, + describe: describeSpeed, + }, +}; diff --git a/explainers/mri-scanner/src/ui/actions.test.ts b/explainers/mri-scanner/src/ui/actions.test.ts new file mode 100644 index 00000000..ac35b8ef --- /dev/null +++ b/explainers/mri-scanner/src/ui/actions.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { FOLLOW_SEQUENCE, MOMENT_IDS } from '../ids'; +import { MOMENTS } from '../model'; +import { createMriScannerStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; + +function run(action: string, value: string) { + const store = createMriScannerStore({ playing: true }); + CHAPTER_ACTIONS[action]?.run(store.getState(), value); + return store.getState(); +} + +function current(action: string, state: ReturnType): string | undefined { + return CHAPTER_ACTIONS[action]?.current?.(state); +} + +describe('chapter actions', () => { + it('switches the field everywhere and the weighting in the picture chapter', () => { + expect(current('field', run('field', 'field30'))).toBe('field30'); + const picked = run('weighting', 't1'); + expect(picked.preset).toBe('picture'); + expect(current('weighting', picked)).toBe('t1'); + }); + + it('picks a tissue in the resonance chapter', () => { + const state = run('tissue', 'fluid'); + expect(state.preset).toBe('resonance'); + expect(current('tissue', state)).toBe('fluid'); + }); + + it('holds one gradient coil or follows the sequence', () => { + const held = run('gradientAxis', 'y'); + expect(held.gradientAxis).toBe('y'); + expect(current('gradientAxis', held)).toBe('y'); + const following = run('gradientAxis', FOLLOW_SEQUENCE); + expect(following.gradientAxis).toBeNull(); + expect(current('gradientAxis', following)).toBe(FOLLOW_SEQUENCE); + }); + + it('pauses at each moment and marks it until playback moves on', () => { + MOMENT_IDS.forEach((moment) => { + const state = run('moment', moment); + expect(state.playing).toBe(false); + expect(state.phase).toBe(MOMENTS[moment]); + expect(current('moment', state)).toBe(moment); + }); + expect(current('moment', createMriScannerStore({ phase: 1 }).getState())).toBe(''); + }); + + it('fills a chosen number of lines and marks only matching counts', () => { + const state = run('lines', '32'); + expect(state.linesFilled).toBe(32); + expect(state.playing).toBe(false); + expect(current('lines', state)).toBe('32'); + expect(current('lines', createMriScannerStore({ linesFilled: 5 }).getState())).toBe(''); + }); + + it('rejects values it does not know', () => { + expect(() => run('lines', '7')).toThrow(); + expect(() => run('gradientAxis', 'w')).toThrow(); + }); +}); diff --git a/explainers/mri-scanner/src/ui/actions.ts b/explainers/mri-scanner/src/ui/actions.ts new file mode 100644 index 00000000..84a37a02 --- /dev/null +++ b/explainers/mri-scanner/src/ui/actions.ts @@ -0,0 +1,77 @@ +import type { ChapterAction } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { + AXIS_CHOICE_IDS, + FIELD_IDS, + FOLLOW_SEQUENCE, + LINE_CHOICE_IDS, + MOMENT_IDS, + TISSUE_IDS, + WEIGHTING_IDS, +} from '../ids'; +import type { GradientAxisId, PresetId } from '../ids'; +import type { MriScannerStoreState } from '../state'; +import { momentAt } from '../timeline'; + +const NOTHING_CURRENT = ''; + +type ChapterChange = (state: MriScannerStoreState, ...args: A) => void; + +export function inChapter( + preset: PresetId, + change: ChapterChange, +): ChapterChange { + return (state, ...args) => { + if (state.preset !== preset) state.applyPreset(preset); + change(state, ...args); + }; +} + +function axisOf(value: string): GradientAxisId | null { + const choice = parseOption(value, AXIS_CHOICE_IDS); + return choice === FOLLOW_SEQUENCE ? null : choice; +} + +function pausedMoment(state: MriScannerStoreState): string { + if (state.playing) return NOTHING_CURRENT; + return momentAt(state.phase) ?? NOTHING_CURRENT; +} + +function lineChoiceOf(lines: number): string { + return LINE_CHOICE_IDS.find((choice) => Number(choice) === lines) ?? NOTHING_CURRENT; +} + +export const CHAPTER_ACTIONS: Record> = { + field: { + run: (state, value) => state.setField(parseOption(value, FIELD_IDS)), + current: (state) => state.field, + }, + weighting: { + run: inChapter('picture', (state, value: string) => + state.setWeighting(parseOption(value, WEIGHTING_IDS)), + ), + current: (state) => state.weighting, + }, + tissue: { + run: inChapter('resonance', (state, value: string) => + state.setTissue(parseOption(value, TISSUE_IDS)), + ), + current: (state) => state.tissue, + }, + gradientAxis: { + run: inChapter('gradients', (state, value: string) => state.setGradientAxis(axisOf(value))), + current: (state) => state.gradientAxis ?? FOLLOW_SEQUENCE, + }, + moment: { + run: inChapter('resonance', (state, value: string) => + state.seekMoment(parseOption(value, MOMENT_IDS)), + ), + current: pausedMoment, + }, + lines: { + run: inChapter('picture', (state, value: string) => + state.setLinesFilled(Number(parseOption(value, LINE_CHOICE_IDS))), + ), + current: (state) => lineChoiceOf(state.linesFilled), + }, +}; diff --git a/explainers/mri-scanner/src/ui/dock.ts b/explainers/mri-scanner/src/ui/dock.ts new file mode 100644 index 00000000..6b5fb12a --- /dev/null +++ b/explainers/mri-scanner/src/ui/dock.ts @@ -0,0 +1,53 @@ +import type { Choice, ViewToggle } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { FIELD_IDS, WEIGHTING_IDS } from '../ids'; +import type { MriScannerStoreState } from '../state'; + +const CUTAWAY_ICON = + ''; +const FIELD_LINES_ICON = + ''; +const LABELS_ICON = + ''; + +export const MRI_SCANNER_CHOICES: readonly Choice[] = [ + { + id: 'field', + labelKey: 'controls.field', + shortcut: 'B', + options: FIELD_IDS.map((field) => ({ + value: field, + labelKey: `controls.fieldOptions.${field}`, + })), + select: (state) => state.field, + apply: (state, value) => state.setField(parseOption(value, FIELD_IDS)), + }, + { + id: 'weighting', + labelKey: 'controls.weighting', + shortcut: 'W', + options: WEIGHTING_IDS.map((weighting) => ({ + value: weighting, + labelKey: `controls.weightingOptions.${weighting}`, + })), + select: (state) => state.weighting, + apply: (state, value) => state.setWeighting(parseOption(value, WEIGHTING_IDS)), + }, +]; + +function viewToggle(view: string, shortcut: string, icon: string): ViewToggle { + return { + view, + shortcut, + nameKey: `controls.views.${view}.name`, + shortKey: `controls.views.${view}.short`, + hintKey: `controls.views.${view}.hint`, + icon, + }; +} + +export const VIEW_TOGGLES: readonly ViewToggle[] = [ + viewToggle('cutaway', 'C', CUTAWAY_ICON), + viewToggle('fieldLines', 'F', FIELD_LINES_ICON), + viewToggle('labels', 'L', LABELS_ICON), +]; diff --git a/explainers/mri-scanner/src/ui/format.dom.test.ts b/explainers/mri-scanner/src/ui/format.dom.test.ts new file mode 100644 index 00000000..29b50c16 --- /dev/null +++ b/explainers/mri-scanner/src/ui/format.dom.test.ts @@ -0,0 +1,92 @@ +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, + formatAxisRole, + formatBoreWidth, + formatBrightness, + formatEarthMultiple, + formatEdgeShift, + formatFieldNow, + formatFieldShare, + formatFringe, + formatHelium, + formatLarmor, + formatLarmorBand, + formatLines, + formatRise, + formatScanTime, + formatSlowdown, + formatSpread, + formatSurplus, + formatSurplusCount, + formatT1, + formatT2, + formatTimeGauge, + formatTurbo, + toSuperscript, +} from './format'; + +describe('readout formats', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('formats the gauge', () => { + expect(formatLarmor('field15')).toBe('63.9 MHz'); + expect(formatLarmor('field30')).toBe('127.7 MHz'); + expect(formatTimeGauge({ ms: 1250.4, totalMs: 2500, share: 0.5 })).toBe('1,250 of 2,500 ms'); + expect(formatLines(24)).toBe('24 of 64'); + }); + + it('keeps the changing gauge numbers as wide as their totals', () => { + expect(formatTimeGauge({ ms: 7, totalMs: 2500, share: 0 })).toBe( + '\u00a0\u00a0\u00a0\u00a07 of 2,500 ms', + ); + expect(formatLines(3)).toBe('\u00a03 of 64'); + }); + + it('formats the overview and the magnet', () => { + expect(formatFieldNow('field15')).toBe('1.5 T, always on'); + expect(formatBoreWidth()).toBe('70 cm across'); + expect(formatEarthMultiple('field15')).toBe('23,000 to 60,000 times stronger'); + expect(formatEarthMultiple('field30')).toBe('46,000 to 120,000 times stronger'); + expect(formatHelium()).toBe('4.2 K, -269 °C'); + expect(formatFringe('field30')).toBe('5.2 m along the tunnel, 2.8 m to the side'); + }); + + it('formats the spins', () => { + expect(formatLarmorBand('field15')).toBe('63.9 MHz, just below FM radio'); + expect(formatLarmorBand('field30')).toBe('127.7 MHz, just above FM radio'); + expect(formatSurplus('field15')).toBe('about 5 in a million'); + expect(formatSurplusCount('field15')).toBe('about 3.3 × 10¹⁴'); + expect(formatSlowdown('field15', 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('⁻¹⁹'); + }); + + it('formats the tip, the relaxation times and the gradients', () => { + expect([formatAcross(90), formatAlong(90)]).toEqual(['100 %', '0 %']); + expect(formatAlong(180)).toBe('-100 %'); + expect([formatT1('field15', 'whiteMatter'), formatT2('field15', 'whiteMatter')]).toEqual([ + '884 ms', + '72 ms', + ]); + expect(formatEdgeShift()).toBe('5.4 mT'); + expect(formatFieldShare('field15')).toBe('0.36 %'); + expect(formatSpread()).toBe('230 kHz'); + expect(formatRise()).toBe('0.225 ms'); + expect(formatAxisRole(null)).toBe(en.chapters.gradients.roleValue.sequence); + expect(formatAxisRole('x')).toBe(en.chapters.gradients.roleValue.x); + }); + + it('formats the picture', () => { + expect(formatScanTime('t2')).toBe('10.7 min for 256 lines'); + expect(formatScanTime('t1')).toBe('2.1 min for 256 lines'); + expect(formatTurbo('t2')).toBe('about 40 s'); + expect(formatTurbo('t1')).toBe('about 8 s'); + expect(formatBrightness('field15', 't2', 'fluid')).toBe('100 %'); + }); +}); diff --git a/explainers/mri-scanner/src/ui/format.ts b/explainers/mri-scanner/src/ui/format.ts new file mode 100644 index 00000000..ed2969bf --- /dev/null +++ b/explainers/mri-scanner/src/ui/format.ts @@ -0,0 +1,193 @@ +import { formatCompact, formatFixed, formatNumber, formatSignificant } from '@core/format'; +import { t } from '@core/i18n'; +import { FOLLOW_SEQUENCE } from '../ids'; +import type { FieldId, GradientAxisId, TissueId, WeightingId } from '../ids'; +import { + BORE, + FIELDS, + HELIUM_K, + KELVIN_AT_ZERO_CELSIUS, + MODEL_SIZE, + REAL_LINES, + RELAXATION, + SECONDS_PER_MINUTE, + earthMultiple, + edgeShiftMT, + frequencySpreadKHz, + larmorMHz, + metresToCentimetres, + relativeBrightness, + riseTimeMs, + scanSeconds, + shareOfField, + spinSurplus, + surplusPerMm3, + tipComponents, +} from '../model'; +import { drawnSlowdown } from '../state'; +import type { TimeGauge } from '../state'; + +const WHOLE = 0; +const COLUMN_PAD = '\u00a0'; +const TENTHS = 1; +const HUNDREDTHS = 2; +const PERCENT = 100; +const PER_MILLION = 1e6; +const EARTH_DIGITS = 2; +const RISE_DIGITS = 3; +const SLOWDOWN_DIGITS = 3; +const FM_BAND_LOW_MHZ = 87.5; +const DECIMAL = 10; +const RADII_PER_DIAMETER = 2; +export const TURBO_LINES_PER_REPETITION = 16; +const SUPERSCRIPT_DIGITS = '⁰¹²³⁴⁵⁶⁷⁸⁹'; +const SUPERSCRIPT_MINUS = '⁻'; + +function unit(key: string, value: string): string { + return t(`units.${key}`, { value }); +} + +export function formatPercent(fraction: number): string { + return unit('percent', formatNumber(Math.round(fraction * PERCENT))); +} + +export function formatDegrees(degrees: number): string { + return unit('degrees', formatNumber(degrees)); +} + +export function formatMs(ms: number): string { + return unit('ms', formatNumber(ms)); +} + +export function formatLarmor(field: FieldId): string { + return unit('mhz', formatFixed(larmorMHz(field), TENTHS)); +} + +function steadyCount(value: number, total: number): { value: string; total: string } { + const whole = formatNumber(total); + return { value: formatNumber(value).padStart(whole.length, COLUMN_PAD), total: whole }; +} + +export function formatTimeGauge(gauge: TimeGauge): string { + return t('units.msOf', steadyCount(Math.round(gauge.ms), gauge.totalMs)); +} + +export function formatLines(lines: number): string { + return t('units.linesOf', steadyCount(lines, MODEL_SIZE)); +} + +export function formatFieldNow(field: FieldId): string { + return t('chapters.overview.field', { tesla: unit('tesla', formatNumber(FIELDS[field].tesla)) }); +} + +export function formatBoreWidth(): string { + const cm = metresToCentimetres(RADII_PER_DIAMETER * BORE.radius); + return t('chapters.overview.bore', { cm: unit('cm', formatNumber(Math.round(cm))) }); +} + +export function formatEarthMultiple(field: FieldId): string { + const { min, max } = earthMultiple(field); + return t('chapters.magnet.earthValue', { + min: formatSignificant(min, EARTH_DIGITS), + max: formatSignificant(max, EARTH_DIGITS), + }); +} + +export function formatHelium(): string { + return t('units.kelvin', { + kelvin: formatFixed(HELIUM_K, TENTHS), + celsius: formatNumber(Math.round(HELIUM_K - KELVIN_AT_ZERO_CELSIUS)), + }); +} + +export function formatFringe(field: FieldId): string { + const { along, side } = FIELDS[field].fringe; + return t('chapters.magnet.fringeValue', { along: formatNumber(along), side: formatNumber(side) }); +} + +export function formatLarmorBand(field: FieldId): string { + const band = larmorMHz(field) < FM_BAND_LOW_MHZ ? 'belowFm' : 'aboveFm'; + return t('chapters.spins.larmorBand', { + value: formatLarmor(field), + band: t(`chapters.spins.band.${band}`), + }); +} + +export function formatSurplus(field: FieldId): string { + return t('chapters.spins.surplusValue', { + perMillion: formatNumber(Math.round(spinSurplus(field) * PER_MILLION)), + }); +} + +export function toSuperscript(exponent: number): string { + const digits = [...String(Math.abs(exponent))].map((digit) => SUPERSCRIPT_DIGITS[Number(digit)]); + return (exponent < 0 ? SUPERSCRIPT_MINUS : '') + digits.join(''); +} + +export function formatSurplusCount(field: FieldId): string { + const count = surplusPerMm3(field); + const exponent = Math.floor(Math.log10(count)); + return t('chapters.spins.countValue', { + mantissa: formatFixed(count / DECIMAL ** exponent, TENTHS), + exponent: toSuperscript(exponent), + }); +} + +export function formatSlowdown(field: FieldId, speed: number): string { + return t('chapters.spins.slowdownValue', { + factor: formatCompact(drawnSlowdown(field, speed), SLOWDOWN_DIGITS), + }); +} + +export function formatAcross(tipAngle: number): string { + return formatPercent(tipComponents(tipAngle).across); +} + +export function formatAlong(tipAngle: number): string { + return formatPercent(tipComponents(tipAngle).along); +} + +export function formatT1(field: FieldId, tissue: TissueId): string { + return formatMs(RELAXATION[field][tissue].t1); +} + +export function formatT2(field: FieldId, tissue: TissueId): string { + return formatMs(RELAXATION[field][tissue].t2); +} + +export function formatEdgeShift(): string { + return unit('mT', formatFixed(edgeShiftMT(), TENTHS)); +} + +export function formatFieldShare(field: FieldId): string { + return unit('percent', formatFixed(shareOfField(field), HUNDREDTHS)); +} + +export function formatSpread(): string { + return unit('kHz', formatFixed(frequencySpreadKHz(), WHOLE)); +} + +export function formatRise(): string { + return unit('ms', formatSignificant(riseTimeMs(), RISE_DIGITS)); +} + +export function formatAxisRole(axis: GradientAxisId | null): string { + return t(`chapters.gradients.roleValue.${axis ?? FOLLOW_SEQUENCE}`); +} + +export function formatBrightness(field: FieldId, weighting: WeightingId, tissue: TissueId): string { + return formatPercent(relativeBrightness(field, weighting)[tissue]); +} + +export function formatScanTime(weighting: WeightingId): string { + return t('chapters.picture.scanTimeValue', { + minutes: formatFixed(scanSeconds(weighting) / SECONDS_PER_MINUTE, TENTHS), + lines: formatNumber(REAL_LINES), + }); +} + +export function formatTurbo(weighting: WeightingId): string { + return t('chapters.picture.turboValue', { + seconds: formatNumber(scanSeconds(weighting) / TURBO_LINES_PER_REPETITION), + }); +} diff --git a/explainers/mri-scanner/src/ui/index.ts b/explainers/mri-scanner/src/ui/index.ts new file mode 100644 index 00000000..0740cfee --- /dev/null +++ b/explainers/mri-scanner/src/ui/index.ts @@ -0,0 +1,24 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { MriScannerStore } from '../state'; +import { mountGradientsReadouts } from './mountGradientsReadouts'; +import { mountMagnetReadouts } from './mountMagnetReadouts'; +import { mountOverviewReadouts } from './mountOverviewReadouts'; +import { mountPictureReadouts } from './mountPictureReadouts'; +import { mountResonanceControl } from './mountResonanceControl'; +import { mountSpinsReadouts } from './mountSpinsReadouts'; + +export { CHAPTER_ACTIONS } from './actions'; +export { MRI_SCANNER_CHOICES, VIEW_TOGGLES } from './dock'; +export { MRI_SCANNER_READOUTS } from './readouts'; + +export function mountMriScannerUi(root: Document, store: MriScannerStore): Disposer { + return disposeAll([ + mountOverviewReadouts(root, store), + mountMagnetReadouts(root, store), + mountSpinsReadouts(root, store), + mountResonanceControl(root, store), + mountGradientsReadouts(root, store), + mountPictureReadouts(root, store), + ]); +} diff --git a/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts b/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts new file mode 100644 index 00000000..1eddcab7 --- /dev/null +++ b/explainers/mri-scanner/src/ui/kspaceView.dom.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { CanvasSurface } from '@core/ui/canvasSurface'; +import type { CanvasFrame, Painter } from '@core/ui/canvasSurface'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { MODEL_SIZE } from '../model'; +import { createMriScannerStore } from '../state'; +import type { MriScannerStore } from '../state'; +import { mountKspaceView } from './kspaceView'; + +const FRAME: CanvasFrame = { width: 256, height: 256, ratio: 1, fontFamily: 'sans-serif' }; + +function fakeContext() { + return { fillRect: vi.fn(), fillText: vi.fn() }; +} + +function paintWith(painter: Painter | undefined) { + const context = fakeContext(); + painter?.(context as unknown as CanvasRenderingContext2D, FRAME); + return context; +} + +describe('k-space and image canvases', () => { + let store: MriScannerStore; + let dispose: () => void; + let paint: MockInstance; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = chapters; + paint = vi.spyOn(CanvasSurface.prototype, 'paint'); + store = createMriScannerStore({ playing: false, linesFilled: 0 }); + dispose = mountKspaceView(document, store); + }); + + afterEach(() => { + dispose(); + paint.mockRestore(); + }); + + function lastPainters(): Painter[] { + return paint.mock.calls.slice(-2).map(([painter]) => painter); + } + + it('makes both canvases square', () => { + document.querySelectorAll('canvas').forEach((canvas) => { + expect(canvas.width).toBe(canvas.height); + }); + }); + + it('shows the empty hint on the image before the first line', () => { + expect(paint).toHaveBeenCalledTimes(2); + const [, image] = lastPainters(); + expect(paintWith(image).fillText).toHaveBeenCalledWith( + en.chapters.picture.empty, + expect.any(Number), + expect.any(Number), + expect.any(Number), + ); + }); + + it('draws k-space and the image once lines arrive', () => { + store.getState().setLinesFilled(16); + expect(paint).toHaveBeenCalledTimes(4); + const [kspace, image] = lastPainters(); + expect(paintWith(kspace).fillRect.mock.calls.length).toBeGreaterThan(1); + const drawn = paintWith(image); + expect(drawn.fillText).not.toHaveBeenCalled(); + expect(drawn.fillRect.mock.calls.length).toBeGreaterThan(1); + }); + + 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'); + expect(paint).toHaveBeenCalledTimes(2); + store.getState().setField('field30'); + expect(paint).toHaveBeenCalledTimes(4); + }); +}); diff --git a/explainers/mri-scanner/src/ui/kspaceView.ts b/explainers/mri-scanner/src/ui/kspaceView.ts new file mode 100644 index 00000000..5634bc77 --- /dev/null +++ b/explainers/mri-scanner/src/ui/kspaceView.ts @@ -0,0 +1,93 @@ +import { t } from '@core/i18n'; +import { CanvasSurface, canvasFont } from '@core/ui/canvasSurface'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { watchShallowLocalized } from '@core/ui/subscribe'; +import type { FieldId, WeightingId } from '../ids'; +import { MODEL_SIZE, kspaceDisplay, pictureFor } from '../model'; +import type { MriScannerStore, MriScannerStoreState } from '../state'; +import { THEME } from '../theme'; + +export type PictureInputs = readonly [field: FieldId, weighting: WeightingId, linesFilled: number]; + +const PIXELS_PER_CELL = 4; +const CANVAS_SIZE = MODEL_SIZE * PIXELS_PER_CELL; +const FULL_LEVEL = 255; +const EMPTY_FONT_PX = 13; +const EMPTY_PADDING_PX = 12; +const HALF = 0.5; +const BACKGROUND = THEME.screenFrame; + +function selectInputs(state: MriScannerStoreState): PictureInputs { + return [state.field, state.weighting, state.linesFilled]; +} + +function grey(level: number): string { + const channel = Math.round(level * FULL_LEVEL); + return `rgb(${channel} ${channel} ${channel})`; +} + +export function paintGrid( + context: CanvasRenderingContext2D, + frame: CanvasFrame, + values: Float32Array, +): void { + const cell = frame.width / MODEL_SIZE; + context.fillStyle = BACKGROUND; + context.fillRect(0, 0, frame.width, frame.height); + values.forEach((level, index) => { + if (level <= 0) return; + context.fillStyle = grey(level); + const row = Math.floor(index / MODEL_SIZE); + const column = index % MODEL_SIZE; + context.fillRect(column * cell, row * cell, Math.ceil(cell), Math.ceil(cell)); + }); +} + +export function paintEmpty(context: CanvasRenderingContext2D, frame: CanvasFrame): void { + context.fillStyle = BACKGROUND; + context.fillRect(0, 0, frame.width, frame.height); + context.fillStyle = THEME.rest; + context.font = canvasFont(frame, EMPTY_FONT_PX); + context.textAlign = 'center'; + context.textBaseline = 'middle'; + const maxWidth = frame.width - 2 * EMPTY_PADDING_PX; + context.fillText(t('chapters.picture.empty'), frame.width * HALF, frame.height * HALF, maxWidth); +} + +function squareCanvas(root: ParentNode, name: string): HTMLCanvasElement { + const canvas = requireElement(root, `[data-canvas="${name}"]`); + canvas.width = CANVAS_SIZE; + canvas.height = CANVAS_SIZE; + 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 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)), + ); + image.paint((context, frame) => + lines > 0 + ? paintGrid(context, frame, pictureFor(field, weighting, lines)) + : paintEmpty(context, frame), + ); + }); + return () => { + stopWatching(); + kspace.dispose(); + image.dispose(); + }; +} diff --git a/explainers/mri-scanner/src/ui/mountGradientsReadouts.ts b/explainers/mri-scanner/src/ui/mountGradientsReadouts.ts new file mode 100644 index 00000000..8dfc78ff --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountGradientsReadouts.ts @@ -0,0 +1,20 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { + formatAxisRole, + formatEdgeShift, + formatFieldShare, + formatRise, + formatSpread, +} from './format'; + +export function mountGradientsReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + edgeShift: () => formatEdgeShift(), + fieldShare: (state) => formatFieldShare(state.field), + spread: () => formatSpread(), + rise: () => formatRise(), + axisRole: (state) => formatAxisRole(state.gradientAxis), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountMagnetReadouts.ts b/explainers/mri-scanner/src/ui/mountMagnetReadouts.ts new file mode 100644 index 00000000..e43ff172 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountMagnetReadouts.ts @@ -0,0 +1,12 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { formatEarthMultiple, formatFringe, formatHelium } from './format'; + +export function mountMagnetReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + earthMultiple: (state) => formatEarthMultiple(state.field), + heliumTemp: () => formatHelium(), + fringe: (state) => formatFringe(state.field), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountOverviewReadouts.ts b/explainers/mri-scanner/src/ui/mountOverviewReadouts.ts new file mode 100644 index 00000000..c28134d3 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountOverviewReadouts.ts @@ -0,0 +1,11 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { formatBoreWidth, formatFieldNow } from './format'; + +export function mountOverviewReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(root, store, { + fieldNow: (state) => formatFieldNow(state.field), + boreWidth: () => formatBoreWidth(), + }); +} diff --git a/explainers/mri-scanner/src/ui/mountPictureReadouts.ts b/explainers/mri-scanner/src/ui/mountPictureReadouts.ts new file mode 100644 index 00000000..0a5ddc92 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountPictureReadouts.ts @@ -0,0 +1,32 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { TissueId } from '../ids'; +import type { MriScannerStore, MriScannerStoreState } from '../state'; +import { formatBrightness, formatScanTime, formatTurbo } from './format'; +import { mountKspaceView } from './kspaceView'; + +const BRIGHTNESS_READOUTS: Readonly> = { + brightFat: 'fat', + brightWhite: 'whiteMatter', + brightGrey: 'greyMatter', + brightFluid: 'fluid', +}; + +function brightnessOf(tissue: TissueId) { + return (state: MriScannerStoreState) => formatBrightness(state.field, state.weighting, tissue); +} + +export function mountPictureReadouts(root: Document, store: MriScannerStore): Disposer { + const brightness = Object.fromEntries( + Object.entries(BRIGHTNESS_READOUTS).map(([id, tissue]) => [id, brightnessOf(tissue)]), + ); + return disposeAll([ + mountLiveReadouts(root, store, { + ...brightness, + scanTime: (state) => formatScanTime(state.weighting), + turbo: (state) => formatTurbo(state.weighting), + }), + mountKspaceView(root, store), + ]); +} diff --git a/explainers/mri-scanner/src/ui/mountResonanceControl.ts b/explainers/mri-scanner/src/ui/mountResonanceControl.ts new file mode 100644 index 00000000..adbd37cf --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountResonanceControl.ts @@ -0,0 +1,31 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { TIP_ANGLE_RANGE } from '../state'; +import type { MriScannerStore } from '../state'; +import { formatAcross, formatAlong, formatDegrees, formatT1, formatT2 } from './format'; +import { inChapter } from './actions'; + +type SelectedTip = readonly [tipAngle: number]; + +export function mountResonanceControl(root: Document, store: MriScannerStore): Disposer { + return disposeAll([ + mountRangeWidget(root, store, { + control: 'tip-angle', + range: TIP_ANGLE_RANGE, + select: (state): SelectedTip => [state.tipAngle], + value: ([tipAngle]) => tipAngle, + format: ([tipAngle]) => formatDegrees(tipAngle), + set: inChapter('resonance', (state, degrees: number) => state.setTipAngle(degrees)), + readouts: { + across: ([tipAngle]) => formatAcross(tipAngle), + along: ([tipAngle]) => formatAlong(tipAngle), + }, + }), + mountLiveReadouts(root, store, { + t1: (state) => formatT1(state.field, state.tissue), + t2: (state) => formatT2(state.field, state.tissue), + }), + ]); +} diff --git a/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts new file mode 100644 index 00000000..82d46015 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountSpinsReadouts.ts @@ -0,0 +1,16 @@ +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import type { MriScannerStore } from '../state'; +import { formatLarmorBand, formatSlowdown, formatSurplus, formatSurplusCount } from './format'; + +const SPINS_CHAPTER = 'section.chapter[data-preset="spins"]'; + +export function mountSpinsReadouts(root: Document, store: MriScannerStore): Disposer { + return mountLiveReadouts(requireElement(root, SPINS_CHAPTER), store, { + larmor: (state) => formatLarmorBand(state.field), + surplus: (state) => formatSurplus(state.field), + surplusCount: (state) => formatSurplusCount(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 new file mode 100644 index 00000000..f885d417 --- /dev/null +++ b/explainers/mri-scanner/src/ui/mountUi.dom.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import { mountActions } from '@core/ui/actions'; +import chapters from '../../chapters.html?raw'; +import en from '../../locales/en.json'; +import { AXIS_CHOICE_IDS, FIELD_IDS, LINE_CHOICE_IDS, TISSUE_IDS, WEIGHTING_IDS } from '../ids'; +import { MOMENTS } from '../model'; +import { createMriScannerStore } from '../state'; +import type { MriScannerStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; +import { fill, isFilled } from './testing'; +import { mountMriScannerUi } from '.'; + +const { units, chapters: copy } = en; + +function readout(id: string): string | null | undefined { + return document.querySelector(`[data-readout="${id}"]`)?.textContent; +} + +function click(action: string, value: string): void { + document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click(); +} + +function pressed(action: string, value: string): string | null | undefined { + return document + .querySelector(`[data-action="${action}"][data-value="${value}"]`) + ?.getAttribute('aria-pressed'); +} + +function tipInput(): HTMLInputElement { + const found = document.querySelector('[data-control="tip-angle"]'); + if (!found) throw new Error('No tip-angle control'); + return found; +} + +function chipValues(action: string): (string | null)[] { + return [...document.querySelectorAll(`.chip[data-action="${action}"]`)].map((node) => + node.getAttribute('data-value'), + ); +} + +describe('chapter widgets', () => { + let store: MriScannerStore; + let dispose: () => void; + + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + beforeEach(() => { + document.body.innerHTML = chapters; + store = createMriScannerStore({ playing: false, phase: 0 }); + dispose = mountMriScannerUi(document, store); + mountActions(document, store, CHAPTER_ACTIONS); + }); + + afterEach(() => dispose()); + + it('offers a chip for every option the chapters switch', () => { + expect(new Set(chipValues('field'))).toEqual(new Set(FIELD_IDS)); + expect(chipValues('weighting')).toEqual([...WEIGHTING_IDS]); + expect(chipValues('tissue')).toEqual([...TISSUE_IDS]); + expect(chipValues('gradientAxis')).toEqual([...AXIS_CHOICE_IDS]); + expect(chipValues('lines')).toEqual([...LINE_CHOICE_IDS]); + }); + + it('fills every readout with a finished text', () => { + const texts = [...document.querySelectorAll('[data-readout]')].map((node) => node.textContent); + expect(texts.length).toBeGreaterThan(0); + texts.forEach((text) => expect(isFilled(text), text ?? '').toBe(true)); + }); + + it('follows the field across the overview, the magnet and the spins', () => { + expect(readout('fieldNow')).toBe(fill(copy.overview.field, { tesla: '1.5 T' })); + click('field', 'field30'); + expect(pressed('field', 'field30')).toBe('true'); + expect(readout('fieldNow')).toBe(fill(copy.overview.field, { tesla: '3 T' })); + expect(readout('slowdown')).toBe(fill(copy.spins.slowdownValue, { factor: '255 million' })); + expect(readout('fieldShare')).toBe(fill(units.percent, { value: '0.18' })); + }); + + it('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}`; + dispose = mountMriScannerUi(document, store); + store.getState().setField('field30'); + const spinsLarmor = document.querySelector('[data-preset="spins"] [data-readout="larmor"]'); + expect(spinsLarmor?.textContent).toContain('127.7'); + expect(document.querySelector('.gauge [data-readout="larmor"]')?.textContent).toBe(''); + }); + + it('turns the tip angle into the across and along shares', () => { + expect(tipInput().max).toBe('180'); + tipInput().value = '180'; + tipInput().dispatchEvent(new Event('input')); + expect(store.getState().tipAngle).toBe(180); + expect(document.querySelector('output[for="tip-angle"]')?.textContent).toBe( + fill(units.degrees, { value: '180' }), + ); + expect(readout('along')).toBe(fill(units.percent, { value: '-100' })); + expect(readout('across')).toBe(fill(units.percent, { value: '0' })); + }); + + it('shows the relaxation times of the chosen tissue and pauses at a moment', () => { + click('tissue', 'fluid'); + expect(readout('t1')).toBe(fill(units.ms, { value: '4,300' })); + expect(readout('t2')).toBe(fill(units.ms, { value: '2,000' })); + click('moment', 'echoPeak'); + expect(store.getState().phase).toBe(MOMENTS.echoPeak); + expect(pressed('moment', 'echoPeak')).toBe('true'); + }); + + it('names the role of the held gradient coil', () => { + click('gradientAxis', 'z'); + expect(readout('axisRole')).toBe(copy.gradients.roleValue.z); + click('gradientAxis', 'sequence'); + expect(readout('axisRole')).toBe(copy.gradients.roleValue.sequence); + }); + + it('updates the picture readouts with the weighting and the lines', () => { + click('weighting', 't1'); + expect(readout('turbo')).toBe(fill(copy.picture.turboValue, { seconds: '8' })); + expect(readout('brightFat')).toBe(fill(units.percent, { value: '100' })); + click('lines', '16'); + expect(store.getState().linesFilled).toBe(16); + expect(pressed('lines', '16')).toBe('true'); + }); +}); diff --git a/explainers/mri-scanner/src/ui/readouts.test.ts b/explainers/mri-scanner/src/ui/readouts.test.ts new file mode 100644 index 00000000..7f683c72 --- /dev/null +++ b/explainers/mri-scanner/src/ui/readouts.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { createMriScannerStore } from '../state'; +import { MRI_SCANNER_READOUTS } from './readouts'; + +const HALF_CYCLE = 500; +const HALF_LINES = 32; + +function meterOf(id: string) { + const meter = MRI_SCANNER_READOUTS.find((row) => row.id === id)?.meter; + if (!meter) throw new Error(`No meter on ${id}`); + return meter; +} + +describe('gauge readouts', () => { + it('shows the spin frequency, the time in the repetition and the lines of k-space', () => { + expect(MRI_SCANNER_READOUTS.map((row) => row.id)).toEqual(['larmor', 'time', 'lines']); + MRI_SCANNER_READOUTS.forEach((row) => expect(row.labelKey).toBe(`readouts.${row.id}`)); + MRI_SCANNER_READOUTS.forEach((row) => expect(row.numeric).toBe(true)); + }); + + it('meters the share of the repetition and the share of the lines', () => { + const state = createMriScannerStore({ phase: HALF_CYCLE, linesFilled: HALF_LINES }).getState(); + expect(meterOf('time').share(state)).toBeCloseTo(0.5); + expect(meterOf('lines').share(state)).toBeCloseTo(0.5); + expect(MRI_SCANNER_READOUTS.find((row) => row.id === 'larmor')?.meter).toBeUndefined(); + }); +}); diff --git a/explainers/mri-scanner/src/ui/readouts.ts b/explainers/mri-scanner/src/ui/readouts.ts new file mode 100644 index 00000000..22d5539b --- /dev/null +++ b/explainers/mri-scanner/src/ui/readouts.ts @@ -0,0 +1,28 @@ +import type { Readout } from '@core/explainer'; +import { lineShare, timeGauge } from '../state'; +import type { MriScannerStoreState } from '../state'; +import { THEME } from '../theme'; +import { formatLarmor, formatLines, formatTimeGauge } from './format'; + +export const MRI_SCANNER_READOUTS: readonly Readout[] = [ + { + id: 'larmor', + labelKey: 'readouts.larmor', + numeric: true, + value: (state) => formatLarmor(state.field), + }, + { + id: 'time', + labelKey: 'readouts.time', + numeric: true, + value: (state) => formatTimeGauge(timeGauge(state.phase, state.weighting)), + meter: { share: (state) => timeGauge(state.phase, state.weighting).share, fill: THEME.rf }, + }, + { + id: 'lines', + labelKey: 'readouts.lines', + numeric: true, + value: (state) => formatLines(state.linesFilled), + meter: { share: (state) => lineShare(state.linesFilled), fill: THEME.echo }, + }, +]; diff --git a/explainers/mri-scanner/src/ui/testing.ts b/explainers/mri-scanner/src/ui/testing.ts new file mode 100644 index 00000000..b8e1cc66 --- /dev/null +++ b/explainers/mri-scanner/src/ui/testing.ts @@ -0,0 +1,9 @@ +export function fill(template: string, values: Readonly>): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => values[name] ?? ''); +} + +const PLACEHOLDER = /\{\{|\}\}/; + +export function isFilled(text: string | null | undefined): boolean { + return Boolean(text) && !PLACEHOLDER.test(text ?? ''); +} diff --git a/package-lock.json b/package-lock.json index d5823913..77859efc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "fft.js": "^4.0.4", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "three": "^0.186.1", @@ -1348,6 +1349,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fft.js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz", + "integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", diff --git a/package.json b/package.json index 13fcf739..86f32295 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@fontsource-variable/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", + "fft.js": "^4.0.4", "i18next": "^26.4.2", "i18next-browser-languagedetector": "^8.2.1", "three": "^0.186.1", diff --git a/scripts/cards/mri-scanner.html b/scripts/cards/mri-scanner.html new file mode 100644 index 00000000..4f3bbc9a --- /dev/null +++ b/scripts/cards/mri-scanner.html @@ -0,0 +1,163 @@ + + + + + How an MRI scanner works: social card + + + + + + +
+ +
+
+

+ + Medical imaging +

+

How an MRI scanner works

+

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

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

whatmakesittick.github.io/mri-scanner

+ + + diff --git a/vite.config.ts b/vite.config.ts index 13925dfa..8f7cf234 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -import { dependsOn } from './vite/chunks.ts'; +import { dependsOn, isExplainerOnlyPackage } from './vite/chunks.ts'; import type { ModuleGraph } from './vite/chunks.ts'; import { explainerPages } from './vite/explainerPages.ts'; import { siteCheck } from './vite/siteCheckPlugin.ts'; @@ -31,12 +31,18 @@ export default defineConfig({ groups: [ { name: 'three', test: THREE_MODULES }, { - name: (id, context) => (needsScene(id, context) ? null : 'shared'), + name: (id, context) => + isExplainerOnlyPackage(id, context) || needsScene(id, context) ? null : 'shared', debugName: 'shared', test: SHARED_MODULES, minShareCount: 2, }, - { name: 'scene', test: SHARED_MODULES, minShareCount: 2 }, + { + name: (id, context) => (isExplainerOnlyPackage(id, context) ? null : 'scene'), + debugName: 'scene', + test: SHARED_MODULES, + minShareCount: 2, + }, ], }, }, diff --git a/vite/chunks.test.ts b/vite/chunks.test.ts index af2e7c30..4cf9387c 100644 --- a/vite/chunks.test.ts +++ b/vite/chunks.test.ts @@ -1,12 +1,25 @@ import { describe, expect, it } from 'vitest'; -import { dependsOn } from './chunks.ts'; +import { dependsOn, isExplainerOnlyPackage } from './chunks.ts'; import type { ModuleGraph } from './chunks.ts'; const THREE = /node_modules\/three\//; -function graph(imports: Record): ModuleGraph { +type Imports = Record; + +function importersOf(id: string, imports: Imports): string[] { + return Object.keys(imports).filter((importer) => imports[importer].includes(id)); +} + +function graph(imports: Imports, dynamicImports: Imports = {}): ModuleGraph { return { - getModuleInfo: (id) => (id in imports ? { importedIds: imports[id] } : null), + getModuleInfo: (id) => + id in imports + ? { + importedIds: imports[id], + importers: importersOf(id, imports), + dynamicImporters: importersOf(id, dynamicImports), + } + : null, }; } @@ -34,3 +47,125 @@ describe('dependsOn', () => { expect(dependsOn('/src/site/main.ts', THREE, modules)).toBe(false); }); }); + +const FIXTURE_ROOT = '/'; + +const packages = graph( + { + '/src/core/mount.ts': ['/node_modules/i18next/index.js'], + '/explainers/mri-scanner/src/model/kspace.ts': ['/node_modules/fft.js/lib/fft.js'], + '/explainers/mri-scanner/src/model/image.ts': [ + '/node_modules/fft.js/lib/fft.js', + '/node_modules/wrapper/index.js', + ], + '/explainers/mri-scanner/src/scene/coil.ts': ['/explainers/mri-scanner/src/model/image.ts'], + '/explainers/naval-drone/src/model/hull.ts': ['/node_modules/both/index.js'], + '/explainers/sundial/src/model/sun.ts': [ + '/node_modules/both/index.js', + '/node_modules/i18next/index.js', + ], + '/explainers/sundial/src/scene/sky.ts': [], + '/node_modules/wrapper/index.js': ['/node_modules/inner/index.js'], + '/node_modules/inner/index.js': ['/node_modules/wrapper/index.js'], + '/node_modules/fft.js/lib/fft.js': [], + '/node_modules/both/index.js': [], + '/node_modules/i18next/index.js': [], + '/node_modules/lazy/index.js': [], + '/node_modules/lazy-core/index.js': [], + '/node_modules/orphan/index.js': [], + }, + { + '/explainers/sundial/src/scene/sky.ts': [ + '/node_modules/lazy/index.js', + '/node_modules/lazy-core/index.js', + ], + '/src/core/mount.ts': ['/node_modules/lazy-core/index.js'], + }, +); + +describe('isExplainerOnlyPackage', () => { + it('is true for a package that only one explainer imports, from any of its files', () => { + expect(isExplainerOnlyPackage('/node_modules/fft.js/lib/fft.js', packages, FIXTURE_ROOT)).toBe( + true, + ); + }); + + it('follows imports through other packages, cycles included', () => { + 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, 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, 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, FIXTURE_ROOT), + ).toBe(false); + expect(isExplainerOnlyPackage('/node_modules/orphan/index.js', packages, FIXTURE_ROOT)).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); + }); + + 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, + ); + }); +}); diff --git a/vite/chunks.ts b/vite/chunks.ts index d88be957..7b4e01d1 100644 --- a/vite/chunks.ts +++ b/vite/chunks.ts @@ -1,7 +1,19 @@ +import { fileURLToPath } from 'node:url'; + +export interface ModuleInfo { + importedIds: readonly string[]; + importers: readonly string[]; + dynamicImporters: readonly string[]; +} + export interface ModuleGraph { - getModuleInfo(id: string): { importedIds: readonly string[] } | null; + getModuleInfo(id: string): ModuleInfo | null; } +const PACKAGE_MODULE = /[\\/]node_modules[\\/]/; +const EXPLAINER_MODULE = /^explainers\/([^/]+)\//; +const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url)); + export function dependsOn(id: string, target: RegExp, graph: ModuleGraph): boolean { const visited = new Set([id]); const pending = [id]; @@ -15,3 +27,45 @@ export function dependsOn(id: string, target: RegExp, graph: ModuleGraph): boole } return false; } + +function importersOf(id: string, graph: ModuleGraph): readonly string[] { + const info = graph.getModuleInfo(id); + return info === null ? [] : [...info.importers, ...info.dynamicImporters]; +} + +function projectImporters(id: string, graph: ModuleGraph): Set { + const importers = new Set(); + const visited = new Set([id]); + const pending = [id]; + for (let current = pending.pop(); current !== undefined; current = pending.pop()) { + for (const importer of importersOf(current, graph)) { + if (visited.has(importer)) continue; + visited.add(importer); + if (PACKAGE_MODULE.test(importer)) pending.push(importer); + else importers.add(importer); + } + } + return importers; +} + +function 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, + root: string = PROJECT_ROOT, +): boolean { + if (!PACKAGE_MODULE.test(id)) return false; + const importers = [...projectImporters(id, graph)]; + const owners = new Set(importers.map((importer) => explainerSlug(importer, root))); + return owners.size === 1 && !owners.has(undefined); +}