be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
316 lines
11 KiB
JavaScript
316 lines
11 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;
|
||
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);
|
||
}
|
||
}
|