Spaces:
Running
Running
Download index.html from AK51/Vortex_Ring_Collusion: direct link, hf CLI and curl.
- Browser
- Download file 42.8 kB
-
https://huggingface.co/spaces/AK51/Vortex_Ring_Collusion/resolve/main/index.html
- Command line
-
hf download hf://spaces/AK51/Vortex_Ring_Collusion/index.html
-
curl -L -o index.html https://huggingface.co/spaces/AK51/Vortex_Ring_Collusion/resolve/main/index.html
42.8 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>Collision of Two Vortex Rings β Particle Fluid</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{background:#e9ecf2;overflow:hidden;font-family:'Segoe UI',system-ui,sans-serif} | |
| canvas{display:block} | |
| #ui{position:fixed;top:14px;left:16px;color:#2a3550;pointer-events:none;user-select:none} | |
| #ui h1{font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#1a2440} | |
| #ui .sub{font-size:10px;color:#7a86a0;margin:2px 0 12px} | |
| #ui .stat{font-size:11px;color:#55617d;line-height:2} | |
| #ui .stat span{color:#2a3550;font-weight:600} | |
| #re{position:fixed;top:14px;right:22px;font-size:30px;font-weight:300;letter-spacing:.12em;color:#444d66;pointer-events:none;user-select:none;font-family:'Consolas',monospace} | |
| #legend{position:fixed;top:60px;right:22px;font-size:11px;color:#55617d;pointer-events:none} | |
| .lr{display:flex;align-items:center;gap:7px;margin-bottom:6px;justify-content:flex-end} | |
| .dot{width:10px;height:10px;border-radius:50%;flex-shrink:0} | |
| .r{background:#c0392b} | |
| .b{background:#2255cc} | |
| #bar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:7px} | |
| #bar button{background:rgba(255,255,255,.7);border:1px solid rgba(40,55,90,.25);color:#2a3550;padding:7px 15px;border-radius:6px;font-size:11px;cursor:pointer;letter-spacing:.04em;transition:background .16s;backdrop-filter:blur(4px)} | |
| #bar button:hover{background:rgba(255,255,255,.95)} | |
| #panel{ | |
| position:fixed;top:50%;right:18px;transform:translateY(-50%); | |
| width:290px;max-height:88vh;overflow-y:auto; | |
| background:rgba(255,255,255,.92);border:1px solid rgba(40,55,90,.2); | |
| border-radius:10px;box-shadow:0 8px 30px rgba(30,45,80,.18); | |
| backdrop-filter:blur(8px);color:#2a3550; | |
| display:none;z-index:20; | |
| } | |
| #panel.open{display:block} | |
| .ph{ | |
| display:flex;align-items:center;justify-content:space-between; | |
| padding:12px 14px;border-bottom:1px solid rgba(40,55,90,.12); | |
| font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase; | |
| } | |
| .ph button{background:none;border:none;color:#7a86a0;font-size:14px;cursor:pointer;padding:2px 4px} | |
| .ph button:hover{color:#2a3550} | |
| .pbody{padding:12px 14px} | |
| .pbody label{ | |
| display:flex;justify-content:space-between;align-items:baseline; | |
| font-size:11px;color:#55617d;margin:12px 0 4px;font-weight:600; | |
| } | |
| .pbody label:first-child{margin-top:0} | |
| .pbody output{color:#2255cc;font-family:'Consolas',monospace;font-size:11px} | |
| .pbody input[type=range]{ | |
| width:100%;height:4px;border-radius:3px;background:#c6cfdf; | |
| -webkit-appearance:none;appearance:none;outline:none;cursor:pointer; | |
| } | |
| .pbody input[type=range]::-webkit-slider-thumb{ | |
| -webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%; | |
| background:#2255cc;cursor:pointer;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.3); | |
| } | |
| .pbody input[type=range]::-moz-range-thumb{ | |
| width:14px;height:14px;border-radius:50%;background:#2255cc;cursor:pointer;border:2px solid #fff; | |
| } | |
| .prow{display:flex;gap:8px;margin-top:18px} | |
| .prow button{ | |
| flex:1;background:#2255cc;border:none;color:#fff;padding:9px;border-radius:6px; | |
| font-size:11px;cursor:pointer;letter-spacing:.04em;transition:background .16s; | |
| } | |
| .prow button#pReset{background:rgba(40,55,90,.12);color:#2a3550} | |
| .prow button:hover{filter:brightness(1.08)} | |
| .pnote{font-size:10px;color:#8894ac;line-height:1.5;margin-top:12px} | |
| .pgroup{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#2255cc;margin:4px 0 2px;padding-top:8px;border-top:1px solid rgba(40,55,90,.1)} | |
| .pgroup:first-child{border-top:none;padding-top:0} | |
| .psel{width:100%;padding:7px 8px;border-radius:6px;border:1px solid rgba(40,55,90,.25);background:#fff;color:#2a3550;font-size:11px;cursor:pointer;margin-top:2px} | |
| .pprops{font-size:10px;color:#55617d;line-height:1.6;margin-top:10px;background:rgba(40,55,90,.05);border-radius:6px;padding:8px 10px} | |
| .pprops b{color:#2a3550} | |
| .pgroup small{font-weight:600;text-transform:none;letter-spacing:0;color:#8894ac;font-size:9px;float:right} | |
| .pcols{display:flex;gap:10px} | |
| .pcols>*{flex:1;min-width:0} | |
| .pcol label{margin-top:0} | |
| #about{ | |
| position:fixed;top:50%;left:50%;transform:translate(-50%,-50%); | |
| width:min(560px,92vw);max-height:86vh;overflow-y:auto; | |
| background:rgba(255,255,255,.97);border:1px solid rgba(40,55,90,.2); | |
| border-radius:12px;box-shadow:0 12px 50px rgba(30,45,80,.3); | |
| backdrop-filter:blur(10px);color:#2a3550;display:none;z-index:30; | |
| } | |
| #about.open{display:block} | |
| .abody{padding:16px 22px 22px} | |
| .abody p{font-size:12px;line-height:1.65;color:#44506a;margin:4px 0 6px} | |
| .agroup{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#2255cc;margin:18px 0 6px;padding-top:12px;border-top:1px solid rgba(40,55,90,.12)} | |
| .agroup:first-child{margin-top:0;padding-top:0;border-top:none} | |
| .aparams{margin:4px 0} | |
| .aparams dt{font-size:12px;font-weight:600;color:#1a2440;margin-top:10px} | |
| .aparams dd{font-size:11.5px;line-height:1.55;color:#55617d;margin:2px 0 0 0} | |
| .arefs{margin:4px 0 0 18px;padding:0} | |
| .arefs li{font-size:11.5px;line-height:1.7;color:#55617d} | |
| .arefs a{color:#2255cc;text-decoration:none} | |
| .arefs a:hover{text-decoration:underline} | |
| .acredit{margin-top:22px;padding-top:14px;border-top:1px solid rgba(40,55,90,.12);text-align:center;font-size:13px;font-weight:600;color:#2a3550;letter-spacing:.04em} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="ui"> | |
| <h1>Rocket Propellant Vortex-Ring Mixing</h1> | |
| <div class="sub">Three.js Β· Dye-Particle Fluid Β· colliding oxidizer / fuel streams</div> | |
| <div class="stat"> | |
| Dye particles <span id="sN">β</span><br> | |
| Time <span id="sT">0.00</span><br> | |
| Phase <span id="sP">Approach</span> | |
| </div> | |
| </div> | |
| <div id="re">Re = 1750</div> | |
| <div id="legend"> | |
| <div class="lr"><span id="legA">RP-1 Kerosene</span> <div class="dot" id="dotA"></div></div> | |
| <div class="lr"><span id="legB">Liquid Oxygen</span> <div class="dot" id="dotB"></div></div> | |
| <div class="lr" style="margin-top:4px;font-size:10px;color:#8894ac" id="legMix">O/F mixing</div> | |
| </div> | |
| <div id="bar"> | |
| <button id="bR">βΊ Restart</button> | |
| <button id="bP">βΈ Pause</button> | |
| <button id="bD">β³ View</button> | |
| <button id="bT">β Front</button> | |
| <button id="bG">β Parameters</button> | |
| <button id="bA">β About</button> | |
| </div> | |
| <div id="panel"> | |
| <div class="ph"> | |
| <span>Simulation Parameters</span> | |
| <button id="pClose">β</button> | |
| </div> | |
| <div class="pbody"> | |
| <div class="pgroup">Propellants</div> | |
| <div class="pcols"> | |
| <div class="pcol"> | |
| <label>Left Stream</label> | |
| <select id="pFuelA" class="psel"></select> | |
| </div> | |
| <div class="pcol"> | |
| <label>Right Stream</label> | |
| <select id="pFuelB" class="psel"></select> | |
| </div> | |
| </div> | |
| <div class="pprops" id="propInfo"></div> | |
| <div class="pgroup">Per-stream properties <small>Left / Right</small></div> | |
| <label>Jet strength (circulation Ξ)<span><output id="oGammaL">22.0</output> / <output id="oGammaR">22.0</output></span></label> | |
| <div class="pcols"> | |
| <input type="range" id="pGammaL" min="6" max="40" step="0.5" value="22"> | |
| <input type="range" id="pGammaR" min="6" max="40" step="0.5" value="22"> | |
| </div> | |
| <label>Ring radius R<span><output id="oRL">2.6</output> / <output id="oRR">2.6</output></span></label> | |
| <div class="pcols"> | |
| <input type="range" id="pRL" min="1.2" max="4.0" step="0.1" value="2.6"> | |
| <input type="range" id="pRR" min="1.2" max="4.0" step="0.1" value="2.6"> | |
| </div> | |
| <label>Launch distance<span><output id="oSepL">9.0</output> / <output id="oSepR">9.0</output></span></label> | |
| <div class="pcols"> | |
| <input type="range" id="pSepL" min="5" max="16" step="0.5" value="9"> | |
| <input type="range" id="pSepR" min="5" max="16" step="0.5" value="9"> | |
| </div> | |
| <label>Vertical offset<span><output id="oYoffL">0.1</output> / <output id="oYoffR">0.1</output></span></label> | |
| <div class="pcols"> | |
| <input type="range" id="pYoffL" min="0" max="5" step="0.1" value="0.1"> | |
| <input type="range" id="pYoffR" min="0" max="5" step="0.1" value="0.1"> | |
| </div> | |
| <label>Core thickness Ξ΅<span><output id="oEpsL">0.62</output> / <output id="oEpsR">0.62</output></span></label> | |
| <div class="pcols"> | |
| <input type="range" id="pEpsL" min="0.3" max="1.2" step="0.02" value="0.62"> | |
| <input type="range" id="pEpsR" min="0.3" max="1.2" step="0.02" value="0.62"> | |
| </div> | |
| <label>Instability lobes (wavenumber)<span><output id="oLobeL">8</output> / <output id="oLobeR">8</output></span></label> | |
| <div class="pcols"> | |
| <input type="range" id="pLobeL" min="0" max="16" step="1" value="8"> | |
| <input type="range" id="pLobeR" min="0" max="16" step="1" value="8"> | |
| </div> | |
| <div class="pgroup">System & numerics</div> | |
| <label>Reynolds number Re<output id="oRe">1750</output></label> | |
| <input type="range" id="pRe" min="100" max="5000" step="50" value="1750"> | |
| <label>Viscosity (dye diffusion)<output id="oDiff">0.0016</output></label> | |
| <input type="range" id="pDiff" min="0" max="0.02" step="0.0005" value="0.0016"> | |
| <label>Dye particles<output id="oN">48,000</output></label> | |
| <input type="range" id="pN" min="8000" max="90000" step="2000" value="48000"> | |
| <label>Particle size<output id="oSize">0.5</output></label> | |
| <input type="range" id="pSize" min="0.1" max="8" step="0.1" value="0.5"> | |
| <label>Playback speed (dt)<output id="oDt">0.022</output></label> | |
| <input type="range" id="pDt" min="0.006" max="0.04" step="0.002" value="0.022"> | |
| <div class="prow"> | |
| <button id="pApply">Apply & Restart</button> | |
| <button id="pReset">Defaults</button> | |
| </div> | |
| <div class="pnote">Per-stream sliders set the Left / Right propellant jets independently. Re maps to dye diffusion: higher Re β sharper filaments; lower Re β more blurred dye.</div> | |
| </div> | |
| </div> | |
| <div id="about"> | |
| <div class="ph"> | |
| <span>About this Simulation</span> | |
| <button id="aClose">β</button> | |
| </div> | |
| <div class="abody"> | |
| <div class="agroup">Objective</div> | |
| <p>This application visualises the head-on collision of two vortex rings as a | |
| model of rocket-propellant stream mixing. Two coaxial rings β one per | |
| propellant β are fired toward each other. As they meet, they interpenetrate, | |
| the dye intermixes, and the merged structure blooms outward into a larger, | |
| lobed ring. It reproduces the qualitative behaviour of the classic Lim & | |
| Nickels (1992) experiment and the Basilisk reference simulation, letting you | |
| explore how propellant choice and flow parameters shape the mixing.</p> | |
| <div class="agroup">Method</div> | |
| <p>Each ring is a loop of discrete vortex elements. Both the dye particles and | |
| the vortex elements move in the summed BiotβSavart velocity field of every | |
| element, so self-propulsion, collision and radial expansion all emerge from | |
| the physics rather than being scripted. This is a lightweight qualitative | |
| model, not a full NavierβStokes combustion solver.</p> | |
| <div class="agroup">Parameters</div> | |
| <p>Parameters that describe a propellant jet are set <b>independently per | |
| stream</b> (Left / Right). Parameters that describe the shared domain or the | |
| numerical scheme are <b>system-wide</b>.</p> | |
| <div class="agroup" style="color:#2a3550;font-size:10px">Per-stream (Left / Right)</div> | |
| <dl class="aparams"> | |
| <dt>Left Stream / Right Stream</dt> | |
| <dd>The propellant assigned to each ring. Sets its dye colour, density | |
| (stream momentum) and viscosity (dye diffusion). The note flags whether the | |
| pair is a real oxidizer + fuel combination.</dd> | |
| <dt>Jet strength (circulation Ξ)</dt> | |
| <dd>How hard that ring is fired β analogous to the injection velocity of | |
| the propellant jet. Controls that stream's approach and collision speed.</dd> | |
| <dt>Ring radius R</dt> | |
| <dd>The major radius of that ring at launch (its nozzle diameter).</dd> | |
| <dt>Launch distance</dt> | |
| <dd>Starting distance of that ring's centre from the origin along the | |
| collision axis. The two streams can start at different distances.</dd> | |
| <dt>Vertical offset</dt> | |
| <dd>Raises the left ring / lowers the right ring by this amount. Unequal | |
| offsets produce an oblique, off-centre collision instead of a head-on one.</dd> | |
| <dt>Core thickness Ξ΅</dt> | |
| <dd>The BiotβSavart smoothing length of that ring β effectively its vortex | |
| core size. Larger values give a thicker, gentler core.</dd> | |
| <dt>Instability lobes (wavenumber)</dt> | |
| <dd>Azimuthal perturbation seeding the scalloped "flower" lobes on that | |
| ring. 0 = perfectly axisymmetric.</dd> | |
| </dl> | |
| <div class="agroup" style="color:#2a3550;font-size:10px">System-wide</div> | |
| <dl class="aparams"> | |
| <dt>Reynolds number (Re)</dt> | |
| <dd>Ratio of inertial to viscous forces for the whole scene. Drives the dye | |
| diffusion rate: high Re β thin, crisp filaments; low Re β blurred dye. | |
| Linked to the viscosity slider.</dd> | |
| <dt>Viscosity (dye diffusion)</dt> | |
| <dd>The base random-walk coefficient applied to the dye. Inversely linked to | |
| Re; combined with each propellant's own viscosity.</dd> | |
| <dt>Dye particles</dt> | |
| <dd>Total number of dye particles (half per ring). More particles give a | |
| denser cloud at a higher compute cost.</dd> | |
| <dt>Particle size</dt> | |
| <dd>On-screen size of each dye point. Updates live without a restart.</dd> | |
| <dt>Playback speed (dt)</dt> | |
| <dd>Simulation time step per frame β slows down or speeds up the animation.</dd> | |
| </dl> | |
| <div class="agroup">References</div> | |
| <ul class="arefs"> | |
| <li><a href="https://basilisk.fr/sandbox/Antoonvh/two_rings.c" target="_blank" rel="noopener">Basilisk β Collision of two vortex rings (Antoon van Hooft)</a></li> | |
| <li><a href="https://github.com/dgreenheck/threejs-particle-fluids" target="_blank" rel="noopener">dgreenheck / threejs-particle-fluids</a></li> | |
| <li>Lim, T.T. & Nickels, T.B. (1992). Instability and reconnection in the head-on collision of two vortex rings. <i>Nature</i>, 357, 225β227.</li> | |
| <li><a href="https://threejs.org/" target="_blank" rel="noopener">Three.js</a></li> | |
| </ul> | |
| <div class="acredit">Created by Andy Kong</div> | |
| </div> | |
| </div> | |
| <script type="importmap"> | |
| {"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.165.0/examples/jsm/"}} | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // RENDERER / SCENE / CAMERA β light studio look like the reference | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| renderer.setSize(innerWidth, innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xe9ecf2); | |
| scene.fog = new THREE.Fog(0xe9ecf2, 70, 150); | |
| const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 1000); | |
| // isometric-ish 3/4 view, matching the reference video angle | |
| camera.position.set(20, 11, 24); | |
| camera.lookAt(0, 0, 0); | |
| const ctrl = new OrbitControls(camera, renderer.domElement); | |
| ctrl.enableDamping = true; | |
| ctrl.dampingFactor = 0.06; | |
| ctrl.target.set(0, 0, 0); | |
| ctrl.minDistance = 6; | |
| ctrl.maxDistance = 90; | |
| addEventListener('resize', () => { | |
| camera.aspect = innerWidth / innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| }); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // GROUND GRID (depth reference, like the Basilisk cells plane) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const grid = new THREE.GridHelper(60, 30, 0x8894ac, 0xc2cad8); | |
| grid.position.y = -11; | |
| grid.material.opacity = 0.55; | |
| grid.material.transparent = true; | |
| scene.add(grid); | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ROCKET PROPELLANTS | |
| // Each stream (ring) is a propellant. Properties drive the look & physics: | |
| // color : dye tint [r,g,b] | |
| // density : relative liquid density (kg/mΒ³ scale) β stream momentum | |
| // viscosity : relative dynamic viscosity β dye diffusion rate | |
| // Densities are the usual liquid/cryogenic values used in rocketry. | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const PROPELLANTS = { | |
| LOX: { name: 'Liquid Oxygen (LOX)', color: [0.45, 0.72, 1.00], density: 1141, viscosity: 0.20, kind: 'oxidizer' }, | |
| LH2: { name: 'Liquid Hydrogen (LHβ)',color: [0.85, 0.90, 1.00], density: 71, viscosity: 0.014, kind: 'fuel' }, | |
| CH4: { name: 'Liquid Methane (CHβ)', color: [0.30, 0.85, 0.70], density: 423, viscosity: 0.12, kind: 'fuel' }, | |
| RP1: { name: 'RP-1 Kerosene', color: [0.95, 0.65, 0.25], density: 810, viscosity: 1.60, kind: 'fuel' }, | |
| N2O4: { name: 'Nitrogen Tetroxide', color: [0.80, 0.45, 0.20], density: 1443, viscosity: 0.42, kind: 'oxidizer' }, | |
| UDMH: { name: 'UDMH Hydrazine', color: [0.95, 0.85, 0.55], density: 791, viscosity: 0.55, kind: 'fuel' }, | |
| N2: { name: 'Liquid Nitrogen (LNβ)',color: [0.70, 0.80, 0.90], density: 807, viscosity: 0.16, kind: 'inert' }, | |
| }; | |
| // reference density/viscosity used to normalise the physics scaling | |
| const RHO_REF = 810; // ~RP-1 | |
| const MU_REF = 1.0; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CONFIG | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const C = { | |
| // ---- PER-STREAM properties (L = left/+x-bound ring, R = right/βx-bound ring) ---- | |
| fuelL: 'RP1', fuelR: 'LOX', // propellant of each stream | |
| GammaL: 22.0, GammaR: 22.0, // jet strength (circulation) of each stream | |
| radL: 2.6, radR: 2.6, // ring radius of each stream | |
| yOffL: 0.1, yOffR: 0.1, // vertical rise (L up) / drop (R down) | |
| sepL: 9.0, sepR: 9.0, // launch distance of each ring from the origin | |
| epsL: 0.62, epsR: 0.62, // core thickness of each stream | |
| lobeL: 8, lobeR: 8, // azimuthal instability lobes of each stream | |
| // ---- SYSTEM-WIDE properties ---- | |
| Ndye: 48_000, // dye particles total (half per ring) | |
| core: 0.42, // dye tube radius (seeding spread) | |
| Nvort: 46, // vortex elements per ring (azimuthal discretisation) | |
| dt: 0.022, // time step | |
| wobbleAmp: 0.045, // perturbation amplitude seeding the lobe instability | |
| diffuse: 0.0016, // base dye random-walk coefficient (scaled by Re & viscosity) | |
| }; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // VORTEX ELEMENTS | |
| // Each ring = Nvort vortex "blobs" on a circle. Each carries a | |
| // tangential vorticity vector (direction = ring tangent). The induced | |
| // velocity of a vortex blob at point p: | |
| // u = (Ξ/4Ο) * ( Ο Γ r ) / (|r|Β²+Ρ²)^{3/2} | |
| // Summing over all blobs of BOTH rings gives self-propulsion, | |
| // mutual interaction, collision and radial bloom automatically. | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let NV = C.Nvort * 2; // total vortex elements | |
| let vP = new Float32Array(NV * 3); // positions | |
| let vW = new Float32Array(NV * 3); // vorticity vectors (Ξ * tangent * dl) | |
| let vE2 = new Float32Array(NV); // per-element core smoothing Ρ² (per stream) | |
| // Build one ring of vortex elements. | |
| // baseIdx : element offset into the shared arrays | |
| // cx : centre x | |
| // dir : +1 ring tangent sense (sets propagation direction) | |
| // yOff : vertical shift of the whole ring | |
| // gammaScale : per-ring circulation multiplier (heavier propellant β more momentum) | |
| // Gamma : per-stream jet strength | |
| // R : per-stream ring radius | |
| // eps : per-stream core thickness | |
| // lobeN : per-stream instability wavenumber | |
| function seedRing(baseIdx, cx, dir, yOff, gammaScale, Gamma, R, eps, lobeN) { | |
| const e2 = eps * eps; | |
| for (let i = 0; i < C.Nvort; i++) { | |
| const th = (2 * Math.PI * i) / C.Nvort; | |
| // small azimuthal radius wobble seeds the flower instability | |
| const rr = R * (1 + C.wobbleAmp * Math.cos(lobeN * th)); | |
| const idx = (baseIdx + i) * 3; | |
| vE2[baseIdx + i] = e2; | |
| // ring lies in y-z plane, centred on x-axis | |
| const y = rr * Math.cos(th) + yOff; | |
| const z = rr * Math.sin(th); | |
| vP[idx] = cx; | |
| vP[idx+1] = y; | |
| vP[idx+2] = z; | |
| // tangent of the circle (y-z plane): d/dΞΈ (cosΞΈ, sinΞΈ) = (-sinΞΈ, cosΞΈ) | |
| const tx = 0; | |
| const ty = -Math.sin(th); | |
| const tz = Math.cos(th); | |
| // vorticity magnitude Ξ * segment length, scaled by propellant momentum | |
| const dl = (2 * Math.PI * R) / C.Nvort; | |
| const g = Gamma * gammaScale * dl * dir; | |
| vW[idx] = tx * g; | |
| vW[idx+1] = ty * g; | |
| vW[idx+2] = tz * g; | |
| } | |
| } | |
| // momentum scaling from density: heavier liquid carries more momentum, so its | |
| // ring propagates faster. We use sqrt(Ο/Ο_ref) and clamp to a sane visual range. | |
| function gammaScaleFor(key) { | |
| const p = PROPELLANTS[key]; | |
| return Math.min(1.8, Math.max(0.45, Math.sqrt(p.density / RHO_REF))); | |
| } | |
| // per-ring diffusion from viscosity (more viscous β more diffuse dye spread here) | |
| function diffuseFor(key) { | |
| const p = PROPELLANTS[key]; | |
| return C.diffuse * Math.min(3.0, Math.max(0.3, Math.sqrt(p.viscosity / MU_REF) + 0.4)); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // INDUCED VELOCITY from all vortex elements at point (px,py,pz) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const _u = new Float64Array(3); | |
| const INV4PI = 1 / (4 * Math.PI); | |
| function induced(px, py, pz) { | |
| let ux = 0, uy = 0, uz = 0; | |
| for (let j = 0; j < NV; j++) { | |
| const j3 = j * 3; | |
| const rx = px - vP[j3]; | |
| const ry = py - vP[j3+1]; | |
| const rz = pz - vP[j3+2]; | |
| const r2 = rx*rx + ry*ry + rz*rz + vE2[j]; | |
| const inv = 1 / (r2 * Math.sqrt(r2)); | |
| const wx = vW[j3], wy = vW[j3+1], wz = vW[j3+2]; | |
| // Ο Γ r | |
| ux += (wy*rz - wz*ry) * inv; | |
| uy += (wz*rx - wx*rz) * inv; | |
| uz += (wx*ry - wy*rx) * inv; | |
| } | |
| _u[0] = ux * INV4PI; | |
| _u[1] = uy * INV4PI; | |
| _u[2] = uz * INV4PI; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // DYE PARTICLES | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let N = C.Ndye; | |
| let pos = new Float32Array(N * 3); | |
| let col = new Float32Array(N * 3); | |
| function seedDye() { | |
| const half = N >> 1; | |
| const a = C.core; | |
| const colL = PROPELLANTS[C.fuelL].color; | |
| const colR = PROPELLANTS[C.fuelR].color; | |
| for (let i = 0; i < half; i++) { | |
| // ---- Left ring, fired from -x toward +x, shifted up ---- | |
| seedDyeParticle(i * 3, -C.sepL, C.radL, a, true, +C.yOffL, colL, C.lobeL); | |
| // ---- Right ring, fired from +x toward -x, shifted down ---- | |
| seedDyeParticle((half + i) * 3, C.sepR, C.radR, a, false, -C.yOffR, colR, C.lobeR); | |
| } | |
| } | |
| function seedDyeParticle(idx, cx, R, a, forward, yOff, baseCol, lobeN) { | |
| const th = Math.random() * 2 * Math.PI; | |
| const phi = Math.random() * 2 * Math.PI; | |
| // concentrate dye near the core but with a trailing tail toward launch side | |
| const rr = a * Math.pow(Math.random(), 0.5); | |
| const rEff = R * (1 + C.wobbleAmp * Math.cos(lobeN * th)) + rr * Math.cos(phi); | |
| const y = rEff * Math.cos(th) + yOff; | |
| const z = rEff * Math.sin(th); | |
| // give a short launch tail: 20% of particles trail behind the ring | |
| const tail = Math.random() < 0.22 ? (Math.random() * 2.4) : 0; | |
| const x = cx + rr * Math.sin(phi) + (forward ? -tail : tail); | |
| pos[idx] = x; | |
| pos[idx+1] = y; | |
| pos[idx+2] = z; | |
| // propellant tint with a small per-particle brightness jitter | |
| const j = 0.88 + 0.12 * Math.random(); | |
| col[idx] = Math.min(1, baseCol[0] * j); | |
| col[idx+1] = Math.min(1, baseCol[1] * j); | |
| col[idx+2] = Math.min(1, baseCol[2] * j); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // THREE.JS POINTS (dark dye on light background β normal blending) | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let geo = new THREE.BufferGeometry(); | |
| let bPos = new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage); | |
| let bCol = new THREE.BufferAttribute(col, 3); | |
| geo.setAttribute('position', bPos); | |
| geo.setAttribute('dyeColor', bCol); | |
| // NOTE: we feed a custom "dyeColor" attribute (NOT the built-in "color"), | |
| // and we do NOT set vertexColors β otherwise three.js injects its own | |
| // `attribute vec3 color;` which collides with ours and the shader fails | |
| // to compile, drawing nothing. | |
| const PR = renderer.getPixelRatio(); | |
| const mat = new THREE.ShaderMaterial({ | |
| uniforms: { uSize: { value: 2.4 * PR } }, | |
| vertexShader: ` | |
| attribute vec3 dyeColor; | |
| varying vec3 vCol; | |
| uniform float uSize; | |
| void main() { | |
| vCol = dyeColor; | |
| vec4 mv = modelViewMatrix * vec4(position, 1.0); | |
| gl_PointSize = uSize * (170.0 / -mv.z); | |
| gl_Position = projectionMatrix * mv; | |
| } | |
| `, | |
| fragmentShader: ` | |
| varying vec3 vCol; | |
| void main() { | |
| float d = length(gl_PointCoord - 0.5); | |
| if (d > 0.5) discard; | |
| float a = 0.9 * (1.0 - smoothstep(0.30, 0.5, d)); | |
| gl_FragColor = vec4(vCol, a); | |
| } | |
| `, | |
| transparent: true, | |
| depthWrite: false, | |
| blending: THREE.NormalBlending, // dark dye reads on light background | |
| }); | |
| let pts = new THREE.Points(geo, mat); | |
| pts.frustumCulled = false; // expanding cloud must never be auto-culled | |
| scene.add(pts); | |
| // Rebuild all size-dependent buffers (called when particle/vortex counts change) | |
| function rebuildBuffers() { | |
| NV = C.Nvort * 2; | |
| N = C.Ndye; | |
| vP = new Float32Array(NV * 3); | |
| vW = new Float32Array(NV * 3); | |
| vE2 = new Float32Array(NV); | |
| vTmp = new Float32Array(NV * 3); | |
| pos = new Float32Array(N * 3); | |
| col = new Float32Array(N * 3); | |
| scene.remove(pts); | |
| geo.dispose(); | |
| geo = new THREE.BufferGeometry(); | |
| bPos = new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage); | |
| bCol = new THREE.BufferAttribute(col, 3); | |
| geo.setAttribute('position', bPos); | |
| geo.setAttribute('dyeColor', bCol); | |
| pts = new THREE.Points(geo, mat); | |
| pts.frustumCulled = false; | |
| scene.add(pts); | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // INTEGRATION | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let simTime = 0; | |
| let paused = false; | |
| // BoxβMuller for diffusion | |
| let _bm = null; | |
| function randn() { | |
| if (_bm !== null) { const v = _bm; _bm = null; return v; } | |
| let u, v, s; | |
| do { u = Math.random()*2-1; v = Math.random()*2-1; s = u*u+v*v; } while (s >= 1); | |
| const f = Math.sqrt(-2*Math.log(s)/s); | |
| _bm = v*f; return u*f; | |
| } | |
| // advance vortex elements (they advect in each other's field) | |
| let vTmp = new Float32Array(NV * 3); | |
| function stepVortex(dt) { | |
| for (let j = 0; j < NV; j++) { | |
| const j3 = j*3; | |
| induced(vP[j3], vP[j3+1], vP[j3+2]); | |
| vTmp[j3] = vP[j3] + _u[0]*dt; | |
| vTmp[j3+1] = vP[j3+1] + _u[1]*dt; | |
| vTmp[j3+2] = vP[j3+2] + _u[2]*dt; | |
| } | |
| vP.set(vTmp); | |
| } | |
| // advance dye particles in the summed vortex field. | |
| // First half = ring A (fuelA), second half = ring B (fuelB); each uses its | |
| // own propellant diffusion so a viscous stream smears more than a thin one. | |
| function stepDye(dt) { | |
| const half = N >> 1; | |
| const dsqA = Math.sqrt(2 * diffuseFor(C.fuelL) * dt); | |
| const dsqB = Math.sqrt(2 * diffuseFor(C.fuelR) * dt); | |
| for (let i = 0; i < N; i++) { | |
| const i3 = i*3; | |
| const dsq = (i < half) ? dsqA : dsqB; | |
| induced(pos[i3], pos[i3+1], pos[i3+2]); | |
| pos[i3] += _u[0]*dt + dsq*randn(); | |
| pos[i3+1] += _u[1]*dt + dsq*randn(); | |
| pos[i3+2] += _u[2]*dt + dsq*randn(); | |
| } | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // PHASE readout | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const sP = document.getElementById('sP'); | |
| const sT = document.getElementById('sT'); | |
| function ringCentreX(base) { | |
| let s = 0; | |
| for (let i = 0; i < C.Nvort; i++) s += vP[(base+i)*3]; | |
| return s / C.Nvort; | |
| } | |
| // mean radial extent of ring A's vortex elements about the ring's OWN centroid | |
| // (centroid-relative so a vertical offset doesn't inflate the baseline radius) | |
| function ringRadius(base) { | |
| let cy = 0, cz = 0; | |
| for (let i = 0; i < C.Nvort; i++) { | |
| cy += vP[(base+i)*3+1]; | |
| cz += vP[(base+i)*3+2]; | |
| } | |
| cy /= C.Nvort; cz /= C.Nvort; | |
| let s = 0; | |
| for (let i = 0; i < C.Nvort; i++) { | |
| const y = vP[(base+i)*3+1] - cy, z = vP[(base+i)*3+2] - cz; | |
| s += Math.sqrt(y*y + z*z); | |
| } | |
| return s / C.Nvort; | |
| } | |
| function updatePhase() { | |
| const gap = ringCentreX(C.Nvort) - ringCentreX(0); // B - A | |
| const Ravg = 0.5 * (C.radL + C.radR); | |
| let p; | |
| if (gap > Ravg * 2.6) p = 'Approach'; | |
| else if (gap > Ravg * 0.5) p = 'Collision'; | |
| else if (gap > -Ravg*0.5) p = 'Reconnection'; | |
| else p = 'Radial bloom'; | |
| sP.textContent = p; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // RESET | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function reset() { | |
| simTime = 0; | |
| // Left ring: fired +x, raised; Right ring: fired βx, lowered. Each uses its | |
| // own propellant momentum, jet strength, radius, core thickness and lobes. | |
| seedRing(0, -C.sepL, +1, +C.yOffL, gammaScaleFor(C.fuelL), C.GammaL, C.radL, C.epsL, C.lobeL); | |
| seedRing(C.Nvort, C.sepR, -1, -C.yOffR, gammaScaleFor(C.fuelR), C.GammaR, C.radR, C.epsR, C.lobeR); | |
| seedDye(); | |
| bPos.needsUpdate = true; | |
| bCol.needsUpdate = true; | |
| sP.textContent = 'Approach'; | |
| } | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CONTROLS | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('sN').textContent = N.toLocaleString(); | |
| document.getElementById('bR').onclick = reset; | |
| document.getElementById('bP').onclick = e => { | |
| paused = !paused; | |
| e.target.textContent = paused ? 'βΆ Resume' : 'βΈ Pause'; | |
| }; | |
| document.getElementById('bD').onclick = () => { | |
| camera.position.set(20, 11, 24); ctrl.target.set(0, 0, 0); ctrl.update(); | |
| }; | |
| document.getElementById('bT').onclick = () => { | |
| camera.position.set(0.01, 0, 32); ctrl.target.set(0, 0, 0); ctrl.update(); | |
| }; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // PARAMETER PANEL | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Re β diffusion mapping. In this dye model the random-walk coefficient plays | |
| // the role of 1/Re: a high Reynolds number means weak diffusion (crisp | |
| // filaments), a low Re means strong diffusion (blurred dye). We map the | |
| // Basilisk reference Re = 1750 to the default diffusion 0.0016. | |
| const RE_REF = 1750, DIFF_REF = 0.0016; | |
| const reToDiff = re => DIFF_REF * (RE_REF / Math.max(re, 1)); | |
| const diffToRe = d => Math.round(DIFF_REF * RE_REF / Math.max(d, 1e-6)); | |
| const DEFAULTS = { | |
| // per-stream (Left / Right) | |
| fuelL: 'RP1', fuelR: 'LOX', | |
| GammaL: 22.0, GammaR: 22.0, | |
| radL: 2.6, radR: 2.6, | |
| sepL: 9.0, sepR: 9.0, | |
| yOffL: 0.1, yOffR: 0.1, | |
| epsL: 0.62, epsR: 0.62, | |
| lobeL: 8, lobeR: 8, | |
| // system | |
| Re: 1750, diffuse: 0.0016, Ndye: 48000, size: 0.5, dt: 0.022, | |
| }; | |
| const el = id => document.getElementById(id); | |
| const panel = el('panel'); | |
| // ---- Propellant dropdowns ---- | |
| const selA = el('pFuelA'), selB = el('pFuelB'); | |
| function populateFuelSelect(sel, current) { | |
| sel.innerHTML = ''; | |
| for (const key in PROPELLANTS) { | |
| const o = document.createElement('option'); | |
| o.value = key; | |
| o.textContent = PROPELLANTS[key].name; | |
| if (key === current) o.selected = true; | |
| sel.appendChild(o); | |
| } | |
| } | |
| populateFuelSelect(selA, C.fuelL); | |
| populateFuelSelect(selB, C.fuelR); | |
| const rgbCss = c => `rgb(${(c[0]*255)|0},${(c[1]*255)|0},${(c[2]*255)|0})`; | |
| // update legend swatches/names + the property read-out box | |
| function refreshFuelUI(keyA, keyB) { | |
| const pA = PROPELLANTS[keyA], pB = PROPELLANTS[keyB]; | |
| el('legA').textContent = pA.name; | |
| el('legB').textContent = pB.name; | |
| const dA = el('dotA'), dB = el('dotB'); | |
| dA.style.background = rgbCss(pA.color); dA.style.boxShadow = '0 0 8px ' + rgbCss(pA.color); | |
| dB.style.background = rgbCss(pB.color); dB.style.boxShadow = '0 0 8px ' + rgbCss(pB.color); | |
| // oxidizer/fuel mixture note | |
| const kinds = [pA.kind, pB.kind]; | |
| let mix = 'propellant mixing'; | |
| if (kinds.includes('oxidizer') && kinds.includes('fuel')) mix = 'β oxidizer + fuel (combustible pair)'; | |
| else if (pA.kind === pB.kind && pA.kind === 'fuel') mix = 'β fuel + fuel (no oxidizer)'; | |
| else if (pA.kind === pB.kind && pA.kind === 'oxidizer') mix = 'β oxidizer + oxidizer (no fuel)'; | |
| el('legMix').textContent = mix; | |
| el('propInfo').innerHTML = | |
| `<b>${pA.name}</b><br>Ο = ${pA.density} kg/mΒ³ Β· ΞΌ = ${pA.viscosity.toFixed(3)} Β· ${pA.kind}` + | |
| `<br><br><b>${pB.name}</b><br>Ο = ${pB.density} kg/mΒ³ Β· ΞΌ = ${pB.viscosity.toFixed(3)} Β· ${pB.kind}` + | |
| `<br><br>Momentum scale L:R = ${gammaScaleFor(keyA).toFixed(2)} : ${gammaScaleFor(keyB).toFixed(2)}`; | |
| } | |
| refreshFuelUI(C.fuelL, C.fuelR); | |
| // live preview of names/colours as the user picks (physics applies on Apply) | |
| selA.addEventListener('change', () => refreshFuelUI(selA.value, selB.value)); | |
| selB.addEventListener('change', () => refreshFuelUI(selA.value, selB.value)); | |
| // slider element references β per-stream (L/R) and system-wide | |
| const S = { | |
| // per-stream Left / Right | |
| GammaL: el('pGammaL'), GammaR: el('pGammaR'), | |
| RL: el('pRL'), RR: el('pRR'), | |
| SepL: el('pSepL'), SepR: el('pSepR'), | |
| YoffL: el('pYoffL'), YoffR: el('pYoffR'), | |
| EpsL: el('pEpsL'), EpsR: el('pEpsR'), | |
| LobeL: el('pLobeL'), LobeR: el('pLobeR'), | |
| // system | |
| Re: el('pRe'), Diff: el('pDiff'), N: el('pN'), Size: el('pSize'), Dt: el('pDt'), | |
| }; | |
| const O = { | |
| GammaL: el('oGammaL'), GammaR: el('oGammaR'), | |
| RL: el('oRL'), RR: el('oRR'), | |
| SepL: el('oSepL'), SepR: el('oSepR'), | |
| YoffL: el('oYoffL'), YoffR: el('oYoffR'), | |
| EpsL: el('oEpsL'), EpsR: el('oEpsR'), | |
| LobeL: el('oLobeL'), LobeR: el('oLobeR'), | |
| Re: el('oRe'), Diff: el('oDiff'), N: el('oN'), Size: el('oSize'), Dt: el('oDt'), | |
| }; | |
| // keep the numeric read-outs beside each slider live while dragging | |
| function refreshOutputs() { | |
| O.GammaL.value = (+S.GammaL.value).toFixed(1); | |
| O.GammaR.value = (+S.GammaR.value).toFixed(1); | |
| O.RL.value = (+S.RL.value).toFixed(1); | |
| O.RR.value = (+S.RR.value).toFixed(1); | |
| O.SepL.value = (+S.SepL.value).toFixed(1); | |
| O.SepR.value = (+S.SepR.value).toFixed(1); | |
| O.YoffL.value = (+S.YoffL.value).toFixed(1); | |
| O.YoffR.value = (+S.YoffR.value).toFixed(1); | |
| O.EpsL.value = (+S.EpsL.value).toFixed(2); | |
| O.EpsR.value = (+S.EpsR.value).toFixed(2); | |
| O.LobeL.value = S.LobeL.value; | |
| O.LobeR.value = S.LobeR.value; | |
| O.Re.value = S.Re.value; | |
| O.Diff.value = (+S.Diff.value).toFixed(4); | |
| O.N.value = (+S.N.value).toLocaleString(); | |
| O.Size.value = (+S.Size.value).toFixed(1); | |
| O.Dt.value = (+S.Dt.value).toFixed(3); | |
| } | |
| // Re and Viscosity are linked β moving one updates the other | |
| S.Re.addEventListener('input', () => { | |
| S.Diff.value = reToDiff(+S.Re.value).toFixed(4); | |
| refreshOutputs(); | |
| }); | |
| S.Diff.addEventListener('input', () => { | |
| S.Re.value = diffToRe(+S.Diff.value); | |
| refreshOutputs(); | |
| }); | |
| ['GammaL','GammaR','RL','RR','SepL','SepR','YoffL','YoffR','EpsL','EpsR','LobeL','LobeR','N','Dt'] | |
| .forEach(k => S[k].addEventListener('input', refreshOutputs)); | |
| // Particle size updates the shader uniform live β no restart needed | |
| S.Size.addEventListener('input', () => { | |
| mat.uniforms.uSize.value = (+S.Size.value) * PR; | |
| refreshOutputs(); | |
| }); | |
| // Apply slider values into the live config, rebuild if counts changed, restart | |
| function applyParams() { | |
| const newN = +S.N.value; | |
| const countChanged = (newN !== C.Ndye); | |
| // per-stream | |
| C.fuelL = selA.value; C.fuelR = selB.value; | |
| C.GammaL = +S.GammaL.value; C.GammaR = +S.GammaR.value; | |
| C.radL = +S.RL.value; C.radR = +S.RR.value; | |
| C.sepL = +S.SepL.value; C.sepR = +S.SepR.value; | |
| C.yOffL = +S.YoffL.value; C.yOffR = +S.YoffR.value; | |
| C.epsL = +S.EpsL.value; C.epsR = +S.EpsR.value; | |
| C.lobeL = +S.LobeL.value; C.lobeR = +S.LobeR.value; | |
| // system | |
| C.diffuse = +S.Diff.value; | |
| C.Ndye = newN; | |
| C.dt = +S.Dt.value; | |
| if (countChanged) rebuildBuffers(); | |
| mat.uniforms.uSize.value = (+S.Size.value) * PR; | |
| refreshFuelUI(C.fuelL, C.fuelR); | |
| el('re').textContent = 'Re = ' + S.Re.value; | |
| el('sN').textContent = C.Ndye.toLocaleString(); | |
| reset(); | |
| } | |
| function loadDefaults() { | |
| selA.value = DEFAULTS.fuelL; selB.value = DEFAULTS.fuelR; | |
| S.GammaL.value = DEFAULTS.GammaL; S.GammaR.value = DEFAULTS.GammaR; | |
| S.RL.value = DEFAULTS.radL; S.RR.value = DEFAULTS.radR; | |
| S.SepL.value = DEFAULTS.sepL; S.SepR.value = DEFAULTS.sepR; | |
| S.YoffL.value = DEFAULTS.yOffL; S.YoffR.value = DEFAULTS.yOffR; | |
| S.EpsL.value = DEFAULTS.epsL; S.EpsR.value = DEFAULTS.epsR; | |
| S.LobeL.value = DEFAULTS.lobeL; S.LobeR.value = DEFAULTS.lobeR; | |
| S.Re.value = DEFAULTS.Re; | |
| S.Diff.value = DEFAULTS.diffuse; | |
| S.N.value = DEFAULTS.Ndye; | |
| S.Size.value = DEFAULTS.size; | |
| S.Dt.value = DEFAULTS.dt; | |
| refreshOutputs(); | |
| } | |
| const about = el('about'); | |
| el('bA').onclick = () => about.classList.toggle('open'); | |
| el('aClose').onclick = () => about.classList.remove('open'); | |
| el('bG').onclick = () => panel.classList.toggle('open'); | |
| el('pClose').onclick = () => panel.classList.remove('open'); | |
| el('pApply').onclick = applyParams; | |
| el('pReset').onclick = () => { loadDefaults(); applyParams(); }; | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // LOOP | |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Load the default parameters into the panel and push them into the live | |
| // config BEFORE the first frame, so the program starts from the panel values | |
| // (vertical offset, particle size, propellants, β¦) rather than raw defaults. | |
| loadDefaults(); | |
| applyParams(); | |
| const RESET_RADIUS = 11.5; // when the bloom grows past this, loop the cycle | |
| let holdFrames = 0; // short pause at full bloom before restarting | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| ctrl.update(); | |
| if (!paused) { | |
| // Fixed number of physics steps per frame β independent of real elapsed | |
| // time, so a slow/backgrounded tab can never race the sim off-screen. | |
| const dt = C.dt; | |
| stepVortex(dt); // vortex elements define the field | |
| stepDye(dt); // dye follows the field | |
| simTime += dt; | |
| sT.textContent = simTime.toFixed(2); | |
| updatePhase(); | |
| bPos.needsUpdate = true; | |
| // Auto-loop: once the merged ring has bloomed outward, hold briefly | |
| // then restart the collision so the interesting window always replays. | |
| if (holdFrames > 0) { | |
| if (--holdFrames === 0) reset(); | |
| } else if (ringRadius(0) > RESET_RADIUS) { | |
| holdFrames = 60; // ~1 s of real time at the bloom's peak | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| </script> | |
| </body> | |
| </html> | |