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