b771c3d497
- 026_geometry_11_hub.sql: hub geometry-11 (cyan, 11 параграфов) + 4 раздела
(Призма и цилиндр, Пирамида и конус, Сфера и шар, Повторение).
- frontend/js/g3d.js: мини-3D движок для стереометрии.
Векторная математика, матрицы 3x3, перспективная + изометрическая проекции,
меши призмы/пирамиды/цилиндра/конуса, wireframe сферы, back-face culling
через нормали, Z-sort, drag-to-rotate (mouse + touch), preset views.
- frontend/textbooks/geometry_11_hub.html: hub с палитрой cyan/sky,
4 карточками разделов, аккордеон финала курса (placeholder Phase 5).
- frontend/textbooks/geometry_11_ch{1..4}.html: skeleton 4 разделов
(через gen_geom11_chapters.js). Все включают: помощники KaTeX, SVG 2D
(axes2D/plotFunc/pointWithDrop/asymptote/rightAngleMark/angleArcAuto/unitVec),
ICONS, makeCard, setupSorter, gcd, wireReadBtn, secNav, search, sidebar,
GEOM11 POLISH CSS + JS, подключение /js/g3d.js. STUB builder для всех 11
параграфов + 4 финалов с demo-G3D viewer (призма/цилиндр/пирамида/конус/
сфера-wireframe).
342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
// 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 += '<polygon points="' + points + '" fill="' + fill + '" stroke="' + strokeVisible + '" stroke-width="1.8" stroke-linejoin="round"/>';
|
|
} 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 += '<line x1="' + a.x.toFixed(1) + '" y1="' + a.y.toFixed(1) + '" x2="' + b.x.toFixed(1) + '" y2="' + b.y.toFixed(1) + '" stroke="' + strokeHidden + '" stroke-width="1" stroke-dasharray="4 3" opacity=".6"/>';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 += '<circle cx="' + origin.x.toFixed(1) + '" cy="' + origin.y.toFixed(1) + '" r="' + (scene.scale * R).toFixed(1) + '" fill="rgba(207,250,254,.25)" stroke="#0e7490" stroke-width="2"/>';
|
|
}
|
|
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 += '<path d="' + d + '" stroke="' + col + '" stroke-width="1" fill="none" opacity=".55"/>';
|
|
}
|
|
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
|
|
};
|
|
})();
|