diff --git a/backend/scripts/gen_geom10_stubs.js b/backend/scripts/gen_geom10_stubs.js
new file mode 100644
index 0000000..5fd0e83
--- /dev/null
+++ b/backend/scripts/gen_geom10_stubs.js
@@ -0,0 +1,224 @@
+#!/usr/bin/env node
+'use strict';
+// Генератор stub-файлов разделов Геометрии 10. W0.
+// Запуск: node backend/scripts/gen_geom10_stubs.js
+
+const fs = require('fs');
+const path = require('path');
+
+const sections = [
+ { file:'geometry_10_r1.html', num:1, slug:'geometry-10-r1',
+ title:'Введение в стереометрию',
+ sub:'Пространственные фигуры · Аксиомы · Сечения',
+ range:'§1–§3 + Финал', wm:'△',
+ primary:'#2563eb', primaryD:'#1d4ed8', soft:'#dbeafe', dark:'#1e3a8a',
+ paras:[
+ { n:1, title:'Пространственные фигуры',
+ sub:'Призма, пирамида, цилиндр, конус, шар. Грани, рёбра, вершины.' },
+ { n:2, title:'Прямые и плоскости',
+ sub:'Аксиомы стереометрии (A1–A3) и их следствия. 4 способа задания плоскости.' },
+ { n:3, title:'Построения сечений',
+ sub:'Метод следов. Сечения куба, призмы, пирамиды.' }
+ ] },
+ { file:'geometry_10_r2.html', num:2, slug:'geometry-10-r2',
+ title:'Параллельность',
+ sub:'Прямые · Прямая и плоскость · Плоскости',
+ range:'§4–§6 + Финал', wm:'∥',
+ primary:'#059669', primaryD:'#047857', soft:'#d1fae5', dark:'#064e3b',
+ paras:[
+ { n:4, title:'Расположение прямых в пространстве',
+ sub:'Пересекающиеся, параллельные, скрещивающиеся прямые. Угол между скрещивающимися.' },
+ { n:5, title:'Прямая и плоскость',
+ sub:'Три случая. Признак параллельности прямой и плоскости.' },
+ { n:6, title:'Две плоскости',
+ sub:'Пересекаются или параллельны. Признак параллельности плоскостей.' }
+ ] },
+ { file:'geometry_10_r3.html', num:3, slug:'geometry-10-r3',
+ title:'Перпендикулярность',
+ sub:'Прямая ⊥ плоскость · Расстояния · Углы · Двугранный угол',
+ range:'§7–§10 + Финал', wm:'⊥',
+ primary:'#e11d48', primaryD:'#be123c', soft:'#fee2e2', dark:'#7f1d1d',
+ paras:[
+ { n:7, title:'Перпендикулярность прямой и плоскости',
+ sub:'Определение, признак перпендикулярности.' },
+ { n:8, title:'Расстояния',
+ sub:'От точки до плоскости, между параллельными плоскостями, между скрещивающимися.' },
+ { n:9, title:'Угол между прямой и плоскостью',
+ sub:'Наклонная и её проекция. Теорема о трёх перпендикулярах.' },
+ { n:10, title:'Перпендикулярность плоскостей',
+ sub:'Двугранный угол. Признак перпендикулярности плоскостей.' }
+ ] },
+ { file:'geometry_10_r4.html', num:4, slug:'geometry-10-r4',
+ title:'Координаты и векторы',
+ sub:'ПДСК в пространстве · Векторы · Скалярное произведение',
+ range:'§11–§14 + Финал', wm:'→',
+ primary:'#d97706', primaryD:'#b45309', soft:'#fef3c7', dark:'#78350f',
+ paras:[
+ { n:11, title:'Координаты в пространстве',
+ sub:'ПДСК. Точка (x; y; z). Расстояние между точками.' },
+ { n:12, title:'Вектор. Действия над векторами',
+ sub:'Сложение, умножение на число, базис i, j, k. Разложение вектора.' },
+ { n:13, title:'Скалярное произведение',
+ sub:'a · b = |a||b|cos α = x₁x₂ + y₁y₂ + z₁z₂. Условие перпендикулярности.' },
+ { n:14, title:'Применение координат и векторов',
+ sub:'Уравнения, углы, расстояния. Векторно-координатный метод.' }
+ ] }
+];
+
+function html(s){
+ const parasHtml = s.paras.map(p => `
+
+ § ${p.n}
+
+
${p.title}
+
${p.sub}
+
+
+ Будет добавлено в следующей волне реализации
+
+
+ `).join('\n');
+
+ return `
+
+
+
+
+
+Геометрия 10 · ${s.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Раздел ${s.num}. ${s.title}
+
${s.sub} · ${s.range}
+
+
+
+
+
+
+
+
+
Раздел ${s.num}
+
${s.title}
+
${s.sub}. Раздел содержит ${s.paras.length} параграф${s.paras.length===1?'':(s.paras.length<5?'а':'ов')} и финальный этап с боссами.
+
+
+
+${parasHtml}
+
+
+
+ Раздел в разработке. Полная реализация — в следующих волнах. Уже доступна 3D-библиотека stereo3d.js.
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+const outDir = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
+for (const s of sections){
+ const fp = path.join(outDir, s.file);
+ fs.writeFileSync(fp, html(s), 'utf8');
+ console.log('Wrote:', fp);
+}
+console.log('Done.');
diff --git a/backend/src/db/migrations/027_geometry_10_hub.sql b/backend/src/db/migrations/027_geometry_10_hub.sql
new file mode 100644
index 0000000..d1c19b3
--- /dev/null
+++ b/backend/src/db/migrations/027_geometry_10_hub.sql
@@ -0,0 +1,32 @@
+-- Geometry 10 hub migration.
+-- Adds hub row + 4 section children for Геометрия 10 (Латотин/Чеботаревский/Горбунова, 2020).
+-- Pattern mirrors 023_algebra_10_hub.sql.
+
+-- 1. Hub row.
+INSERT INTO textbooks
+ (slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active)
+VALUES
+ ('geometry-10', 'math', 10, 'Геометрия — 10 класс', '',
+ 'Полный курс стереометрии 10 класса по учебнику Л. А. Латотина и Б. Д. Чеботаревского: введение в стереометрию (аксиомы, сечения), параллельность прямых и плоскостей, перпендикулярность, координаты и векторы в пространстве. 4 раздела, 14 параграфов, ~140 интерактивов, 24 босса. Все 3D-фигуры — через библиотеку stereo3d.js.',
+ 'geometry_10_hub.html', 14, 'blue', 9, 1);
+
+-- 2. Section children.
+INSERT INTO textbooks
+ (slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug)
+VALUES
+ ('geometry-10-r1', 'math', 10, 'Геометрия 10 · Введение в стереометрию',
+ '',
+ '§1–§3: пространственные фигуры (призма, пирамида, цилиндр, конус, шар), аксиомы стереометрии и их следствия, построение сечений многогранников методом следов.',
+ 'geometry_10_r1.html', 3, 'blue', 1, 1, 'geometry-10'),
+ ('geometry-10-r2', 'math', 10, 'Геометрия 10 · Параллельность',
+ '',
+ '§4–§6: взаимное расположение прямых в пространстве (пересекающиеся, параллельные, скрещивающиеся), взаимное расположение прямой и плоскости, взаимное расположение двух плоскостей, признаки параллельности.',
+ 'geometry_10_r2.html', 3, 'emerald', 2, 1, 'geometry-10'),
+ ('geometry-10-r3', 'math', 10, 'Геометрия 10 · Перпендикулярность',
+ '',
+ '§7–§10: перпендикулярность прямой и плоскости, расстояния в пространстве, угол между прямой и плоскостью (теорема о трёх перпендикулярах), перпендикулярность плоскостей (двугранный угол).',
+ 'geometry_10_r3.html', 4, 'rose', 3, 1, 'geometry-10'),
+ ('geometry-10-r4', 'math', 10, 'Геометрия 10 · Координаты и векторы',
+ '',
+ '§11–§14: прямоугольная система координат в пространстве, векторы и действия над ними, скалярное произведение, применение векторно-координатного метода к решению задач.',
+ 'geometry_10_r4.html', 4, 'amber', 4, 1, 'geometry-10');
diff --git a/frontend/js/stereo3d.js b/frontend/js/stereo3d.js
new file mode 100644
index 0000000..89373e9
--- /dev/null
+++ b/frontend/js/stereo3d.js
@@ -0,0 +1,717 @@
+/* stereo3d.js — библиотека 3D-проекций для Геометрии 10 (стереометрия).
+ *
+ * Чистый SVG-движок: проекция 3D → 2D через cabinet / isometric.
+ * Не зависит от Three.js, Canvas, WebGL. Возвращает строки SVG.
+ *
+ * Публичный API: window.STEREO3D = { Scene, views, util }.
+ *
+ * Использование:
+ * const scene = new STEREO3D.Scene(380, 320, {view:'CABINET', scale:50});
+ * scene.addCube({center:[0,0,0], size:2, label:'ABCDA1B1C1D1'});
+ * scene.addLabel('M', [0, 0, 1.2]);
+ * const svgHtml = scene.render();
+ *
+ * Соглашение об осях: x → вправо, y → вглубь, z → вверх.
+ * В SVG y растёт вниз, мы инвертируем при проекции.
+ */
+(function(){
+'use strict';
+
+if (window.STEREO3D && window.STEREO3D.__installed) return;
+const S = window.STEREO3D = window.STEREO3D || {};
+S.__installed = true;
+
+/* ============================================================ */
+/* ВЕКТОРНАЯ АЛГЕБРА */
+/* ============================================================ */
+
+function v3(a,b,c){ return [a,b,c]; }
+function add(a,b){ return [a[0]+b[0], a[1]+b[1], a[2]+b[2]]; }
+function sub(a,b){ return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
+function scale3(a,k){ return [a[0]*k, a[1]*k, a[2]*k]; }
+function dot(a,b){ return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
+function cross(a,b){
+ return [
+ a[1]*b[2] - a[2]*b[1],
+ a[2]*b[0] - a[0]*b[2],
+ a[0]*b[1] - a[1]*b[0]
+ ];
+}
+function len3(a){ return Math.hypot(a[0], a[1], a[2]); }
+function norm3(a){ const L = len3(a)||1; return [a[0]/L, a[1]/L, a[2]/L]; }
+
+function rotX(p, a){
+ const c = Math.cos(a), s = Math.sin(a);
+ return [p[0], p[1]*c - p[2]*s, p[1]*s + p[2]*c];
+}
+function rotY(p, a){
+ const c = Math.cos(a), s = Math.sin(a);
+ return [p[0]*c + p[2]*s, p[1], -p[0]*s + p[2]*c];
+}
+function rotZ(p, a){
+ const c = Math.cos(a), s = Math.sin(a);
+ return [p[0]*c - p[1]*s, p[0]*s + p[1]*c, p[2]];
+}
+
+S.util = { v3, add, sub, scale3, dot, cross, len3, norm3, rotX, rotY, rotZ };
+
+/* ============================================================ */
+/* ПРОЕКЦИИ */
+/* ============================================================ */
+
+const VIEWS = S.views = {
+ CABINET: {
+ project: function(p){
+ // x → вправо, y → вглубь под 30° со сжатием 0.5, z → вверх
+ const a = Math.PI / 6;
+ return [
+ p[0] + 0.5 * p[1] * Math.cos(a),
+ -p[2] + 0.5 * p[1] * Math.sin(a)
+ ];
+ },
+ cameraDir: [0.5*Math.cos(Math.PI/6), 1, 0.5*Math.sin(Math.PI/6)]
+ },
+ ISOMETRIC: {
+ project: function(p){
+ // классическая изометрия: оси под 30° к горизонтали
+ const c = Math.cos(Math.PI/6), s = Math.sin(Math.PI/6);
+ return [
+ (p[0] - p[1]) * c,
+ -p[2] + (p[0] + p[1]) * s
+ ];
+ },
+ cameraDir: [1, 1, 1]
+ }
+};
+
+/* ============================================================ */
+/* МАТЕРИАЛЫ */
+/* ============================================================ */
+
+const MAT = {
+ edge: { stroke:'#1e293b', width:1.8 },
+ edgeHidden:{ stroke:'#94a3b8', width:1.2, dash:'4 3' },
+ edgeHi: { stroke:'#dc2626', width:2.8 },
+ edgePar: { stroke:'#10b981', width:2.4 },
+ edgePerp: { stroke:'#7c3aed', width:2.4 },
+ face: { fill:'#dbeafe', opacity:0.35, stroke:'#1e3a8a', strokeWidth:1.4 },
+ plane: { fill:'#3b82f6', opacity:0.18, stroke:'#1e3a8a', strokeWidth:1.2, dash:'6 4' },
+ vertex: { fill:'#1e293b', r:3.5 }
+};
+
+/* ============================================================ */
+/* Scene */
+/* ============================================================ */
+
+class Scene {
+ constructor(W, H, opts){
+ opts = opts || {};
+ this.W = W;
+ this.H = H;
+ this.scale = opts.scale || 40;
+ this.center = opts.center || [W/2, H/2];
+ this.view = (typeof opts.view === 'string') ? (VIEWS[opts.view] || VIEWS.CABINET) : (opts.view || VIEWS.CABINET);
+ this.rotX = opts.rotX || 0;
+ this.rotY = opts.rotY || 0;
+ this.bg = opts.bg || '#fafafa';
+ this.border = (opts.border !== undefined) ? opts.border : '1px solid #e2e8f0';
+ this.radius = opts.radius || 10;
+ this.items = []; // отрисовка в порядке Z (заполняется в render)
+ this._id = opts.id || ('s3d-' + Math.random().toString(36).slice(2,7));
+ }
+
+ // Применить повороты + проекцию
+ project3(p){
+ let q = p;
+ if (this.rotX) q = rotX(q, this.rotX);
+ if (this.rotY) q = rotY(q, this.rotY);
+ const [px, py] = this.view.project(q);
+ return [this.center[0] + this.scale * px, this.center[1] + this.scale * py];
+ }
+
+ // То же, но возвращает все три величины (для сортировки по глубине)
+ project3Depth(p){
+ let q = p;
+ if (this.rotX) q = rotX(q, this.rotX);
+ if (this.rotY) q = rotY(q, this.rotY);
+ const [px, py] = this.view.project(q);
+ // глубина = проекция на направление камеры
+ const cam = this.view.cameraDir;
+ const depth = dot(q, cam);
+ return { x: this.center[0] + this.scale * px, y: this.center[1] + this.scale * py, depth };
+ }
+
+ /* === Добавление элементов === */
+
+ addEdge(p1, p2, opts){
+ this.items.push({ kind:'edge', p1, p2, opts: opts || {} });
+ return this;
+ }
+ addFace(points, opts){
+ this.items.push({ kind:'face', points, opts: opts || {} });
+ return this;
+ }
+ addVertex(p, label, opts){
+ this.items.push({ kind:'vertex', p, label, opts: opts || {} });
+ return this;
+ }
+ addLabel(label, p, opts){
+ this.items.push({ kind:'label', p, label, opts: opts || {} });
+ return this;
+ }
+ addArrow(p1, p2, opts){
+ this.items.push({ kind:'arrow', p1, p2, opts: opts || {} });
+ return this;
+ }
+ addPlane(point, normal, opts){
+ this.items.push({ kind:'plane', point, normal, opts: opts || {} });
+ return this;
+ }
+ addAngleMark(vertex, p1, p2, opts){
+ this.items.push({ kind:'angle', vertex, p1, p2, opts: opts || {} });
+ return this;
+ }
+ addRightAngleMark(vertex, p1, p2, opts){
+ this.items.push({ kind:'rightAngle', vertex, p1, p2, opts: opts || {} });
+ return this;
+ }
+ addDashedSegment(p1, p2, opts){
+ return this.addEdge(p1, p2, Object.assign({hidden:true}, opts||{}));
+ }
+
+ /* === Готовые тела === */
+
+ // Куб ABCDA1B1C1D1 — стандартное соглашение учебника
+ addCube(params){
+ params = params || {};
+ const c = params.center || [0,0,0];
+ const s = (params.size || 2) / 2;
+ const labels = params.labels !== false;
+ const labelMap = params.labelMap || ['A','B','C','D','A_1','B_1','C_1','D_1'];
+ // Нижняя грань (z = -s): A B C D против часовой при взгляде сверху
+ const A = add(c, [-s, -s, -s]);
+ const B = add(c, [+s, -s, -s]);
+ const C = add(c, [+s, +s, -s]);
+ const D = add(c, [-s, +s, -s]);
+ const A1 = add(c, [-s, -s, +s]);
+ const B1 = add(c, [+s, -s, +s]);
+ const C1 = add(c, [+s, +s, +s]);
+ const D1 = add(c, [-s, +s, +s]);
+ const verts = [A,B,C,D,A1,B1,C1,D1];
+ const faces = [
+ [0,1,2,3], // нижняя
+ [4,5,6,7], // верхняя
+ [0,1,5,4], // передняя
+ [1,2,6,5], // правая
+ [2,3,7,6], // задняя
+ [3,0,4,7] // левая
+ ];
+ const edges = [
+ [0,1],[1,2],[2,3],[3,0], // низ
+ [4,5],[5,6],[6,7],[7,4], // верх
+ [0,4],[1,5],[2,6],[3,7] // вертикали
+ ];
+ return this._addPolyhedron(verts, faces, edges,
+ labels ? labelMap.map((L,i)=>({label:L, point:verts[i]})) : [],
+ params);
+ }
+
+ // Параллелепипед с произвольными размерами
+ addBox(params){
+ params = params || {};
+ const c = params.center || [0,0,0];
+ const sz = params.size || [2,2,2];
+ const [ax, ay, az] = [sz[0]/2, sz[1]/2, sz[2]/2];
+ const labelMap = params.labelMap || ['A','B','C','D','A_1','B_1','C_1','D_1'];
+ const A = add(c, [-ax, -ay, -az]);
+ const B = add(c, [+ax, -ay, -az]);
+ const C = add(c, [+ax, +ay, -az]);
+ const D = add(c, [-ax, +ay, -az]);
+ const A1 = add(c, [-ax, -ay, +az]);
+ const B1 = add(c, [+ax, -ay, +az]);
+ const C1 = add(c, [+ax, +ay, +az]);
+ const D1 = add(c, [-ax, +ay, +az]);
+ const verts = [A,B,C,D,A1,B1,C1,D1];
+ const faces = [
+ [0,1,2,3],[4,5,6,7],[0,1,5,4],[1,2,6,5],[2,3,7,6],[3,0,4,7]
+ ];
+ const edges = [
+ [0,1],[1,2],[2,3],[3,0],
+ [4,5],[5,6],[6,7],[7,4],
+ [0,4],[1,5],[2,6],[3,7]
+ ];
+ return this._addPolyhedron(verts, faces, edges,
+ params.labels !== false ? labelMap.map((L,i)=>({label:L, point:verts[i]})) : [],
+ params);
+ }
+
+ // Тетраэдр (правильный или по 4 вершинам)
+ addTetrahedron(params){
+ params = params || {};
+ let verts;
+ if (params.vertices){
+ verts = params.vertices;
+ } else {
+ const c = params.center || [0,0,0];
+ const r = params.size || 1.6;
+ // правильный тетраэдр: вершины на сфере
+ verts = [
+ add(c, [ r, r, r]),
+ add(c, [ r, -r, -r]),
+ add(c, [-r, r, -r]),
+ add(c, [-r, -r, r])
+ ];
+ }
+ const labelMap = params.labelMap || ['A','B','C','D'];
+ const faces = [[0,1,2],[0,1,3],[0,2,3],[1,2,3]];
+ const edges = [[0,1],[0,2],[0,3],[1,2],[1,3],[2,3]];
+ return this._addPolyhedron(verts, faces, edges,
+ params.labels !== false ? labelMap.map((L,i)=>({label:L, point:verts[i]})) : [],
+ params);
+ }
+
+ // n-угольная призма (база — правильный n-угольник в плоскости xy)
+ addPrism(params){
+ params = params || {};
+ const n = params.n || 4;
+ const r = params.baseRadius || 1.4;
+ const h = params.height || 2;
+ const c = params.center || [0,0,0];
+ const bottom = [];
+ const top = [];
+ for (let i = 0; i < n; i++){
+ const a = 2 * Math.PI * i / n - Math.PI / 2;
+ bottom.push(add(c, [r * Math.cos(a), r * Math.sin(a), -h/2]));
+ top.push(add(c, [r * Math.cos(a), r * Math.sin(a), +h/2]));
+ }
+ const verts = bottom.concat(top);
+ const faces = [];
+ const bottomIdx = []; for (let i = 0; i < n; i++) bottomIdx.push(i);
+ const topIdx = []; for (let i = 0; i < n; i++) topIdx.push(i + n);
+ faces.push(bottomIdx);
+ faces.push(topIdx.slice().reverse());
+ for (let i = 0; i < n; i++){
+ const j = (i + 1) % n;
+ faces.push([i, j, j+n, i+n]);
+ }
+ const edges = [];
+ for (let i = 0; i < n; i++){
+ const j = (i + 1) % n;
+ edges.push([i, j]);
+ edges.push([i+n, j+n]);
+ edges.push([i, i+n]);
+ }
+ return this._addPolyhedron(verts, faces, edges, [], params);
+ }
+
+ // n-угольная пирамида
+ addPyramid(params){
+ params = params || {};
+ const n = params.n || 4;
+ const r = params.baseRadius || 1.4;
+ const h = params.height || 2;
+ const c = params.center || [0,0,0];
+ const base = [];
+ for (let i = 0; i < n; i++){
+ const a = 2 * Math.PI * i / n - Math.PI / 2;
+ base.push(add(c, [r * Math.cos(a), r * Math.sin(a), -h/2]));
+ }
+ const apex = add(c, [0, 0, h/2]);
+ const verts = base.concat([apex]);
+ const apexIdx = n;
+ const faces = [];
+ faces.push(base.map((_,i)=>i));
+ for (let i = 0; i < n; i++){
+ const j = (i + 1) % n;
+ faces.push([i, j, apexIdx]);
+ }
+ const edges = [];
+ for (let i = 0; i < n; i++){
+ const j = (i + 1) % n;
+ edges.push([i, j]);
+ edges.push([i, apexIdx]);
+ }
+ return this._addPolyhedron(verts, faces, edges, [], params);
+ }
+
+ // Конус (приближение многогранником)
+ addCone(params){
+ params = params || {};
+ const n = params.segments || 24;
+ return this.addPyramid(Object.assign({n}, params));
+ }
+
+ // Цилиндр (приближение призмой)
+ addCylinder(params){
+ params = params || {};
+ const n = params.segments || 24;
+ return this.addPrism(Object.assign({n}, params));
+ }
+
+ // Сфера (упрощённо — окружность по силуэту)
+ addSphere(params){
+ params = params || {};
+ const c = params.center || [0,0,0];
+ const r = params.radius || 1.4;
+ this.items.push({ kind:'sphere', center:c, radius:r, opts:params });
+ return this;
+ }
+
+ // Общий конструктор многогранника
+ _addPolyhedron(verts, faces, edges, vertLabels, params){
+ params = params || {};
+ const color = params.color || '#dbeafe';
+ const opacity = (params.opacity !== undefined) ? params.opacity : 0.35;
+ const showFaces = params.showFaces !== false;
+ const showHidden = params.showHidden !== false;
+ this.items.push({
+ kind:'polyhedron',
+ verts, faces, edges, vertLabels,
+ color, opacity, showFaces, showHidden,
+ hiOptsByEdge: params.highlightEdges || null,
+ hiOptsByFace: params.highlightFaces || null,
+ labels: vertLabels,
+ labelStyle: params.labelStyle || {}
+ });
+ return this;
+ }
+
+ /* === Рендер === */
+
+ render(){
+ const W = this.W, H = this.H;
+ const open = '';
+ }
+
+ _defs(){
+ return ''
+ + ''
+ + ''
+ + ''
+ + '';
+ }
+
+ _polyhedronToFlat(it, flat){
+ const proj = it.verts.map(p => this.project3Depth(p));
+ // Для каждой грани вычисляем нормаль в 3D и определяем — обращена к камере или нет.
+ const cam = this.view.cameraDir;
+ const faceData = it.faces.map((idxs)=>{
+ const p0 = it.verts[idxs[0]], p1 = it.verts[idxs[1]], p2 = it.verts[idxs[2]];
+ // нормаль через cross product. Для выпуклых тел нужно учесть центр тела.
+ const n = norm3(cross(sub(p1, p0), sub(p2, p0)));
+ // центр грани
+ let cx=0, cy=0, cz=0;
+ for (const i of idxs){ cx+=it.verts[i][0]; cy+=it.verts[i][1]; cz+=it.verts[i][2]; }
+ const ctr = [cx/idxs.length, cy/idxs.length, cz/idxs.length];
+ // если нормаль смотрит ВНУТРЬ тела (к началу), инвертируем
+ // (примерное предположение что центр тела — среднее всех вершин)
+ let bx=0, by=0, bz=0;
+ for (const v of it.verts){ bx+=v[0]; by+=v[1]; bz+=v[2]; }
+ const body = [bx/it.verts.length, by/it.verts.length, bz/it.verts.length];
+ const outward = sub(ctr, body);
+ if (dot(n, outward) < 0){ n[0]=-n[0]; n[1]=-n[1]; n[2]=-n[2]; }
+ // повернуть нормаль теми же поворотами что и точки
+ let rn = n;
+ if (this.rotX) rn = rotX(rn, this.rotX);
+ if (this.rotY) rn = rotY(rn, this.rotY);
+ const visible = dot(rn, cam) > 0;
+ const meanDepth = idxs.reduce((s,i)=>s+proj[i].depth, 0)/idxs.length;
+ return { idxs, visible, meanDepth, n: rn };
+ });
+
+ // Какие рёбра видимые (хоть одна смежная грань видима)
+ const edgeFaceMap = new Map();
+ function edgeKey(a, b){ return Math.min(a,b)+'-'+Math.max(a,b); }
+ for (let fi = 0; fi < it.faces.length; fi++){
+ const fc = it.faces[fi];
+ for (let i = 0; i < fc.length; i++){
+ const k = edgeKey(fc[i], fc[(i+1)%fc.length]);
+ if (!edgeFaceMap.has(k)) edgeFaceMap.set(k, []);
+ edgeFaceMap.get(k).push(fi);
+ }
+ }
+ const edgeVisible = new Map();
+ for (const [k, fis] of edgeFaceMap){
+ const vis = fis.some(fi => faceData[fi].visible);
+ edgeVisible.set(k, vis);
+ }
+
+ // 1) задние грани (полупрозрачные) — если showFaces
+ if (it.showFaces){
+ for (const fd of faceData){
+ if (!fd.visible){
+ const pts = fd.idxs.map(i => proj[i]);
+ flat.push({ pass:2, depth:fd.meanDepth, svg: this._faceSvg(pts, { fill: it.color, opacity: it.opacity * 0.5, stroke:'none' }) });
+ }
+ }
+ }
+
+ // 2) невидимые рёбра — пунктир
+ if (it.showHidden){
+ for (const e of it.edges){
+ const k = edgeKey(e[0], e[1]);
+ if (!edgeVisible.get(k)){
+ const a = proj[e[0]], b = proj[e[1]];
+ flat.push({ pass:1, depth:(a.depth+b.depth)/2, svg: this._edgeSvg(a, b, { hidden:true }) });
+ }
+ }
+ }
+
+ // 3) передние грани (полупрозрачные) — поверх задних рёбер
+ if (it.showFaces){
+ for (const fd of faceData){
+ if (fd.visible){
+ const pts = fd.idxs.map(i => proj[i]);
+ flat.push({ pass:3, depth:fd.meanDepth, svg: this._faceSvg(pts, { fill: it.color, opacity: it.opacity, stroke:'none' }) });
+ }
+ }
+ }
+
+ // 4) видимые рёбра — сплошные
+ for (const e of it.edges){
+ const k = edgeKey(e[0], e[1]);
+ if (edgeVisible.get(k)){
+ const a = proj[e[0]], b = proj[e[1]];
+ let style = {};
+ if (it.hiOptsByEdge){
+ for (const h of it.hiOptsByEdge){
+ if ((h.edge[0] === e[0] && h.edge[1] === e[1]) || (h.edge[0] === e[1] && h.edge[1] === e[0])){
+ style = h; break;
+ }
+ }
+ }
+ flat.push({ pass:4, depth:(a.depth+b.depth)/2, svg: this._edgeSvg(a, b, style) });
+ }
+ }
+
+ // 5) подписи вершин
+ if (it.labels && it.labels.length){
+ for (let vi = 0; vi < it.labels.length; vi++){
+ const lab = it.labels[vi];
+ const proj1 = proj[vi];
+ flat.push({ pass:6, depth:proj1.depth, svg: this._vertexLabelSvg(proj1, lab.label, it.labelStyle) });
+ }
+ }
+ }
+
+ /* === SVG примитивы === */
+
+ _edgeSvg(a, b, opts){
+ opts = opts || {};
+ let stroke, width, dash;
+ if (opts.hidden){ stroke = opts.stroke || MAT.edgeHidden.stroke; width = opts.width || MAT.edgeHidden.width; dash = opts.dash || MAT.edgeHidden.dash; }
+ else if (opts.highlight === 'par'){ stroke = MAT.edgePar.stroke; width = MAT.edgePar.width; }
+ else if (opts.highlight === 'perp'){ stroke = MAT.edgePerp.stroke; width = MAT.edgePerp.width; }
+ else if (opts.highlight){ stroke = MAT.edgeHi.stroke; width = MAT.edgeHi.width; }
+ else { stroke = opts.stroke || MAT.edge.stroke; width = opts.width || MAT.edge.width; dash = opts.dash; }
+ let s = '';
+ return s;
+ }
+
+ _faceSvg(pts, opts){
+ opts = opts || {};
+ const d = pts.map(p => p.x.toFixed(2)+','+p.y.toFixed(2)).join(' ');
+ const fill = opts.fill || MAT.face.fill;
+ const op = (opts.opacity !== undefined) ? opts.opacity : MAT.face.opacity;
+ const stroke = opts.stroke || 'none';
+ const sw = opts.strokeWidth || MAT.face.strokeWidth;
+ return '';
+ }
+
+ _vertexSvg(p, label, opts){
+ opts = opts || {};
+ const r = opts.r || MAT.vertex.r;
+ const fill = opts.fill || MAT.vertex.fill;
+ let s = '';
+ if (label) s += this._vertexLabelSvg(p, label, opts);
+ return s;
+ }
+
+ _vertexLabelSvg(p, label, opts){
+ opts = opts || {};
+ const dx = (opts.dx !== undefined) ? opts.dx : 8;
+ const dy = (opts.dy !== undefined) ? opts.dy : -6;
+ const fs = opts.fontSize || 14;
+ const fill = opts.color || '#1e293b';
+ // если в label есть _, форматируем подстрочный индекс
+ const html = formatLabel(label);
+ return ''+html+'';
+ }
+
+ _labelSvg(p, label, opts){
+ opts = opts || {};
+ const dx = (opts.dx !== undefined) ? opts.dx : 0;
+ const dy = (opts.dy !== undefined) ? opts.dy : 0;
+ const fs = opts.fontSize || 13;
+ const fill = opts.color || '#475569';
+ const html = formatLabel(label);
+ const anchor = opts.anchor || 'middle';
+ return ''+html+'';
+ }
+
+ _arrowSvg(a, b, opts){
+ opts = opts || {};
+ const stroke = opts.color || '#1e293b';
+ const w = opts.width || 2;
+ let s = '';
+ if (opts.label){
+ const mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
+ s += ''+formatLabel(opts.label)+'';
+ }
+ return s;
+ }
+
+ _planeSvg(point, normal, opts){
+ opts = opts || {};
+ const sz = opts.size || 3;
+ const fill = opts.fill || MAT.plane.fill;
+ const op = (opts.opacity !== undefined) ? opts.opacity : MAT.plane.opacity;
+ // строим два ортогональных вектора в плоскости нормали
+ const n = norm3(normal);
+ let u;
+ if (Math.abs(n[0]) < 0.9) u = norm3(cross(n, [1,0,0]));
+ else u = norm3(cross(n, [0,1,0]));
+ const w = norm3(cross(n, u));
+ const corners = [
+ add(point, add(scale3(u, +sz), scale3(w, +sz))),
+ add(point, add(scale3(u, +sz), scale3(w, -sz))),
+ add(point, add(scale3(u, -sz), scale3(w, -sz))),
+ add(point, add(scale3(u, -sz), scale3(w, +sz)))
+ ].map(p => this.project3(p));
+ const d = corners.map(p => p[0].toFixed(2)+','+p[1].toFixed(2)).join(' ');
+ let s = '';
+ if (opts.label){
+ const c = this.project3(point);
+ s += ''+formatLabel(opts.label)+'';
+ }
+ return s;
+ }
+
+ _angleMarkSvg(vertex, p1, p2, opts){
+ opts = opts || {};
+ const r3 = opts.r || 0.35;
+ const u = norm3(sub(p1, vertex));
+ const v = norm3(sub(p2, vertex));
+ // строим дугу в 3D через интерполяцию по углу
+ const steps = 12;
+ const dotUV = Math.max(-1, Math.min(1, dot(u, v)));
+ const ang = Math.acos(dotUV);
+ const w = norm3(sub(v, scale3(u, dotUV)));
+ const pts = [];
+ for (let i = 0; i <= steps; i++){
+ const t = ang * i / steps;
+ const dir = add(scale3(u, Math.cos(t)), scale3(w, Math.sin(t)));
+ pts.push(this.project3(add(vertex, scale3(dir, r3))));
+ }
+ const d = 'M ' + pts.map(p => p[0].toFixed(2)+','+p[1].toFixed(2)).join(' L ');
+ let s = '';
+ if (opts.label){
+ const mid = pts[Math.floor(pts.length/2)];
+ s += ''+formatLabel(opts.label)+'';
+ }
+ return s;
+ }
+
+ _rightAngleMarkSvg(vertex, p1, p2, opts){
+ opts = opts || {};
+ const sz = opts.size || 0.2;
+ const u = norm3(sub(p1, vertex));
+ const v = norm3(sub(p2, vertex));
+ const a = add(vertex, scale3(u, sz));
+ const b = add(vertex, scale3(add(u, v), sz));
+ const c = add(vertex, scale3(v, sz));
+ const A = this.project3(a), B = this.project3(b), C = this.project3(c);
+ return '';
+ }
+}
+
+/* === Форматирование подписей: A_1, B_1, alpha -> греческие, и т.д. === */
+function formatLabel(s){
+ if (s == null) return '';
+ let out = String(s);
+ // греческие
+ out = out.replace(/\balpha\b/g, 'α')
+ .replace(/\bbeta\b/g, 'β')
+ .replace(/\bgamma\b/g, 'γ')
+ .replace(/\bphi\b/g, 'φ')
+ .replace(/\btheta\b/g, 'θ')
+ .replace(/\bpi\b/g, 'π');
+ // одиночные индексы _1, _2 — превращаем в
+ out = out.replace(/_(\d)/g, '$1');
+ // _{abc} — тоже
+ out = out.replace(/_\{([^}]+)\}/g, '$1');
+ return out;
+}
+
+S.Scene = Scene;
+S.formatLabel = formatLabel;
+
+})();
diff --git a/frontend/textbooks/geometry_10_hub.html b/frontend/textbooks/geometry_10_hub.html
new file mode 100644
index 0000000..42389e7
--- /dev/null
+++ b/frontend/textbooks/geometry_10_hub.html
@@ -0,0 +1,370 @@
+
+
+
+
+
+
+
+
+Геометрия 10 класс — учебник
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Геометрия — 10 класс
+
Стереометрия · 3D-фигуры · Координаты и векторы
+
+
+
+
+
+
+
+
+ △
+
+
Общий прогресс по курсу
+
Загрузка...
+
+
+ 0 XP
+
+
+
+
+
+
+
+
+
△
+
Раздел 1
+
Введение в стереометрию
+
§1–§3 + Финал
+
+
+
Пространственные фигуры (призма, пирамида, цилиндр, конус, шар), аксиомы стереометрии и их следствия, метод сечений многогранников.
+
+
+
+
+
+
+
+
∥
+
Раздел 2
+
Параллельность
+
§4–§6 + Финал
+
+
+
Взаимное расположение прямых в пространстве (скрещивающиеся), прямой и плоскости, двух плоскостей. Признаки параллельности.
+
+
+
+
+
+
+
+
⊥
+
Раздел 3
+
Перпендикулярность
+
§7–§10 + Финал
+
+
+
Перпендикулярность прямой и плоскости, расстояния в пространстве, угол между прямой и плоскостью (теорема о трёх перпендикулярах), двугранный угол.
+
+
+
+
+
+
+
+
→
+
Раздел 4
+
Координаты и векторы
+
§11–§14 + Финал
+
+
+
Прямоугольная система координат в пространстве, векторы и действия над ними, скалярное произведение, применение векторно-координатного метода.
+
+
+
+
+
+
+
+
+
+
+
Магистр геометрии 10
+
Прочитайте все 14 параграфов четырёх разделов, чтобы получить достижение
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/textbooks/geometry_10_r1.html b/frontend/textbooks/geometry_10_r1.html
new file mode 100644
index 0000000..7544f98
--- /dev/null
+++ b/frontend/textbooks/geometry_10_r1.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+Геометрия 10 · Введение в стереометрию
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Раздел 1. Введение в стереометрию
+
Пространственные фигуры · Аксиомы · Сечения · §1–§3 + Финал
+
+
+
+
+
+
+
+
+
Раздел 1
+
Введение в стереометрию
+
Пространственные фигуры · Аксиомы · Сечения. Раздел содержит 3 параграфа и финальный этап с боссами.
+
+
+
+
+
+ § 1
+
+
Пространственные фигуры
+
Призма, пирамида, цилиндр, конус, шар. Грани, рёбра, вершины.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 2
+
+
Прямые и плоскости
+
Аксиомы стереометрии (A1–A3) и их следствия. 4 способа задания плоскости.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 3
+
+
Построения сечений
+
Метод следов. Сечения куба, призмы, пирамиды.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+
+ Раздел в разработке. Полная реализация — в следующих волнах. Уже доступна 3D-библиотека stereo3d.js.
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/textbooks/geometry_10_r2.html b/frontend/textbooks/geometry_10_r2.html
new file mode 100644
index 0000000..5172521
--- /dev/null
+++ b/frontend/textbooks/geometry_10_r2.html
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+Геометрия 10 · Параллельность
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Раздел 2. Параллельность
+
Прямые · Прямая и плоскость · Плоскости · §4–§6 + Финал
+
+
+
+
+
+
+
+
+
Раздел 2
+
Параллельность
+
Прямые · Прямая и плоскость · Плоскости. Раздел содержит 3 параграфа и финальный этап с боссами.
+
+
+
+
+
+ § 4
+
+
Расположение прямых в пространстве
+
Пересекающиеся, параллельные, скрещивающиеся прямые. Угол между скрещивающимися.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 5
+
+
Прямая и плоскость
+
Три случая. Признак параллельности прямой и плоскости.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 6
+
+
Две плоскости
+
Пересекаются или параллельны. Признак параллельности плоскостей.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+
+ Раздел в разработке. Полная реализация — в следующих волнах. Уже доступна 3D-библиотека stereo3d.js.
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/textbooks/geometry_10_r3.html b/frontend/textbooks/geometry_10_r3.html
new file mode 100644
index 0000000..6036e0d
--- /dev/null
+++ b/frontend/textbooks/geometry_10_r3.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+Геометрия 10 · Перпендикулярность
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Раздел 3. Перпендикулярность
+
Прямая ⊥ плоскость · Расстояния · Углы · Двугранный угол · §7–§10 + Финал
+
+
+
+
+
+
+
+
+
Раздел 3
+
Перпендикулярность
+
Прямая ⊥ плоскость · Расстояния · Углы · Двугранный угол. Раздел содержит 4 параграфа и финальный этап с боссами.
+
+
+
+
+
+ § 7
+
+
Перпендикулярность прямой и плоскости
+
Определение, признак перпендикулярности.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 8
+
+
Расстояния
+
От точки до плоскости, между параллельными плоскостями, между скрещивающимися.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 9
+
+
Угол между прямой и плоскостью
+
Наклонная и её проекция. Теорема о трёх перпендикулярах.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 10
+
+
Перпендикулярность плоскостей
+
Двугранный угол. Признак перпендикулярности плоскостей.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+
+ Раздел в разработке. Полная реализация — в следующих волнах. Уже доступна 3D-библиотека stereo3d.js.
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/textbooks/geometry_10_r4.html b/frontend/textbooks/geometry_10_r4.html
new file mode 100644
index 0000000..47984fa
--- /dev/null
+++ b/frontend/textbooks/geometry_10_r4.html
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+Геометрия 10 · Координаты и векторы
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Раздел 4. Координаты и векторы
+
ПДСК в пространстве · Векторы · Скалярное произведение · §11–§14 + Финал
+
+
+
+
+
+
+
+
+
Раздел 4
+
Координаты и векторы
+
ПДСК в пространстве · Векторы · Скалярное произведение. Раздел содержит 4 параграфа и финальный этап с боссами.
+
+
+
+
+
+ § 11
+
+
Координаты в пространстве
+
ПДСК. Точка (x; y; z). Расстояние между точками.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 12
+
+
Вектор. Действия над векторами
+
Сложение, умножение на число, базис i, j, k. Разложение вектора.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 13
+
+
Скалярное произведение
+
a · b = |a||b|cos α = x₁x₂ + y₁y₂ + z₁z₂. Условие перпендикулярности.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+ § 14
+
+
Применение координат и векторов
+
Уравнения, углы, расстояния. Векторно-координатный метод.
+
+
+ Будет добавлено в следующей волне реализации
+
+
+
+
+
+
+ Раздел в разработке. Полная реализация — в следующих волнах. Уже доступна 3D-библиотека stereo3d.js.
+
+
+
+
+
+
+
+
+
+