feat: Phase 5 планиметрии — касательные (tangent) + параллельный перенос (translate)

- gTangentPoints(O, P, r): касательные через полярно-полярную точку M=O+v*r²/d, h=r√(d²-r²)/d
- tangent: 2 derived_line (which=0/1) из внешней точки к окружности; оба пересчитываются
  при движении точки или изменении радиуса/центра; _pendingCircRef хранит окружность-источник
- translate: derived point P'=P+(B-A) по вектору AB; 3-фазный ввод с onHintChange(tool,2/3)
- _hitTestCircle(): найти окружность под курсором (HIT=12px)
- _drawLineRefHighlight(): расширен для circle (рисует дугу подсветки)
- _pendingCircRef очищается в setTool()
- lab.html: кнопки Симметрия/Перенос/Касательные, _GEO_PHASE_HINTS словарь,
  _geoShowHint(name, phase) принимает числовой phase вместо boolean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 10:28:09 +03:00
parent e2e351d9c2
commit 0523734898
2 changed files with 196 additions and 27 deletions
+171 -13
View File
@@ -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();
}
+25 -14
View File
@@ -3861,10 +3861,18 @@
<div class="gp-section-title" style="margin-top:4px">Преобразования</div>
<div class="geo-tool-grid">
<button id="geo-btn-reflect" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('reflect',this)" title="Симметрия точки относительно прямой — клик на ось, затем на точку">
<button id="geo-btn-reflect" class="geo-tool-btn" onclick="geoSetTool('reflect',this)" title="Симметрия точки относительно прямой — клик на ось, затем на точку">
<svg viewBox="0 0 24 24" fill="none"><line x1="12" y1="2" x2="12" y2="22" stroke-width="1.5" stroke-dasharray="3,2"/><circle cx="6" cy="12" r="3" stroke-width="1.5"/><circle cx="18" cy="12" r="2.5" stroke-width="1.5" opacity=".5"/><line x1="9" y1="12" x2="15" y2="12" stroke-width="1" opacity=".6"/></svg>
Симметрия
</button>
<button id="geo-btn-translate" class="geo-tool-btn" onclick="geoSetTool('translate',this)" title="Параллельный перенос — вектор AB, затем точка P">
<svg viewBox="0 0 24 24" fill="none"><circle cx="6" cy="18" r="2.5" stroke-width="1.5"/><circle cx="18" cy="6" r="2.5" stroke-width="1.5"/><line x1="6" y1="18" x2="18" y2="6" stroke-width="1.5" marker-end="url(#arrow)"/><circle cx="14" cy="18" r="2.5" stroke-width="1.5" opacity=".4"/><circle cx="21" cy="9" r="2" stroke-width="1.5" stroke-dasharray="3,2"/></svg>
Перенос
</button>
<button id="geo-btn-tangent" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('tangent',this)" title="Касательные из точки к окружности — клик на окружность, затем на точку">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="7" stroke-width="1.5"/><line x1="4" y1="4" x2="19" y2="12" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="4" y1="20" x2="19" y2="12" stroke-width="1.5" stroke-dasharray="4,3"/><circle cx="4" cy="12" r="2.5" fill="currentColor"/></svg>
Касательные
</button>
</div>
<div class="gp-section-title" style="margin-top:4px">Правильный многоугольник</div>
@@ -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) {