feat: Phase 3 планиметрии — дуги углов, маркер 90°, инструменты foot/circumcircle/incircle

- _drawAngleMeasures(): реальные дуги через биссектрису (midAngle±halfSpread),
  маркер правого угла квадратом при |angle-90°|<2°
- gIncircle(): функция вписанной окружности треугольника
- GeoEngine: поддержка constr='foot' (точка), constr='circumcircle'/'incircle' (окружность)
  в _dependsOn и recompute; cascadeDelete через derived circles
- _drawCircle(): обработка derived=true (circumcircle/incircle) — dashed + cx/cy/r
- getStats(): исправлена статистика для производных окружностей
- constructions учитывает derivedCircles
- lab.html: 3 новые кнопки (Основание, Описанная, Вписанная) + хинты

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 10:15:05 +03:00
parent 95cca89dfc
commit 2e7ec81e59
2 changed files with 217 additions and 22 deletions
+201 -22
View File
@@ -58,6 +58,18 @@ function gCircumcircle(A, B, C) {
return { cx, cy, r: gDist({x:cx,y:cy}, A) }; return { cx, cy, r: gDist({x:cx,y:cy}, A) };
} }
/** Вписанная окружность треугольника */
function gIncircle(A, B, C) {
const a = gDist(B, C), b = gDist(A, C), c = gDist(A, B);
const s = a + b + c;
if (s < 1e-12) return null;
const cx = (a*A.x + b*B.x + c*C.x) / s;
const cy = (a*A.y + b*B.y + c*C.y) / s;
const area = gPolygonArea([A, B, C]);
const r = area / (s / 2);
return { cx, cy, r };
}
/** Угол ABC (в градусах, вершина B) */ /** Угол ABC (в градусах, вершина B) */
function gAngleDeg(A, B, C) { function gAngleDeg(A, B, C) {
const v1 = gSub(A, B), v2 = gSub(C, B); const v1 = gSub(A, B), v2 = gSub(C, B);
@@ -149,14 +161,21 @@ class GeoEngine {
switch (obj.type) { switch (obj.type) {
case 'segment': case 'line': case 'ray': case 'segment': case 'line': case 'ray':
return obj.p1Id === id || obj.p2Id === id; return obj.p1Id === id || obj.p2Id === id;
case 'circle':
return obj.centerId === id || obj.edgeId === id;
case 'polygon': case 'polygon':
return obj.pointIds.includes(id); return obj.pointIds.includes(id);
case 'circle':
if (obj.derived) return obj.ptA === id || obj.ptB === id || obj.ptC === id;
return obj.centerId === id || obj.edgeId === id;
case 'point': case 'point':
if (!obj.derived) return false; if (!obj.derived) return false;
if (obj.constr === 'midpoint') return obj.srcA === id || obj.srcB === id; if (obj.constr === 'midpoint') return obj.srcA === id || obj.srcB === id;
if (obj.constr === 'intersect') return obj.src1 === id || obj.src2 === id; if (obj.constr === 'intersect') return obj.src1 === id || obj.src2 === id;
if (obj.constr === 'foot') {
if (obj.srcPt === id || obj.srcLine === id) return true;
// Если srcLine — обычная прямая, зависим и от её точек
const sl = this._objects.get(obj.srcLine);
return !!(sl && (sl.p1Id === id || sl.p2Id === id));
}
return false; return false;
case 'derived_line': case 'derived_line':
switch (obj.constr) { switch (obj.constr) {
@@ -189,6 +208,34 @@ class GeoEngine {
if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; } if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; }
else obj.valid = false; else obj.valid = false;
} }
} else if (obj.constr === 'foot') {
const srcPt = _g(obj.srcPt);
const sl = _g(obj.srcLine);
if (!srcPt || !sl) return;
let L1, L2;
if (sl.type === 'derived_line') {
L1 = { x: sl.ptX, y: sl.ptY };
L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
} else {
L1 = _g(sl.p1Id); L2 = _g(sl.p2Id);
}
if (L1 && L2) {
const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
obj.x = f.x; obj.y = f.y;
}
}
} else if (obj.type === 'circle' && obj.derived) {
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
if (!pA || !pB || !pC) return;
const A = { x: pA.x, y: pA.y }, B = { x: pB.x, y: pB.y }, C = { x: pC.x, y: pC.y };
if (obj.constr === 'circumcircle') {
const cc = gCircumcircle(A, B, C);
if (cc) { obj.cx = cc.cx; obj.cy = cc.cy; obj.r = cc.r; obj.valid = true; }
else { obj.valid = false; }
} else if (obj.constr === 'incircle') {
const ic = gIncircle(A, B, C);
if (ic) { obj.cx = ic.cx; obj.cy = ic.cy; obj.r = ic.r; obj.valid = true; }
else { obj.valid = false; }
} }
} else if (obj.type === 'derived_line') { } else if (obj.type === 'derived_line') {
if (obj.constr === 'perpbisect') { if (obj.constr === 'perpbisect') {
@@ -731,23 +778,33 @@ class GeoSim {
} }
_drawCircle(ctx, obj) { _drawCircle(ctx, obj) {
const c = this._p(obj.centerId), e = this._p(obj.edgeId); let cx, cy, r;
if (!c || !e) return; const isDerived = obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle');
const r = gDist(c, e); if (isDerived) {
if (!obj.valid) return;
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) return;
cx = c.x; cy = c.y;
r = gDist(c, e);
}
const col = obj.style?.color || '#FFB347'; const col = obj.style?.color || '#FFB347';
const sel = this._isSelected(obj); const sel = this._isSelected(obj);
ctx.save(); ctx.save();
ctx.strokeStyle = col; ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2); ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4; ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
ctx.globalAlpha = 0.9; ctx.globalAlpha = isDerived ? 0.75 : 0.9;
// Лёгкая заливка if (isDerived) ctx.setLineDash([7, 4]);
ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.05)`; ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.04)`;
ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI*2); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fill(); ctx.stroke(); ctx.fill(); ctx.stroke();
ctx.restore(); ctx.restore();
if (this.showLabels && obj.label) if (this.showLabels && obj.label)
this._drawObjLabel(ctx, obj.label, { x: c.x, y: c.y - r - 8 }, col); this._drawObjLabel(ctx, obj.label, { x: cx, y: cy - r - 8 }, col);
} }
_drawObjLabel(ctx, label, pos, col) { _drawObjLabel(ctx, label, pos, col) {
@@ -796,22 +853,71 @@ class GeoSim {
} }
_drawAngleMeasures(ctx) { _drawAngleMeasures(ctx) {
const ARC_R = 22; // радиус дуги угла, пикселей
const SQ_SZ = 11; // сторона квадрата прямого угла, пикселей
const LBL_D = 14; // отступ подписи от дуги/квадрата, пикселей
for (const poly of this.eng.byType('polygon')) { for (const poly of this.eng.byType('polygon')) {
const ids = poly.pointIds; const ids = poly.pointIds;
const n = ids.length; const n = ids.length;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const A = this._mpt(ids[(i-1+n)%n]); const A = this._mpt(ids[(i-1+n)%n]);
const B = this._mpt(ids[i]); const B = this._mpt(ids[i]);
const C = this._mpt(ids[(i+1)%n]); const C = this._mpt(ids[(i+1)%n]);
if (!A||!B||!C) continue; if (!A || !B || !C) continue;
const angle = gAngleDeg(A, B, C); const angle = gAngleDeg(A, B, C);
const Apx = this.vp.toCanvas(A.x, A.y);
const Bpx = this.vp.toCanvas(B.x, B.y); const Bpx = this.vp.toCanvas(B.x, B.y);
const Cpx = this.vp.toCanvas(C.x, C.y);
const col = poly.style?.color || '#FFE066';
// Единичные векторы B→A и B→C в пиксельных координатах
const dAx = Apx.x - Bpx.x, dAy = Apx.y - Bpx.y;
const dCx = Cpx.x - Bpx.x, dCy = Cpx.y - Bpx.y;
const lenA = Math.hypot(dAx, dAy), lenC = Math.hypot(dCx, dCy);
if (lenA < 1e-4 || lenC < 1e-4) continue;
const uAx = dAx/lenA, uAy = dAy/lenA;
const uCx = dCx/lenC, uCy = dCy/lenC;
// Биссектриса угла в пиксельных координатах
const bisX = uAx + uCx, bisY = uAy + uCy;
const bisLen = Math.hypot(bisX, bisY);
ctx.save(); ctx.save();
ctx.font = '10px Manrope,sans-serif'; ctx.strokeStyle = col;
ctx.fillStyle = '#FFE066'; ctx.lineWidth = 1.5;
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3; if (Math.abs(angle - 90) < 2) {
ctx.fillText(angle.toFixed(1)+'°', Bpx.x, Bpx.y + 18); // Прямой угол — маленький квадрат
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.moveTo(Bpx.x + uAx*SQ_SZ, Bpx.y + uAy*SQ_SZ);
ctx.lineTo(Bpx.x + uAx*SQ_SZ + uCx*SQ_SZ, Bpx.y + uAy*SQ_SZ + uCy*SQ_SZ);
ctx.lineTo(Bpx.x + uCx*SQ_SZ, Bpx.y + uCy*SQ_SZ);
ctx.stroke();
} else if (bisLen > 1e-6) {
// Дуга угла — от midAngle-halfSpread до midAngle+halfSpread через биссектрису
const midAngle = Math.atan2(bisY, bisX);
const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2;
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.arc(Bpx.x, Bpx.y, ARC_R, midAngle - halfSpread, midAngle + halfSpread, false);
ctx.stroke();
}
// Подпись угла вдоль биссектрисы
if (bisLen > 1e-6) {
const ldist = (Math.abs(angle - 90) < 2 ? SQ_SZ : ARC_R) + LBL_D;
const bx = bisX / bisLen, by = bisY / bisLen;
ctx.font = '10px Manrope,sans-serif';
ctx.fillStyle = col;
ctx.globalAlpha = 0.9;
ctx.textAlign = 'center';
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3;
ctx.fillText(angle.toFixed(1) + '°', Bpx.x + bx*ldist, Bpx.y + by*ldist + 3);
}
ctx.restore(); ctx.restore();
} }
} }
@@ -1324,6 +1430,75 @@ class GeoSim {
} }
break; break;
} }
/* ══ Phase 3: foot, circumcircle, incircle ══ */
case 'foot': {
if (!this._pendingLineRef) {
// Первый клик: выбрать прямую
const hit = this._hitTestLine(px, py);
if (hit) {
this._pendingLineRef = hit;
if (this.onHintChange) this.onHintChange('foot', 2);
}
} else {
// Второй клик: выбрать точку, с которой опускаем перпендикуляр
this._pushUndo();
const srcPt = this._ensurePoint(snapped);
const sl = this._pendingLineRef;
let L1, L2;
if (sl.type === 'derived_line') {
L1 = { x: sl.ptX, y: sl.ptY };
L2 = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
} else {
L1 = this._mpt(sl.p1Id); L2 = this._mpt(sl.p2Id);
}
if (L1 && L2) {
const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
const lbl = this._nextLabel();
this.eng.add({
type: 'point', derived: true, constr: 'foot',
srcLine: sl.id, srcPt: srcPt.id,
x: f.x, y: f.y,
label: lbl, style: { color: '#4ADE80', size: 4 }
});
}
this._pendingLineRef = null; this._pending = []; this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
case 'circumcircle':
case 'incircle': {
this._pending.push(snapped);
if (this._pending.length === 3) {
this._pushUndo();
const pts = this._pending.map(p => this._ensurePoint(p));
const A = { x: pts[0].x, y: pts[0].y };
const B = { x: pts[1].x, y: pts[1].y };
const C = { x: pts[2].x, y: pts[2].y };
const cc = this.tool === 'circumcircle'
? gCircumcircle(A, B, C)
: gIncircle(A, B, C);
if (cc) {
const sameConstr = this.eng.byType('circle').filter(c => c.constr === this.tool).length;
const isCircum = this.tool === 'circumcircle';
this.eng.add({
type: 'circle', derived: true, constr: this.tool,
ptA: pts[0].id, ptB: pts[1].id, ptC: pts[2].id,
cx: cc.cx, cy: cc.cy, r: cc.r, valid: true,
label: isCircum
? (sameConstr ? 'C' + (sameConstr+1) : 'C₁')
: (sameConstr ? 'I' + (sameConstr+1) : 'I₁'),
style: { color: isCircum ? '#38BDF8' : '#34D399' }
});
}
this._pending = []; this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
} }
this.render(); this.render();
} }
@@ -1478,7 +1653,8 @@ class GeoSim {
const segs = this.eng.byType('segment').length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0); const segs = this.eng.byType('segment').length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0);
const circs= this.eng.byType('circle').length; const circs= this.eng.byType('circle').length;
const polys= this.eng.byType('polygon').length; const polys= this.eng.byType('polygon').length;
const constructions = this.eng.byType('derived_line').length + derivedPts; const derivedCircles = this.eng.byType('circle').filter(c => c.derived).length;
const constructions = this.eng.byType('derived_line').length + derivedPts + derivedCircles;
// Статистика для выбранного объекта // Статистика для выбранного объекта
let sel = null; let sel = null;
@@ -1488,11 +1664,14 @@ class GeoSim {
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id); const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (m1&&m2) sel = { type:'segment', len: gDist(m1,m2).toFixed(3), mid: gMid(m1,m2) }; if (m1&&m2) sel = { type:'segment', len: gDist(m1,m2).toFixed(3), mid: gMid(m1,m2) };
} else if (obj.type === 'circle') { } else if (obj.type === 'circle') {
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId); let r = null;
if (mc&&me) { if (obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle')) {
const r = gDist(mc,me); if (obj.valid) r = obj.r;
sel = { type:'circle', r:r.toFixed(3), perimeter:(2*Math.PI*r).toFixed(3), area:(Math.PI*r*r).toFixed(3) }; } else {
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
if (mc && me) r = gDist(mc, me);
} }
if (r != null) sel = { type:'circle', r:r.toFixed(3), perimeter:(2*Math.PI*r).toFixed(3), area:(Math.PI*r*r).toFixed(3) };
} else if (obj.type === 'polygon') { } else if (obj.type === 'polygon') {
const pts2 = obj.pointIds.map(id=>this._mpt(id)).filter(Boolean); const pts2 = obj.pointIds.map(id=>this._mpt(id)).filter(Boolean);
if (pts2.length >= 3) { if (pts2.length >= 3) {
+16
View File
@@ -3826,6 +3826,18 @@
<svg viewBox="0 0 24 24" fill="none"><line x1="4" y1="20" x2="20" y2="4" stroke-width="2"/><line x1="4" y1="4" x2="20" y2="20" stroke-width="2"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" fill="none"><line x1="4" y1="20" x2="20" y2="4" stroke-width="2"/><line x1="4" y1="4" x2="20" y2="20" stroke-width="2"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/></svg>
Пересеч. Пересеч.
</button> </button>
<button id="geo-btn-foot" class="geo-tool-btn" onclick="geoSetTool('foot',this)" title="Основание перпендикуляра — клик на прямую, затем на точку">
<svg viewBox="0 0 24 24" fill="none"><line x1="3" y1="18" x2="21" y2="18" stroke-width="2"/><line x1="12" y1="18" x2="12" y2="4" stroke-width="1.5" stroke-dasharray="3,2"/><path d="M12 18 L15 18 L15 15" stroke-width="1.5" fill="none"/><circle cx="12" cy="4" r="2.5" fill="currentColor"/></svg>
Основание
</button>
<button id="geo-btn-circumcircle" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('circumcircle',this)" title="Описанная окружность — 3 точки треугольника">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke-width="1.5" stroke-dasharray="4,3"/><polygon points="6,18 18,18 12,6" stroke-width="1.5" fill="none"/></svg>
Описанная ☉
</button>
<button id="geo-btn-incircle" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('incircle',this)" title="Вписанная окружность — 3 точки треугольника">
<svg viewBox="0 0 24 24" fill="none"><polygon points="4,20 20,20 12,4" stroke-width="1.5" fill="none"/><circle cx="12" cy="15" r="5" stroke-width="1.5" stroke-dasharray="4,3"/></svg>
Вписанная ○
</button>
</div> </div>
<!-- Display options --> <!-- Display options -->
@@ -5313,6 +5325,9 @@
parallel: 'Сначала кликни на прямую/отрезок, затем на точку', parallel: 'Сначала кликни на прямую/отрезок, затем на точку',
perpendicular:'Сначала кликни на прямую/отрезок, затем на точку', perpendicular:'Сначала кликни на прямую/отрезок, затем на точку',
intersect: 'Кликни на первую прямую, затем на вторую', intersect: 'Кликни на первую прямую, затем на вторую',
foot: 'Сначала кликни на прямую/отрезок',
circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
}; };
function geoSetTool(name, btnEl) { function geoSetTool(name, btnEl) {
@@ -5331,6 +5346,7 @@
parallel: 'Теперь кликни на точку — через неё проведём прямую', parallel: 'Теперь кликни на точку — через неё проведём прямую',
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр', perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
intersect: 'Теперь кликни на вторую прямую', intersect: 'Теперь кликни на вторую прямую',
foot: 'Теперь кликни на точку — найдём основание перпендикуляра',
}; };
hint.textContent = phase2hints[name] || _GEO_HINTS[name] || ''; hint.textContent = phase2hints[name] || _GEO_HINTS[name] || '';
} else { } else {