diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index 45bfcf9..071de87 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -70,6 +70,21 @@ function gIncircle(A, B, C) { return { cx, cy, r }; } +/** Точки касания двух касательных из внешней точки P к окружности (O, r) */ +function gTangentPoints(O, P, r) { + const d = gDist(O, P); + if (d <= r + 1e-9) return null; // P внутри или на окружности + const t = (r * r) / d; // |OM|, M = нога с O на хорду касания + const h = r * Math.sqrt(d * d - r * r) / d; // |T₁M| = |T₂M| + const vx = (P.x - O.x) / d, vy = (P.y - O.y) / d; // O→P + const px = -vy, py = vx; // перпендикуляр + const M = { x: O.x + vx * t, y: O.y + vy * t }; + return [ + { x: M.x + px * h, y: M.y + py * h }, + { x: M.x - px * h, y: M.y - py * h }, + ]; +} + /** Угол ABC (в градусах, вершина B) */ function gAngleDeg(A, B, C) { const v1 = gSub(A, B), v2 = gSub(C, B); @@ -177,6 +192,7 @@ class GeoEngine { return !!(sl && (sl.p1Id === id || sl.p2Id === id)); } if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id; + if (obj.constr === 'translate') return obj.srcA === id || obj.srcB === id || obj.srcPt === id; return false; case 'derived_line': switch (obj.constr) { @@ -184,6 +200,14 @@ class GeoEngine { case 'anglebisect': return obj.srcA === id || obj.srcVtx === id || obj.srcB === id; case 'parallel': case 'perpendicular': return obj.srcPt === id || obj.srcDirPt1 === id || obj.srcDirPt2 === id; + case 'tangent': { + if (obj.srcPt === id || obj.srcCircle === id) return true; + // Зависим и от точек, определяющих окружность + const circ = this._objects.get(obj.srcCircle); + if (!circ) return false; + if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id; + return circ.centerId === id || circ.edgeId === id; + } } return false; } @@ -238,6 +262,12 @@ class GeoEngine { const cos_a = Math.cos(angle), sin_a = Math.sin(angle); obj.x = center.x + dx*cos_a - dy*sin_a; obj.y = center.y + dx*sin_a + dy*cos_a; + } else if (obj.constr === 'translate') { + const pA = _g(obj.srcA), pB = _g(obj.srcB), pP = _g(obj.srcPt); + if (pA && pB && pP) { + obj.x = pP.x + (pB.x - pA.x); + obj.y = pP.y + (pB.y - pA.y); + } } } else if (obj.type === 'circle' && obj.derived) { const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC); @@ -281,6 +311,26 @@ class GeoEngine { if (len < 1e-12) return; obj.ptX = srcPt.x; obj.ptY = srcPt.y; obj.dirX = -dy/len; obj.dirY = dx/len; + } else if (obj.constr === 'tangent') { + const circ = _g(obj.srcCircle), pt = _g(obj.srcPt); + if (!circ || !pt) return; + let O, r; + if (circ.derived && circ.cx != null) { + O = { x: circ.cx, y: circ.cy }; r = circ.r; + } else { + const ctr = _g(circ.centerId), edg = _g(circ.edgeId); + if (!ctr || !edg) return; + O = { x: ctr.x, y: ctr.y }; r = gDist(O, { x: edg.x, y: edg.y }); + } + const P = { x: pt.x, y: pt.y }; + const tpts = gTangentPoints(O, P, r); + if (!tpts) { obj.valid = false; return; } + const T = tpts[obj.which]; + const dx = T.x - P.x, dy = T.y - P.y, len = Math.hypot(dx, dy); + if (len < 1e-12) { obj.valid = false; return; } + obj.ptX = P.x; obj.ptY = P.y; + obj.dirX = dx/len; obj.dirY = dy/len; + obj.valid = true; } } } @@ -353,7 +403,8 @@ class GeoSim { this.tool = 'select'; this._pending = []; // промежуточные клики многошаговых инструментов this._preview = null; // предпросмотр (курсор при рисовании) - this._pendingLineRef = null; // первый кликнутый линейный объект для parallel/perp/intersect + this._pendingLineRef = null; // первый кликнутый объект для parallel/perp/intersect/reflect/foot + this._pendingCircRef = null; // первый кликнутый объект-окружность для tangent /* ── Состояние drag/pan ── */ this._drag = null; // { id, offX, offY } — перетаскиваем точку @@ -409,6 +460,7 @@ class GeoSim { this._preview = null; this._selected = null; this._pendingLineRef = null; + this._pendingCircRef = null; this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair'; this.render(); } @@ -461,6 +513,7 @@ class GeoSim { this._drawPreview(ctx); // Подсветка первого объекта при инструментах построения if (this._pendingLineRef) this._drawLineRefHighlight(ctx, this._pendingLineRef); + if (this._pendingCircRef) this._drawLineRefHighlight(ctx, this._pendingCircRef); // Индикатор снапа if (this._snapPt) this._drawSnapIndicator(ctx); } @@ -718,26 +771,57 @@ class GeoSim { } } - /* Подсветить линейный объект (первый клик в parallel/perpendicular/intersect) */ + /* Подсветить объект (первый клик в parallel/perpendicular/intersect/tangent/...) */ _drawLineRefHighlight(ctx, obj) { if (!obj) return; - let p1c, p2c; - if (obj.type === 'derived_line') { - const m1 = { x:obj.ptX, y:obj.ptY }, m2 = { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }; - [p1c, p2c] = this._extendToEdges(m1, m2); - } else { - const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id); - if (!m1 || !m2) return; - if (obj.type === 'segment') { p1c = this.vp.toCanvas(m1.x,m1.y); p2c = this.vp.toCanvas(m2.x,m2.y); } - else [p1c, p2c] = this._extendToEdges(m1, m2); - } ctx.save(); ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.globalAlpha = 0.55; ctx.setLineDash([6, 4]); - ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke(); + if (obj.type === 'circle') { + // Подсветка окружности (для инструмента tangent) + let cx, cy, r; + if (obj.derived && obj.cx != null) { + const c = this.vp.toCanvas(obj.cx, obj.cy); + cx = c.x; cy = c.y; r = this.vp.toCanvasDist(obj.r); + } else { + const c = this._p(obj.centerId), e = this._p(obj.edgeId); + if (!c || !e) { ctx.restore(); return; } + cx = c.x; cy = c.y; r = gDist(c, e); + } + ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.stroke(); + } else { + let p1c, p2c; + if (obj.type === 'derived_line') { + const m1 = { x:obj.ptX, y:obj.ptY }, m2 = { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }; + [p1c, p2c] = this._extendToEdges(m1, m2); + } else { + const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id); + if (!m1 || !m2) { ctx.restore(); return; } + if (obj.type === 'segment') { p1c = this.vp.toCanvas(m1.x,m1.y); p2c = this.vp.toCanvas(m2.x,m2.y); } + else [p1c, p2c] = this._extendToEdges(m1, m2); + } + ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke(); + } ctx.restore(); } + /* Найти окружность под курсором (для инструмента tangent) */ + _hitTestCircle(px, py) { + const HIT = 12, m = this.vp.toMath(px, py); + for (const obj of this.eng.byType('circle')) { + let O, r; + if (obj.derived && obj.cx != null) { + O = { x: obj.cx, y: obj.cy }; r = obj.r; + } else { + const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId); + if (!mc || !me) continue; + O = mc; r = gDist(mc, me); + } + if (Math.abs(gDist(m, O) - r) * this.vp.scale < HIT) return obj; + } + return null; + } + /* Вернуть две мат. точки на объекте (line/segment/ray/derived_line) — для хит-теста и пересечений */ _twoPointsOnObj(obj) { if (!obj) return null; @@ -1593,6 +1677,80 @@ class GeoSim { } break; } + + /* ══ Phase 5: tangent, translate ══ */ + + case 'tangent': { + if (!this._pendingCircRef) { + // Первый клик: выбрать окружность + const hit = this._hitTestCircle(px, py); + if (hit) { + this._pendingCircRef = hit; + if (this.onHintChange) this.onHintChange('tangent', 2); + } + } else { + // Второй клик: внешняя точка → создать 2 касательные + this._pushUndo(); + const extPt = this._ensurePoint(snapped); + const circ = this._pendingCircRef; + let O, r; + if (circ.derived && circ.cx != null) { + O = { x: circ.cx, y: circ.cy }; r = circ.r; + } else { + const mc = this._mpt(circ.centerId), me = this._mpt(circ.edgeId); + O = mc; r = mc && me ? gDist(mc, me) : 0; + } + if (O && r > 0) { + const tpts = gTangentPoints(O, { x: extPt.x, y: extPt.y }, r); + if (tpts) { + const cnt = this.eng.byType('derived_line').filter(d => d.constr === 'tangent').length; + for (let w = 0; w < 2; w++) { + const T = tpts[w]; + const dx = T.x - extPt.x, dy = T.y - extPt.y; + const len = Math.hypot(dx, dy); + if (len < 1e-12) continue; + const lbl = cnt + w === 0 ? 't₁' : 't' + (cnt + w + 1); + this.eng.add({ + type: 'derived_line', derived: true, constr: 'tangent', + srcCircle: circ.id, srcPt: extPt.id, which: w, + ptX: extPt.x, ptY: extPt.y, + dirX: dx/len, dirY: dy/len, + valid: true, + label: lbl, style: { color: '#FCD34D' } + }); + } + } + } + this._pendingCircRef = null; this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } + + case 'translate': { + this._pending.push(snapped); + if (this._pending.length === 1) { + if (this.onHintChange) this.onHintChange('translate', 2); + } else if (this._pending.length === 2) { + if (this.onHintChange) this.onHintChange('translate', 3); + } else if (this._pending.length === 3) { + this._pushUndo(); + const ptA = this._ensurePoint(this._pending[0]); + const ptB = this._ensurePoint(this._pending[1]); + const ptP = this._ensurePoint(this._pending[2]); + const lbl = this._nextLabel(); + this.eng.add({ + type: 'point', derived: true, constr: 'translate', + srcA: ptA.id, srcB: ptB.id, srcPt: ptP.id, + x: ptP.x + (ptB.x - ptA.x), + y: ptP.y + (ptB.y - ptA.y), + label: lbl, style: { color: '#60A5FA', size: 4 } + }); + this._pending = []; this._preview = null; + if (this.onUpdate) this.onUpdate(this.getStats()); + } + break; + } } this.render(); } diff --git a/frontend/lab.html b/frontend/lab.html index 5503a80..6b68d6d 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3861,10 +3861,18 @@
Преобразования
- + +
Правильный многоугольник
@@ -5374,6 +5382,8 @@ incircle: 'Кликни 3 точки треугольника — получи вписанную окружность', reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)', ngon: 'Клик — центр правильного многоугольника; второй клик — вершина', + tangent: 'Кликни на окружность — построим касательные', + translate: 'Кликни начало вектора A', }; function geoSetTool(name, btnEl) { @@ -5384,24 +5394,25 @@ _geoShowHint(name); } - function _geoShowHint(name, phase2) { + const _GEO_PHASE_HINTS = { + parallel_2: 'Теперь кликни на точку — через неё проведём прямую', + perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр', + intersect_2: 'Теперь кликни на вторую прямую', + foot_2: 'Теперь кликни на точку — найдём основание перпендикуляра', + reflect_2: 'Теперь кликни на точку — получишь её симметричное отражение', + tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные', + translate_2: 'Теперь кликни конец вектора B', + translate_3: 'Теперь кликни точку P — она будет перенесена', + }; + + function _geoShowHint(name, phase) { const hint = document.getElementById('geo-hint'); if (!hint) return; - if (phase2) { - const phase2hints = { - parallel: 'Теперь кликни на точку — через неё проведём прямую', - perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр', - intersect: 'Теперь кликни на вторую прямую', - foot: 'Теперь кликни на точку — найдём основание перпендикуляра', - reflect: 'Теперь кликни на точку — получишь её симметричное отражение', - }; - hint.textContent = phase2hints[name] || _GEO_HINTS[name] || ''; + if (phase && phase > 1) { + hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || ''; } else { hint.textContent = _GEO_HINTS[name] || ''; } - // Показываем/скрываем n-selector только для ngon - const ngonCtrl = document.getElementById('geo-ngon-ctrl'); - // ngon-ctrl всегда виден — расположен рядом с кнопкой в grid } function geoNgonN(delta) {