// g3d.js — мини-3D движок для стереометрии. SVG-рендеринг, изометрическая проекция, drag-to-rotate.
(function(){
'use strict';
// === Векторная математика ===
function vAdd(a, b) { return {x:a.x+b.x, y:a.y+b.y, z:a.z+b.z}; }
function vSub(a, b) { return {x:a.x-b.x, y:a.y-b.y, z:a.z-b.z}; }
function vScale(a, k) { return {x:a.x*k, y:a.y*k, z:a.z*k}; }
function vDot(a, b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
function vCross(a, b) {
return {
x: a.y*b.z - a.z*b.y,
y: a.z*b.x - a.x*b.z,
z: a.x*b.y - a.y*b.x
};
}
function vLen(a) { return Math.sqrt(a.x*a.x + a.y*a.y + a.z*a.z); }
function vNorm(a) { var l = vLen(a) || 1; return vScale(a, 1/l); }
// === Матрицы 3x3 ===
function matRotX(t) {
var c = Math.cos(t), s = Math.sin(t);
return [[1,0,0],[0,c,-s],[0,s,c]];
}
function matRotY(t) {
var c = Math.cos(t), s = Math.sin(t);
return [[c,0,s],[0,1,0],[-s,0,c]];
}
function matMul(A, B) {
var R = [[0,0,0],[0,0,0],[0,0,0]];
for (var i=0;i<3;i++) for (var j=0;j<3;j++)
R[i][j] = A[i][0]*B[0][j] + A[i][1]*B[1][j] + A[i][2]*B[2][j];
return R;
}
function vApply(M, v) {
return {
x: M[0][0]*v.x + M[0][1]*v.y + M[0][2]*v.z,
y: M[1][0]*v.x + M[1][1]*v.y + M[1][2]*v.z,
z: M[2][0]*v.x + M[2][1]*v.y + M[2][2]*v.z
};
}
// === Проекция (перспективная с камерой по оси Z) ===
function projectPersp(v, camDist, cx, cy, scale) {
var z = camDist - v.z;
if (z < 0.1) return null;
var k = scale * camDist / z;
return { x: cx + v.x * k, y: cy - v.y * k, depth: z };
}
// === Изометрическая проекция (без перспективы) ===
function projectIso(v, cx, cy, scale) {
return {
x: cx + scale * (v.x - v.z) * 0.866,
y: cy - scale * (v.y - (v.x + v.z) * 0.5),
depth: -v.z + v.x*0.5 + v.y*0.5
};
}
// === Сцена ===
function createScene(opts) {
opts = opts || {};
return {
W: opts.W || 480,
H: opts.H || 360,
cx: opts.cx !== undefined ? opts.cx : (opts.W || 480) / 2,
cy: opts.cy !== undefined ? opts.cy : (opts.H || 360) / 2,
scale: opts.scale || 50,
camDist: opts.camDist || 8,
rotX: opts.rotX !== undefined ? opts.rotX : -0.35,
rotY: opts.rotY !== undefined ? opts.rotY : 0.7,
proj: opts.proj || 'persp',
bg: opts.bg || 'transparent'
};
}
// === Вершины фигур ===
function prismPolygonBase(n, R, theta0) {
theta0 = theta0 || -Math.PI/2;
var verts = [];
for (var i = 0; i < n; i++) {
var a = theta0 + 2*Math.PI*i/n;
verts.push({ x: R*Math.cos(a), z: R*Math.sin(a) });
}
return verts;
}
function prismMesh(n, R, h) {
var baseXZ = prismPolygonBase(n, R);
var verts = [];
for (var i = 0; i < baseXZ.length; i++) verts.push({ x: baseXZ[i].x, y: -h/2, z: baseXZ[i].z });
for (var j = 0; j < baseXZ.length; j++) verts.push({ x: baseXZ[j].x, y: h/2, z: baseXZ[j].z });
var faces = [];
var bottom = [];
for (var k = n - 1; k >= 0; k--) bottom.push(k);
faces.push({ indices: bottom, kind: 'base' });
var top = [];
for (var t = 0; t < n; t++) top.push(n + t);
faces.push({ indices: top, kind: 'base' });
for (var s = 0; s < n; s++) {
var ss = (s + 1) % n;
faces.push({ indices: [s, ss, n + ss, n + s], kind: 'lateral' });
}
return { verts: verts, faces: faces };
}
function pyramidMesh(n, R, h) {
var baseXZ = prismPolygonBase(n, R);
var verts = baseXZ.map(function(p){ return { x: p.x, y: -h/2, z: p.z }; });
verts.push({ x: 0, y: h/2, z: 0 });
var faces = [];
var apex = n;
var bottom = [];
for (var i = n - 1; i >= 0; i--) bottom.push(i);
faces.push({ indices: bottom, kind: 'base' });
for (var j = 0; j < n; j++) {
var jj = (j + 1) % n;
faces.push({ indices: [j, jj, apex], kind: 'lateral' });
}
return { verts: verts, faces: faces };
}
function cylinderMesh(R, h, segments) {
segments = segments || 32;
var bottom = [], top = [];
for (var i = 0; i < segments; i++) {
var a = 2*Math.PI*i/segments;
bottom.push({ x: R*Math.cos(a), y: -h/2, z: R*Math.sin(a) });
top.push({ x: R*Math.cos(a), y: h/2, z: R*Math.sin(a) });
}
var verts = bottom.concat(top);
var faces = [];
var bIdx = []; for (var k = segments - 1; k >= 0; k--) bIdx.push(k);
faces.push({ indices: bIdx, kind: 'base', isRound: true });
var tIdx = []; for (var t = 0; t < segments; t++) tIdx.push(segments + t);
faces.push({ indices: tIdx, kind: 'base', isRound: true });
for (var s = 0; s < segments; s++) {
var ss = (s + 1) % segments;
faces.push({ indices: [s, ss, segments + ss, segments + s], kind: 'lateral' });
}
return { verts: verts, faces: faces, isRound: true };
}
function coneMesh(R, h, segments) {
segments = segments || 32;
var base = [];
for (var i = 0; i < segments; i++) {
var a = 2*Math.PI*i/segments;
base.push({ x: R*Math.cos(a), y: -h/2, z: R*Math.sin(a) });
}
var verts = base.concat([{ x: 0, y: h/2, z: 0 }]);
var apex = segments;
var faces = [];
var bIdx = []; for (var k = segments - 1; k >= 0; k--) bIdx.push(k);
faces.push({ indices: bIdx, kind: 'base', isRound: true });
for (var s = 0; s < segments; s++) {
var ss = (s + 1) % segments;
faces.push({ indices: [s, ss, apex], kind: 'lateral' });
}
return { verts: verts, faces: faces, isRound: true };
}
function sphereWireframe(R, lat, lon) {
lat = lat || 6;
lon = lon || 12;
var lines = [];
for (var i = 1; i < lat; i++) {
var phi = -Math.PI/2 + Math.PI * i/lat;
var r = R * Math.cos(phi);
var y = R * Math.sin(phi);
var pts = [];
for (var j = 0; j <= 48; j++) {
var t = 2*Math.PI*j/48;
pts.push({ x: r*Math.cos(t), y: y, z: r*Math.sin(t) });
}
lines.push({ pts: pts, kind: 'parallel' });
}
for (var k = 0; k < lon; k++) {
var theta = 2*Math.PI*k/lon;
var pts2 = [];
for (var ii = 0; ii <= 48; ii++) {
var phi2 = -Math.PI/2 + Math.PI * ii/48;
var r2 = R * Math.cos(phi2);
pts2.push({ x: r2*Math.cos(theta), y: R*Math.sin(phi2), z: r2*Math.sin(theta) });
}
lines.push({ pts: pts2, kind: 'meridian' });
}
return { lines: lines, R: R };
}
// === Рендеринг ===
function renderMesh(mesh, M, scene, opts) {
opts = opts || {};
var fillBase = opts.fillBase || 'rgba(252,231,243,.55)';
var fillSide = opts.fillSide || 'rgba(219,234,254,.55)';
var strokeVisible = opts.strokeVisible || '#0f172a';
var strokeHidden = opts.strokeHidden || '#94a3b8';
var rotated = mesh.verts.map(function(v){ return vApply(M, v); });
var projector;
if (scene.proj === 'iso') {
projector = function(v){ return projectIso(v, scene.cx, scene.cy, scene.scale); };
} else {
projector = function(v){ return projectPersp(v, scene.camDist, scene.cx, scene.cy, scene.scale); };
}
var projected = rotated.map(projector);
var facesWithDepth = mesh.faces.map(function(face){
var idx = face.indices;
var normal = null;
if (idx.length >= 3) {
var v0 = rotated[idx[0]], v1 = rotated[idx[1]], v2 = rotated[idx[2]];
var e1 = vSub(v1, v0), e2 = vSub(v2, v0);
normal = vCross(e1, e2);
}
var avgZ = 0;
for (var i = 0; i < idx.length; i++) avgZ += rotated[idx[i]].z;
avgZ /= idx.length;
var visible = normal ? (normal.z > -1e-6) : true;
return { face: face, visible: visible, avgZ: avgZ, projected: idx.map(function(j){ return projected[j]; }) };
});
facesWithDepth.sort(function(a, b){ return a.avgZ - b.avgZ; });
var svg = '';
for (var f = 0; f < facesWithDepth.length; f++) {
var fd = facesWithDepth[f];
var anyMissing = false;
for (var p = 0; p < fd.projected.length; p++) if (!fd.projected[p]) { anyMissing = true; break; }
if (anyMissing) continue;
var points = fd.projected.map(function(pp){ return pp.x.toFixed(1) + ',' + pp.y.toFixed(1); }).join(' ');
var fill = fd.face.kind === 'base' ? fillBase : fillSide;
if (fd.visible) {
svg += '';
} else {
var idx2 = fd.face.indices;
for (var ii = 0; ii < idx2.length; ii++) {
var a = projected[idx2[ii]], b = projected[idx2[(ii+1) % idx2.length]];
if (a && b) {
svg += '';
}
}
}
}
return svg;
}
// === Рендеринг wireframe сферы ===
function renderSphereWireframe(sphere, M, scene, opts) {
opts = opts || {};
var strokeParallel = opts.strokeParallel || '#0891b2';
var strokeMeridian = opts.strokeMeridian || '#0e7490';
var projector;
if (scene.proj === 'iso') {
projector = function(v){ return projectIso(v, scene.cx, scene.cy, scene.scale); };
} else {
projector = function(v){ return projectPersp(v, scene.camDist, scene.cx, scene.cy, scene.scale); };
}
var svg = '';
// outline circle: проецируем экватор после поворота (приближённо радиус как scale*R)
var R = sphere.R;
var origin = projector({x:0,y:0,z:0});
if (origin) {
svg += '';
}
for (var i = 0; i < sphere.lines.length; i++) {
var line = sphere.lines[i];
var col = line.kind === 'parallel' ? strokeParallel : strokeMeridian;
var d = '';
var prev = false;
for (var j = 0; j < line.pts.length; j++) {
var rv = vApply(M, line.pts[j]);
var pp = projector(rv);
if (!pp) { prev = false; continue; }
// back-face: если z вершины > 0 (после поворота), рисуем сплошной, иначе пунктир
var solid = rv.z > -1e-6;
// упрощённо: рисуем непрерывную полилинию, цвет в зависимости от middle z
d += (prev ? ' L' : ' M') + pp.x.toFixed(1) + ',' + pp.y.toFixed(1);
prev = true;
}
svg += '';
}
return svg;
}
// === Drag-to-rotate ===
function attachOrbit(svgEl, scene, onChange) {
var dragging = false, lastX = 0, lastY = 0;
svgEl.style.touchAction = 'none';
svgEl.style.cursor = 'grab';
function onDown(e) {
dragging = true;
svgEl.style.cursor = 'grabbing';
var p = e.touches ? e.touches[0] : e;
lastX = p.clientX; lastY = p.clientY;
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
var p = e.touches ? e.touches[0] : e;
var dx = p.clientX - lastX, dy = p.clientY - lastY;
scene.rotY += dx * 0.012;
scene.rotX += dy * 0.012;
scene.rotX = Math.max(-1.4, Math.min(1.4, scene.rotX));
lastX = p.clientX; lastY = p.clientY;
if (onChange) onChange();
e.preventDefault();
}
function onUp() { dragging = false; svgEl.style.cursor = 'grab'; }
svgEl.addEventListener('mousedown', onDown, { passive: false });
svgEl.addEventListener('touchstart', onDown, { passive: false });
window.addEventListener('mousemove', onMove, { passive: false });
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('mouseup', onUp);
window.addEventListener('touchend', onUp);
}
function buildRotMatrix(scene) {
return matMul(matRotX(scene.rotX), matRotY(scene.rotY));
}
function presetView(scene, name, onChange) {
if (name === 'front') { scene.rotX = 0; scene.rotY = 0; }
else if (name === 'top') { scene.rotX = -Math.PI/2 + 0.01; scene.rotY = 0; }
else if (name === 'side') { scene.rotX = 0; scene.rotY = Math.PI/2; }
else if (name === 'iso') { scene.rotX = -0.35; scene.rotY = 0.7; }
if (onChange) onChange();
}
// === Экспорт ===
window.G3D = {
vAdd: vAdd, vSub: vSub, vScale: vScale, vDot: vDot, vCross: vCross, vLen: vLen, vNorm: vNorm,
matRotX: matRotX, matRotY: matRotY, matMul: matMul, vApply: vApply,
projectPersp: projectPersp, projectIso: projectIso,
createScene: createScene, buildRotMatrix: buildRotMatrix, attachOrbit: attachOrbit, presetView: presetView,
prismMesh: prismMesh, pyramidMesh: pyramidMesh, cylinderMesh: cylinderMesh, coneMesh: coneMesh,
sphereWireframe: sphereWireframe,
renderMesh: renderMesh, renderSphereWireframe: renderSphereWireframe
};
})();