feat: Phase 4 планиметрии — симметрия (reflect) + правильный n-угольник (ngon)

- GeoEngine: _dependsOn/recompute для constr='reflect' и 'ngon_vertex'
- reflect: производная точка-отражение (P'=2·foot-P), зависит от axis+srcPt
- ngon: правильный n-угольник по центру и вершине; вершины v1..vn-1 = derived
  points (constr='ngon_vertex', хранят srcCenter/srcVertex/k/n); при движении
  центра/вершины все вершины автоматически пересчитываются
- GeoSim: _ngonSides=6, setNgonSides(n), инструменты 'reflect'/'ngon' в _handleToolClick
- lab.html: кнопки Симметрия и n-угольник, +/- контроллер сторон, хинты Phase 2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 10:22:49 +03:00
parent 2e7ec81e59
commit e2e351d9c2
2 changed files with 154 additions and 3 deletions
+97 -3
View File
@@ -170,12 +170,13 @@ class GeoEngine {
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.constr === 'foot' || obj.constr === 'reflect') {
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));
}
if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id;
return false;
case 'derived_line':
switch (obj.constr) {
@@ -208,7 +209,7 @@ class GeoEngine {
if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; }
else obj.valid = false;
}
} else if (obj.constr === 'foot') {
} else if (obj.constr === 'foot' || obj.constr === 'reflect') {
const srcPt = _g(obj.srcPt);
const sl = _g(obj.srcLine);
if (!srcPt || !sl) return;
@@ -221,8 +222,22 @@ class GeoEngine {
}
if (L1 && L2) {
const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
obj.x = f.x; obj.y = f.y;
if (obj.constr === 'foot') {
obj.x = f.x; obj.y = f.y;
} else {
// reflect: P' = 2*foot - P
obj.x = 2*f.x - srcPt.x;
obj.y = 2*f.y - srcPt.y;
}
}
} else if (obj.constr === 'ngon_vertex') {
const center = _g(obj.srcCenter), v0 = _g(obj.srcVertex);
if (!center || !v0) return;
const angle = 2 * Math.PI * obj.k / obj.n;
const dx = v0.x - center.x, dy = v0.y - center.y;
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.type === 'circle' && obj.derived) {
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
@@ -370,9 +385,14 @@ class GeoSim {
this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки
this._labelCounter = 0;
this._ngonSides = 6; // для инструмента правильного многоугольника
this._bindEvents();
}
setNgonSides(n) {
this._ngonSides = Math.max(3, Math.min(20, n));
}
/* ── Инициализация ─────────────────────────────────────────── */
fit() {
const c = this.canvas;
@@ -1499,6 +1519,80 @@ class GeoSim {
}
break;
}
/* ══ Phase 4: reflect, ngon ══ */
case 'reflect': {
if (!this._pendingLineRef) {
// Первый клик: выбрать ось симметрии (прямую/отрезок)
const hit = this._hitTestLine(px, py);
if (hit) {
this._pendingLineRef = hit;
if (this.onHintChange) this.onHintChange('reflect', 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: 'reflect',
srcLine: sl.id, srcPt: srcPt.id,
x: 2*f.x - srcPt.x,
y: 2*f.y - srcPt.y,
label: lbl, style: { color: '#F472B6', size: 4 }
});
}
this._pendingLineRef = null; this._pending = []; this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
case 'ngon': {
this._pending.push(snapped);
if (this._pending.length === 2) {
this._pushUndo();
const n = this._ngonSides;
const center = this._ensurePoint(this._pending[0]);
const v0 = this._ensurePoint(this._pending[1]);
const col = '#D4B96B';
const ptIds = [v0.id];
for (let k = 1; k < n; k++) {
const angle = 2 * Math.PI * k / n;
const dx = v0.x - center.x, dy = v0.y - center.y;
const cos_a = Math.cos(angle), sin_a = Math.sin(angle);
const vk = this.eng.add({
type: 'point', derived: true, constr: 'ngon_vertex',
srcCenter: center.id, srcVertex: v0.id, k, n,
x: center.x + dx*cos_a - dy*sin_a,
y: center.y + dx*sin_a + dy*cos_a,
label: '', style: { color: col, size: 4 }
});
ptIds.push(vk.id);
}
this.eng.add({
type: 'polygon', pointIds: ptIds,
style: { color: col, fillColor: 'rgba(212,185,107,0.08)' }
});
this._pending = []; this._preview = null;
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
}
this.render();
}
+57
View File
@@ -672,6 +672,25 @@
grid-column: span 2;
}
.geo-ngon-ctrl {
display: flex; align-items: center; justify-content: center; gap: 6px;
border: 1.5px solid var(--border); border-radius: 10px;
padding: 4px 6px;
}
.geo-ngon-btn {
width: 22px; height: 22px; border-radius: 6px;
border: 1px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .12s;
}
.geo-ngon-btn svg { width: 12px; height: 12px; stroke: currentColor; }
.geo-ngon-btn:hover { background: rgba(155,93,229,.1); }
#geo-ngon-n {
font-size: 0.78rem; font-weight: 700; color: var(--text);
min-width: 18px; text-align: center;
}
.geo-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 4px; border-radius: 8px; cursor: pointer;
@@ -3840,6 +3859,31 @@
</button>
</div>
<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="Симметрия точки относительно прямой — клик на ось, затем на точку">
<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>
</div>
<div class="gp-section-title" style="margin-top:4px">Правильный многоугольник</div>
<div class="geo-tool-grid">
<button id="geo-btn-ngon" class="geo-tool-btn" onclick="geoSetTool('ngon',this)" title="Правильный n-угольник — клик центр, клик вершина">
<svg viewBox="0 0 24 24" fill="none"><polygon points="12,3 20.2,8.5 17.3,18 6.7,18 3.8,8.5" stroke-width="1.5" fill="none"/></svg>
n-угольник
</button>
<div class="geo-ngon-ctrl" id="geo-ngon-ctrl">
<button class="geo-ngon-btn" onclick="geoNgonN(-1)">
<svg viewBox="0 0 16 16" fill="none"><line x1="3" y1="8" x2="13" y2="8" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<span id="geo-ngon-n">6</span>
<button class="geo-ngon-btn" onclick="geoNgonN(+1)">
<svg viewBox="0 0 16 16" fill="none"><line x1="8" y1="3" x2="8" y2="13" stroke-width="2" stroke-linecap="round"/><line x1="3" y1="8" x2="13" y2="8" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<!-- Display options -->
<div class="gp-section-title" style="margin-top:6px">Параметры</div>
<label class="geo-toggle-row" onclick="geoToggle('showGrid',this)">
@@ -5328,6 +5372,8 @@
foot: 'Сначала кликни на прямую/отрезок',
circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)',
ngon: 'Клик — центр правильного многоугольника; второй клик — вершина',
};
function geoSetTool(name, btnEl) {
@@ -5347,11 +5393,22 @@
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
intersect: 'Теперь кликни на вторую прямую',
foot: 'Теперь кликни на точку — найдём основание перпендикуляра',
reflect: 'Теперь кликни на точку — получишь её симметричное отражение',
};
hint.textContent = phase2hints[name] || _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) {
if (!geomSim) return;
geomSim.setNgonSides(geomSim._ngonSides + delta);
const el = document.getElementById('geo-ngon-n');
if (el) el.textContent = geomSim._ngonSides;
}
function geoToggle(prop, rowEl) {