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:
@@ -170,12 +170,13 @@ class GeoEngine {
|
|||||||
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.constr === 'foot' || obj.constr === 'reflect') {
|
||||||
if (obj.srcPt === id || obj.srcLine === id) return true;
|
if (obj.srcPt === id || obj.srcLine === id) return true;
|
||||||
// Если srcLine — обычная прямая, зависим и от её точек
|
// Если srcLine — обычная прямая, зависим и от её точек
|
||||||
const sl = this._objects.get(obj.srcLine);
|
const sl = this._objects.get(obj.srcLine);
|
||||||
return !!(sl && (sl.p1Id === id || sl.p2Id === id));
|
return !!(sl && (sl.p1Id === id || sl.p2Id === id));
|
||||||
}
|
}
|
||||||
|
if (obj.constr === 'ngon_vertex') return obj.srcCenter === id || obj.srcVertex === id;
|
||||||
return false;
|
return false;
|
||||||
case 'derived_line':
|
case 'derived_line':
|
||||||
switch (obj.constr) {
|
switch (obj.constr) {
|
||||||
@@ -208,7 +209,7 @@ 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') {
|
} else if (obj.constr === 'foot' || obj.constr === 'reflect') {
|
||||||
const srcPt = _g(obj.srcPt);
|
const srcPt = _g(obj.srcPt);
|
||||||
const sl = _g(obj.srcLine);
|
const sl = _g(obj.srcLine);
|
||||||
if (!srcPt || !sl) return;
|
if (!srcPt || !sl) return;
|
||||||
@@ -221,8 +222,22 @@ class GeoEngine {
|
|||||||
}
|
}
|
||||||
if (L1 && L2) {
|
if (L1 && L2) {
|
||||||
const f = gFoot({ x: srcPt.x, y: srcPt.y }, 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) {
|
} else if (obj.type === 'circle' && obj.derived) {
|
||||||
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
|
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.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки
|
||||||
|
|
||||||
this._labelCounter = 0;
|
this._labelCounter = 0;
|
||||||
|
this._ngonSides = 6; // для инструмента правильного многоугольника
|
||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setNgonSides(n) {
|
||||||
|
this._ngonSides = Math.max(3, Math.min(20, n));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Инициализация ─────────────────────────────────────────── */
|
/* ── Инициализация ─────────────────────────────────────────── */
|
||||||
fit() {
|
fit() {
|
||||||
const c = this.canvas;
|
const c = this.canvas;
|
||||||
@@ -1499,6 +1519,80 @@ class GeoSim {
|
|||||||
}
|
}
|
||||||
break;
|
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();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -672,6 +672,25 @@
|
|||||||
grid-column: span 2;
|
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 {
|
.geo-toggle-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 5px 4px; border-radius: 8px; cursor: pointer;
|
padding: 5px 4px; border-radius: 8px; cursor: pointer;
|
||||||
@@ -3840,6 +3859,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Display options -->
|
||||||
<div class="gp-section-title" style="margin-top:6px">Параметры</div>
|
<div class="gp-section-title" style="margin-top:6px">Параметры</div>
|
||||||
<label class="geo-toggle-row" onclick="geoToggle('showGrid',this)">
|
<label class="geo-toggle-row" onclick="geoToggle('showGrid',this)">
|
||||||
@@ -5328,6 +5372,8 @@
|
|||||||
foot: 'Сначала кликни на прямую/отрезок',
|
foot: 'Сначала кликни на прямую/отрезок',
|
||||||
circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
|
circumcircle: 'Кликни 3 точки треугольника — получи описанную окружность',
|
||||||
incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
|
incircle: 'Кликни 3 точки треугольника — получи вписанную окружность',
|
||||||
|
reflect: 'Сначала кликни на ось симметрии (прямую/отрезок)',
|
||||||
|
ngon: 'Клик — центр правильного многоугольника; второй клик — вершина',
|
||||||
};
|
};
|
||||||
|
|
||||||
function geoSetTool(name, btnEl) {
|
function geoSetTool(name, btnEl) {
|
||||||
@@ -5347,11 +5393,22 @@
|
|||||||
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
|
perpendicular: 'Теперь кликни на точку — через неё проведём перпендикуляр',
|
||||||
intersect: 'Теперь кликни на вторую прямую',
|
intersect: 'Теперь кликни на вторую прямую',
|
||||||
foot: 'Теперь кликни на точку — найдём основание перпендикуляра',
|
foot: 'Теперь кликни на точку — найдём основание перпендикуляра',
|
||||||
|
reflect: 'Теперь кликни на точку — получишь её симметричное отражение',
|
||||||
};
|
};
|
||||||
hint.textContent = phase2hints[name] || _GEO_HINTS[name] || '';
|
hint.textContent = phase2hints[name] || _GEO_HINTS[name] || '';
|
||||||
} else {
|
} else {
|
||||||
hint.textContent = _GEO_HINTS[name] || '';
|
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) {
|
function geoToggle(prop, rowEl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user