From 2e7ec81e593760d29b02c76bb357cc30a01985de Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 14 Apr 2026 10:15:05 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=B8=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B3=D0=B8=20=D1=83=D0=B3=D0=BB=D0=BE=D0=B2,=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BA=D0=B5=D1=80=2090=C2=B0,=20=D0=B8=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20foot/circumc?= =?UTF-8?q?ircle/incircle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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 --- frontend/js/labs/geometry.js | 223 +++++++++++++++++++++++++++++++---- frontend/lab.html | 16 +++ 2 files changed, 217 insertions(+), 22 deletions(-) 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 {