diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js
index c86140f..57e09b6 100644
--- a/frontend/js/labs/geometry.js
+++ b/frontend/js/labs/geometry.js
@@ -58,6 +58,18 @@ function gCircumcircle(A, B, C) {
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) */
function gAngleDeg(A, B, C) {
const v1 = gSub(A, B), v2 = gSub(C, B);
@@ -149,14 +161,21 @@ class GeoEngine {
switch (obj.type) {
case 'segment': case 'line': case 'ray':
return obj.p1Id === id || obj.p2Id === id;
- case 'circle':
- return obj.centerId === id || obj.edgeId === id;
case 'polygon':
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':
if (!obj.derived) return false;
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 === '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;
case 'derived_line':
switch (obj.constr) {
@@ -189,6 +208,34 @@ class GeoEngine {
if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; }
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') {
if (obj.constr === 'perpbisect') {
@@ -731,23 +778,33 @@ class GeoSim {
}
_drawCircle(ctx, obj) {
- const c = this._p(obj.centerId), e = this._p(obj.edgeId);
- if (!c || !e) return;
- const r = gDist(c, e);
+ let cx, cy, r;
+ const isDerived = obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle');
+ 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 sel = this._isSelected(obj);
ctx.save();
ctx.strokeStyle = col;
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
- ctx.globalAlpha = 0.9;
- // Лёгкая заливка
- ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.05)`;
- ctx.beginPath(); ctx.arc(c.x, c.y, r, 0, Math.PI*2);
+ ctx.globalAlpha = isDerived ? 0.75 : 0.9;
+ if (isDerived) ctx.setLineDash([7, 4]);
+ ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.04)`;
+ ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
ctx.fill(); ctx.stroke();
ctx.restore();
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) {
@@ -796,22 +853,71 @@ class GeoSim {
}
_drawAngleMeasures(ctx) {
+ const ARC_R = 22; // радиус дуги угла, пикселей
+ const SQ_SZ = 11; // сторона квадрата прямого угла, пикселей
+ const LBL_D = 14; // отступ подписи от дуги/квадрата, пикселей
+
for (const poly of this.eng.byType('polygon')) {
const ids = poly.pointIds;
- const n = ids.length;
+ const n = ids.length;
for (let i = 0; i < n; i++) {
const A = this._mpt(ids[(i-1+n)%n]);
const B = this._mpt(ids[i]);
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 Apx = this.vp.toCanvas(A.x, A.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.font = '10px Manrope,sans-serif';
- ctx.fillStyle = '#FFE066';
- ctx.textAlign = 'center';
- ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3;
- ctx.fillText(angle.toFixed(1)+'°', Bpx.x, Bpx.y + 18);
+ ctx.strokeStyle = col;
+ ctx.lineWidth = 1.5;
+
+ if (Math.abs(angle - 90) < 2) {
+ // Прямой угол — маленький квадрат
+ 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();
}
}
@@ -1324,6 +1430,75 @@ class GeoSim {
}
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();
}
@@ -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 circs= this.eng.byType('circle').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;
@@ -1488,11 +1664,14 @@ class GeoSim {
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) };
} else if (obj.type === 'circle') {
- const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
- if (mc&&me) {
- const r = gDist(mc,me);
- sel = { type:'circle', r:r.toFixed(3), perimeter:(2*Math.PI*r).toFixed(3), area:(Math.PI*r*r).toFixed(3) };
+ let r = null;
+ if (obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle')) {
+ if (obj.valid) r = obj.r;
+ } 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') {
const pts2 = obj.pointIds.map(id=>this._mpt(id)).filter(Boolean);
if (pts2.length >= 3) {
diff --git a/frontend/lab.html b/frontend/lab.html
index 818abae..18feb62 100644
--- a/frontend/lab.html
+++ b/frontend/lab.html
@@ -3826,6 +3826,18 @@
Пересеч.
+
+
+
@@ -5313,6 +5325,9 @@
parallel: 'Сначала кликни на прямую/отрезок, затем на точку',
perpendicular:'Сначала кликни на прямую/отрезок, затем на точку',
intersect: 'Кликни на первую прямую, затем на вторую',
+ foot: 'Сначала кликни на прямую/отрезок',
+ circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
+ incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
};
function geoSetTool(name, btnEl) {
@@ -5331,6 +5346,7 @@
parallel: 'Теперь кликни на точку — через неё проведём прямую',
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
intersect: 'Теперь кликни на вторую прямую',
+ foot: 'Теперь кликни на точку — найдём основание перпендикуляра',
};
hint.textContent = phase2hints[name] || _GEO_HINTS[name] || '';
} else {