// 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 }; })();