From b3ea35049fbeed8687a1c87e2e8ec06e7b8394cd Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 21:45:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(stereo3d):=20drag-to-rotate=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=203D-=D1=81=D1=86=D0=B5=D0=BD=20=D0=93=D0=B5=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D0=B8=D0=B8=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STEREO3D.attachDragRotate(target, scene, onChange?) — мутирует scene.rotX/rotY на mouse/touch drag, по умолчанию пересобирает innerHTML контейнера через scene.render(). Применено к аннотированному кубу §1 (viz1-cube) в geometry_10_r1.html. Остальные сцены не затронуты. --- frontend/js/stereo3d.js | 70 ++++++++++++++++++++++++++ frontend/textbooks/geometry_10_r1.html | 9 ++-- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/frontend/js/stereo3d.js b/frontend/js/stereo3d.js index 89373e9..f562679 100644 --- a/frontend/js/stereo3d.js +++ b/frontend/js/stereo3d.js @@ -714,4 +714,74 @@ function formatLabel(s){ 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); + }; +}; + })(); diff --git a/frontend/textbooks/geometry_10_r1.html b/frontend/textbooks/geometry_10_r1.html index a4fef95..f0038e0 100644 --- a/frontend/textbooks/geometry_10_r1.html +++ b/frontend/textbooks/geometry_10_r1.html @@ -245,7 +245,7 @@ const PARAS = [ { id:'p1', num:'§ 1', name:'Пространственные фигуры', sub:'Призма · пирамида · цилиндр · конус · шар' }, { id:'p2', num:'§ 2', name:'Прямые и плоскости', sub:'3 аксиомы стереометрии + следствия' }, { id:'p3', num:'§ 3', name:'Построения сечений', sub:'Метод следов · max 6-угольник у куба' }, - { id:'final', num:'★', name:'Финал раздела', sub:'4 интегрированных босса', final:true } + { id:'final', num:'★', name:'Финал раздела', sub:'4 интегрированных босса', final:true } ]; PARAS.forEach(p => { STATE.progress[p.id] = 0; }); @@ -579,7 +579,7 @@ function buildP1(){ html += makeCard('rule', 'Формула Эйлера', '§ 1.3', '

Для любого выпуклого многогранника:

' + '

$$В - Р + Г = 2$$

' - + '

Куб: $8 - 12 + 6 = 2$ ✓. Тетраэдр: $4 - 6 + 4 = 2$ ✓.

' + + '

Куб: $8 - 12 + 6 = 2$ ✓. Тетраэдр: $4 - 6 + 4 = 2$ ✓.

' + '
Свойства основных тел
' + '
    ' + '
  • $n$-угольная призма: $В=2n$, $Р=3n$, $Г=n+2$.
  • ' @@ -948,7 +948,10 @@ function buildAnnotatedCube(){ sc.addCube({center:[0,0,0], size:2.0, labels:true}); sc.addEdge([-1,-1,-1],[1,1,1], {stroke:'#dc2626', width:3, dash:'5 3'}); sc.addFace([[-1,-1,-1],[1,-1,-1],[1,-1,1],[-1,-1,1]], {fill:'#a78bfa', opacity:0.30, stroke:'none'}); - const el = document.getElementById('viz1-cube'); if(el) el.innerHTML = sc.render(); + const el = document.getElementById('viz1-cube'); + if(!el) return; + el.innerHTML = sc.render(); + if (S.attachDragRotate && !el.__rotBound){ S.attachDragRotate(el, sc); el.__rotBound = true; } } function buildPrismDirect(){