/* 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 = ''; const defs = this._defs(); // Собираем «плоские» элементы для отрисовки: faces (back), edges (hidden), faces (front), edges (visible), verts, labels. const out = []; // Сначала — обрабатываем все объекты в two passes: // pass 1: задние грани + невидимые рёбра // pass 2: передние грани + видимые рёбра // pass 3: вершины // pass 4: подписи + декораторы (углы, стрелки) const flat = []; // {type, depth, svg} for (const it of this.items){ if (it.kind === 'polyhedron'){ this._polyhedronToFlat(it, flat); } else if (it.kind === 'edge'){ const a = this.project3Depth(it.p1); const b = this.project3Depth(it.p2); const mid = (a.depth + b.depth) / 2; flat.push({ pass: it.opts.hidden ? 1 : 4, depth: mid, svg: this._edgeSvg(a, b, it.opts) }); } else if (it.kind === 'face'){ const pts = it.points.map(p => this.project3Depth(p)); const mid = pts.reduce((s,p)=>s+p.depth, 0)/pts.length; flat.push({ pass: 2, depth: mid, svg: this._faceSvg(pts, it.opts) }); } else if (it.kind === 'vertex'){ const a = this.project3Depth(it.p); flat.push({ pass: 5, depth: a.depth, svg: this._vertexSvg(a, it.label, it.opts) }); } else if (it.kind === 'label'){ const a = this.project3Depth(it.p); flat.push({ pass: 6, depth: a.depth, svg: this._labelSvg(a, it.label, it.opts) }); } else if (it.kind === 'arrow'){ const a = this.project3Depth(it.p1); const b = this.project3Depth(it.p2); flat.push({ pass: 4, depth: (a.depth+b.depth)/2, svg: this._arrowSvg(a, b, it.opts) }); } else if (it.kind === 'plane'){ flat.push({ pass: 2, depth: dot(it.point, this.view.cameraDir), svg: this._planeSvg(it.point, it.normal, it.opts) }); } else if (it.kind === 'angle'){ const v = this.project3Depth(it.vertex); flat.push({ pass: 6, depth: v.depth, svg: this._angleMarkSvg(it.vertex, it.p1, it.p2, it.opts) }); } else if (it.kind === 'rightAngle'){ const v = this.project3Depth(it.vertex); flat.push({ pass: 6, depth: v.depth, svg: this._rightAngleMarkSvg(it.vertex, it.p1, it.p2, it.opts) }); } else if (it.kind === 'sphere'){ const c = this.project3Depth(it.center); const r = this.scale * it.radius; flat.push({ pass: 2, depth: c.depth, svg: '' + '' }); } } // Сортируем по pass, потом по depth (меньшая глубина = дальше). flat.sort((a,b)=> (a.pass - b.pass) || (a.depth - b.depth)); let body = ''; for (const f of flat) body += f.svg; return open + defs + body + ''; } _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 = ' 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 = ''+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 = ''+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; /* ============================================================ */ /* Drag-to-rotate */ /* ============================================================ */ /* * STEREO3D.attachDragRotate(target, scene, onChange?) * * target — SVGElement или контейнер-HTMLElement, на который повешен * результат scene.render(). * scene — экземпляр STEREO3D.Scene (мы мутируем scene.rotX / scene.rotY). * onChange — необязательный callback после изменения углов. * Если не передан и target не SVG — по умолчанию * пересобираем содержимое контейнера через scene.render(). * * Возвращает функцию detach() — для удаления слушателей. */ S.attachDragRotate = function attachDragRotate(target, scene, onChange){ if (!target || !scene) return function(){}; // По умолчанию для контейнера-HTMLElement делаем re-render через innerHTML. const isSvg = (target.tagName && String(target.tagName).toLowerCase() === 'svg'); const defaultOnChange = isSvg ? null : function(){ target.innerHTML = scene.render(); }; const cb = onChange || defaultOnChange; let dragging = false, lastX = 0, lastY = 0; try { target.style.touchAction = 'none'; } catch(_){} try { target.style.cursor = 'grab'; } catch(_){} function onDown(e){ dragging = true; try { target.style.cursor = 'grabbing'; } catch(_){} const p = (e.touches && e.touches[0]) ? e.touches[0] : e; lastX = p.clientX; lastY = p.clientY; if (e.preventDefault) e.preventDefault(); } function onMove(e){ if (!dragging) return; const p = (e.touches && e.touches[0]) ? e.touches[0] : e; const dx = p.clientX - lastX, dy = p.clientY - lastY; scene.rotY = (scene.rotY || 0) + dx * 0.012; scene.rotX = (scene.rotX || 0) + dy * 0.012; if (scene.rotX > 1.4) scene.rotX = 1.4; if (scene.rotX < -1.4) scene.rotX = -1.4; lastX = p.clientX; lastY = p.clientY; if (cb) cb(scene); if (e.preventDefault) e.preventDefault(); } function onUp(){ if (!dragging) return; dragging = false; try { target.style.cursor = 'grab'; } catch(_){} } target.addEventListener('mousedown', onDown, { passive: false }); target.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); window.addEventListener('touchcancel',onUp); return function detach(){ target.removeEventListener('mousedown', onDown); target.removeEventListener('touchstart', onDown); window.removeEventListener('mousemove', onMove); window.removeEventListener('touchmove', onMove); window.removeEventListener('mouseup', onUp); window.removeEventListener('touchend', onUp); window.removeEventListener('touchcancel',onUp); }; }; })();