6afe928c0d
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
340 lines
12 KiB
JavaScript
340 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
/* ═══════════════════════════════════════════════
|
||
CrystalSim — 3D crystal lattice (Three.js)
|
||
NaCl, Diamond, BCC metal, FCC metal
|
||
═══════════════════════════════════════════════ */
|
||
|
||
class CrystalSim {
|
||
constructor(container) {
|
||
this.container = container;
|
||
this._running = false;
|
||
|
||
/* Three.js */
|
||
this.scene = new THREE.Scene();
|
||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
|
||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
this.renderer.setClearColor(0x0D0D1A, 1);
|
||
container.appendChild(this.renderer.domElement);
|
||
|
||
/* lighting */
|
||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
|
||
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
||
dir.position.set(5, 8, 6);
|
||
this.scene.add(dir);
|
||
const pt = new THREE.PointLight(0x9B5DE5, 0.4, 50);
|
||
pt.position.set(-4, 3, 5);
|
||
this.scene.add(pt);
|
||
|
||
this.camera.position.set(8, 6, 8);
|
||
this.camera.lookAt(0, 0, 0);
|
||
|
||
/* orbit-like manual controls */
|
||
this._drag = false;
|
||
this._prevX = 0;
|
||
this._prevY = 0;
|
||
this._rotY = 0.6;
|
||
this._rotX = 0.4;
|
||
this._dist = 12;
|
||
this._autoSpin = true;
|
||
|
||
const el = this.renderer.domElement;
|
||
el.style.cursor = 'grab';
|
||
el.addEventListener('pointerdown', e => { this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; el.style.cursor = 'grabbing'; });
|
||
window.addEventListener('pointerup', () => { this._drag = false; el.style.cursor = 'grab'; });
|
||
window.addEventListener('pointermove', e => {
|
||
if (!this._drag) return;
|
||
this._rotY += (e.clientX - this._prevX) * 0.008;
|
||
this._rotX += (e.clientY - this._prevY) * 0.008;
|
||
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
||
this._prevX = e.clientX; this._prevY = e.clientY;
|
||
});
|
||
el.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
this._dist = Math.max(5, Math.min(30, this._dist + e.deltaY * 0.02));
|
||
}, { passive: false });
|
||
|
||
/* touch */
|
||
el.addEventListener('touchstart', e => {
|
||
if (e.touches.length === 1) {
|
||
this._drag = true; this._prevX = e.touches[0].clientX; this._prevY = e.touches[0].clientY; this._autoSpin = false;
|
||
}
|
||
}, { passive: true });
|
||
el.addEventListener('touchmove', e => {
|
||
if (!this._drag || e.touches.length !== 1) return;
|
||
const t = e.touches[0];
|
||
this._rotY += (t.clientX - this._prevX) * 0.008;
|
||
this._rotX += (t.clientY - this._prevY) * 0.008;
|
||
this._rotX = Math.max(-1.4, Math.min(1.4, this._rotX));
|
||
this._prevX = t.clientX; this._prevY = t.clientY;
|
||
}, { passive: true });
|
||
el.addEventListener('touchend', () => { this._drag = false; });
|
||
|
||
/* resize */
|
||
this._ro = new ResizeObserver(() => this.fit());
|
||
this._ro.observe(container);
|
||
|
||
/* state */
|
||
this._lattice = 'nacl';
|
||
this._group = new THREE.Group();
|
||
this.scene.add(this._group);
|
||
|
||
this._buildLattice('nacl');
|
||
this.fit();
|
||
this.play();
|
||
}
|
||
|
||
/* ── public ── */
|
||
setLattice(type) {
|
||
this._lattice = type;
|
||
if (window.LabFX) LabFX.sound.play('chime', { pitch: 1.2, volume: 0.3 });
|
||
this._buildLattice(type);
|
||
}
|
||
|
||
fit() {
|
||
const w = this.container.clientWidth || 600;
|
||
const h = this.container.clientHeight || 400;
|
||
this.camera.aspect = w / h;
|
||
this.camera.updateProjectionMatrix();
|
||
this.renderer.setSize(w, h);
|
||
}
|
||
|
||
play() { if (!this._running) { this._running = true; this._loop(); } }
|
||
stop() { this._running = false; }
|
||
pause() { this._running = false; }
|
||
|
||
/* ── lattice builders ── */
|
||
_buildLattice(type) {
|
||
// clear
|
||
while (this._group.children.length) {
|
||
const c = this._group.children[0];
|
||
c.geometry?.dispose(); c.material?.dispose();
|
||
this._group.remove(c);
|
||
}
|
||
|
||
const builders = {
|
||
nacl: () => this._buildNaCl(),
|
||
diamond: () => this._buildDiamond(),
|
||
bcc: () => this._buildBCC(),
|
||
fcc: () => this._buildFCC(),
|
||
};
|
||
(builders[type] || builders.nacl)();
|
||
}
|
||
|
||
_sphere(r, color) {
|
||
const geo = new THREE.SphereGeometry(r, 24, 24);
|
||
const mat = new THREE.MeshPhysicalMaterial({
|
||
color, metalness: 0.1, roughness: 0.3,
|
||
clearcoat: 0.6, clearcoatRoughness: 0.2,
|
||
});
|
||
return new THREE.Mesh(geo, mat);
|
||
}
|
||
|
||
_bond(from, to, color = 0x555555) {
|
||
const dir = new THREE.Vector3().subVectors(to, from);
|
||
const len = dir.length();
|
||
const geo = new THREE.CylinderGeometry(0.04, 0.04, len, 8);
|
||
const mat = new THREE.MeshStandardMaterial({ color, opacity: 0.5, transparent: true });
|
||
const mesh = new THREE.Mesh(geo, mat);
|
||
mesh.position.copy(from).add(dir.multiplyScalar(0.5));
|
||
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
|
||
return mesh;
|
||
}
|
||
|
||
_buildNaCl() {
|
||
const n = 3; // 3×3×3 unit cells
|
||
const a = 2.0; // lattice constant
|
||
const offset = -(n - 1) * a / 2;
|
||
const positions = [];
|
||
|
||
for (let i = 0; i < n; i++)
|
||
for (let j = 0; j < n; j++)
|
||
for (let k = 0; k < n; k++) {
|
||
const x = offset + i * a, y = offset + j * a, z = offset + k * a;
|
||
const isNa = (i + j + k) % 2 === 0;
|
||
const s = this._sphere(isNa ? 0.28 : 0.38, isNa ? 0x9B5DE5 : 0x06D6E0);
|
||
s.position.set(x, y, z);
|
||
this._group.add(s);
|
||
positions.push({ x, y, z });
|
||
}
|
||
|
||
// bonds to nearest neighbors
|
||
for (let i = 0; i < positions.length; i++)
|
||
for (let j = i + 1; j < positions.length; j++) {
|
||
const dx = positions[i].x - positions[j].x;
|
||
const dy = positions[i].y - positions[j].y;
|
||
const dz = positions[i].z - positions[j].z;
|
||
const d2 = dx * dx + dy * dy + dz * dz;
|
||
if (Math.abs(d2 - a * a) < 0.01) {
|
||
const b = this._bond(
|
||
new THREE.Vector3(positions[i].x, positions[i].y, positions[i].z),
|
||
new THREE.Vector3(positions[j].x, positions[j].y, positions[j].z),
|
||
0x444466
|
||
);
|
||
this._group.add(b);
|
||
}
|
||
}
|
||
}
|
||
|
||
_buildDiamond() {
|
||
const a = 2.5;
|
||
const basis = [
|
||
[0, 0, 0], [0.5, 0.5, 0], [0.5, 0, 0.5], [0, 0.5, 0.5],
|
||
[0.25, 0.25, 0.25], [0.75, 0.75, 0.25], [0.75, 0.25, 0.75], [0.25, 0.75, 0.75],
|
||
];
|
||
const n = 2;
|
||
const offset = -(n * a) / 2;
|
||
const allPos = [];
|
||
|
||
for (let ci = 0; ci < n; ci++)
|
||
for (let cj = 0; cj < n; cj++)
|
||
for (let ck = 0; ck < n; ck++)
|
||
for (const [bx, by, bz] of basis) {
|
||
const x = offset + (ci + bx) * a;
|
||
const y = offset + (cj + by) * a;
|
||
const z = offset + (ck + bz) * a;
|
||
const s = this._sphere(0.22, 0x34d399);
|
||
s.position.set(x, y, z);
|
||
this._group.add(s);
|
||
allPos.push(new THREE.Vector3(x, y, z));
|
||
}
|
||
|
||
// bonds
|
||
const bondLen = a * Math.sqrt(3) / 4;
|
||
const tol = bondLen * 0.15;
|
||
for (let i = 0; i < allPos.length; i++)
|
||
for (let j = i + 1; j < allPos.length; j++) {
|
||
const d = allPos[i].distanceTo(allPos[j]);
|
||
if (Math.abs(d - bondLen) < tol) {
|
||
this._group.add(this._bond(allPos[i], allPos[j], 0x228866));
|
||
}
|
||
}
|
||
}
|
||
|
||
_buildBCC() {
|
||
const a = 2.2, n = 3;
|
||
const offset = -(n - 1) * a / 2;
|
||
const allPos = [];
|
||
|
||
for (let i = 0; i < n; i++)
|
||
for (let j = 0; j < n; j++)
|
||
for (let k = 0; k < n; k++) {
|
||
// corner atoms
|
||
const x1 = offset + i * a, y1 = offset + j * a, z1 = offset + k * a;
|
||
const s1 = this._sphere(0.3, 0xF15BB5);
|
||
s1.position.set(x1, y1, z1);
|
||
this._group.add(s1);
|
||
allPos.push(new THREE.Vector3(x1, y1, z1));
|
||
|
||
// body center (except last cell in each dimension)
|
||
if (i < n - 1 && j < n - 1 && k < n - 1) {
|
||
const cx = x1 + a / 2, cy = y1 + a / 2, cz = z1 + a / 2;
|
||
const s2 = this._sphere(0.3, 0xF59E0B);
|
||
s2.position.set(cx, cy, cz);
|
||
this._group.add(s2);
|
||
allPos.push(new THREE.Vector3(cx, cy, cz));
|
||
}
|
||
}
|
||
|
||
// bonds
|
||
const bondLen = a * Math.sqrt(3) / 2;
|
||
const tol = bondLen * 0.1;
|
||
for (let i = 0; i < allPos.length; i++)
|
||
for (let j = i + 1; j < allPos.length; j++) {
|
||
const d = allPos[i].distanceTo(allPos[j]);
|
||
if (Math.abs(d - bondLen) < tol) {
|
||
this._group.add(this._bond(allPos[i], allPos[j], 0x664444));
|
||
}
|
||
}
|
||
}
|
||
|
||
_buildFCC() {
|
||
const a = 2.4, n = 3;
|
||
const offset = -(n - 1) * a / 2;
|
||
const allPos = [];
|
||
|
||
for (let i = 0; i < n; i++)
|
||
for (let j = 0; j < n; j++)
|
||
for (let k = 0; k < n; k++) {
|
||
const x = offset + i * a, y = offset + j * a, z = offset + k * a;
|
||
// corner
|
||
const s = this._sphere(0.25, 0x60a5fa);
|
||
s.position.set(x, y, z);
|
||
this._group.add(s);
|
||
allPos.push(new THREE.Vector3(x, y, z));
|
||
|
||
// face centers (only for cell interiors)
|
||
if (i < n - 1 && j < n - 1) {
|
||
const f1 = this._sphere(0.25, 0x60a5fa);
|
||
f1.position.set(x + a / 2, y + a / 2, z);
|
||
this._group.add(f1);
|
||
allPos.push(new THREE.Vector3(x + a / 2, y + a / 2, z));
|
||
}
|
||
if (i < n - 1 && k < n - 1) {
|
||
const f2 = this._sphere(0.25, 0x60a5fa);
|
||
f2.position.set(x + a / 2, y, z + a / 2);
|
||
this._group.add(f2);
|
||
allPos.push(new THREE.Vector3(x + a / 2, y, z + a / 2));
|
||
}
|
||
if (j < n - 1 && k < n - 1) {
|
||
const f3 = this._sphere(0.25, 0x60a5fa);
|
||
f3.position.set(x, y + a / 2, z + a / 2);
|
||
this._group.add(f3);
|
||
allPos.push(new THREE.Vector3(x, y + a / 2, z + a / 2));
|
||
}
|
||
}
|
||
|
||
// bonds to nearest neighbors (a/√2)
|
||
const bondLen = a / Math.SQRT2;
|
||
const tol = bondLen * 0.1;
|
||
for (let i = 0; i < allPos.length; i++)
|
||
for (let j = i + 1; j < allPos.length; j++) {
|
||
const d = allPos[i].distanceTo(allPos[j]);
|
||
if (Math.abs(d - bondLen) < tol) {
|
||
this._group.add(this._bond(allPos[i], allPos[j], 0x334466));
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── animation ── */
|
||
_loop() {
|
||
if (!this._running) return;
|
||
requestAnimationFrame(() => this._loop());
|
||
|
||
if (this._autoSpin) this._rotY += 0.003;
|
||
|
||
this.camera.position.set(
|
||
this._dist * Math.sin(this._rotY) * Math.cos(this._rotX),
|
||
this._dist * Math.sin(this._rotX),
|
||
this._dist * Math.cos(this._rotY) * Math.cos(this._rotX)
|
||
);
|
||
this.camera.lookAt(0, 0, 0);
|
||
|
||
this.renderer.render(this.scene, this.camera);
|
||
}
|
||
}
|
||
|
||
/* ─── lab UI init ─────────────────────────────────── */
|
||
var crystalSim = null;
|
||
function _openCrystal() {
|
||
document.getElementById('sim-topbar-title').textContent = 'Кристаллическая решётка';
|
||
_simShow('sim-crystal');
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
if (!crystalSim) {
|
||
crystalSim = new CrystalSim(document.getElementById('crystal-container'));
|
||
} else {
|
||
crystalSim.fit();
|
||
crystalSim.play();
|
||
}
|
||
}));
|
||
}
|
||
function setCrystal(type, btn) {
|
||
document.querySelectorAll('.crystal-type-btn').forEach(b => { b.classList.remove('active'); b.style.borderColor = ''; b.style.color = ''; });
|
||
btn.classList.add('active');
|
||
btn.style.borderColor = '#9B5DE5'; btn.style.color = '#9B5DE5';
|
||
if (crystalSim) crystalSim.setLattice(type);
|
||
}
|
||
|
||
/* ── molecular orbitals (3D) ── */
|