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();
}