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:
+171
-13
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user