feat: Фазы 6.1–6.3 планиметрии — высоты, прямые углы, удаление объектов

6.1 Стороны полигонов теперь выбираются как опорные линии (_hitTestLine),
    через виртуальные сегменты (virtual:true, polyId). Cascade-удаление
    исправлено на BFS (transitive deps). Теперь можно строить высоты треугольников.

6.2 Прямой угол (квадратный маркер) рисуется для всех foot-конструкций
    в _drawAngleMeasures, независимо от полигона.

6.3 Удаление отдельных объектов: onDeleteRequest callback, диалог
    «Только этот» (derived-точки → свободные) / «Со всеми зависимыми»
    (cascade) / «Отмена». CSS-панель .geo-del-confirm поверх canvas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 11:00:19 +03:00
parent 0523734898
commit 76428db513
2 changed files with 190 additions and 14 deletions
+131 -12
View File
@@ -165,16 +165,28 @@ class GeoEngine {
} }
remove(id) { remove(id) {
this._objects.delete(id); // BFS-каскад: собрать все транзитивно зависимые объекты, затем удалить
// Удалить объекты, зависящие от этой точки const toDelete = new Set([id]);
for (const [oid, obj] of this._objects) { let changed = true;
if (this._dependsOn(obj, id)) this._objects.delete(oid); while (changed) {
changed = false;
for (const [oid, obj] of this._objects) {
if (toDelete.has(oid)) continue;
for (const did of toDelete) {
if (this._dependsOn(obj, did)) { toDelete.add(oid); changed = true; break; }
}
}
} }
for (const oid of toDelete) this._objects.delete(oid);
} }
_dependsOn(obj, id) { _dependsOn(obj, id) {
switch (obj.type) { switch (obj.type) {
case 'segment': case 'line': case 'ray': case 'segment':
// Виртуальный отрезок-сторона полигона зависит от самого полигона
if (obj.virtual && obj.polyId === id) return true;
return obj.p1Id === id || obj.p2Id === id;
case 'line': case 'ray':
return obj.p1Id === id || obj.p2Id === id; return obj.p1Id === id || obj.p2Id === id;
case 'polygon': case 'polygon':
return obj.pointIds.includes(id); return obj.pointIds.includes(id);
@@ -358,6 +370,15 @@ class GeoEngine {
} }
} }
/* Прямые зависимые объекты (один уровень) */
getDependents(id) {
const result = [];
for (const obj of this._objects.values()) {
if (obj.id !== id && this._dependsOn(obj, id)) result.push(obj);
}
return result;
}
get(id) { return this._objects.get(id); } get(id) { return this._objects.get(id); }
has(id) { return this._objects.has(id); } has(id) { return this._objects.has(id); }
all() { return [...this._objects.values()]; } all() { return [...this._objects.values()]; }
@@ -432,8 +453,9 @@ class GeoSim {
this._hovered = null; this._hovered = null;
/* ── Callbacks ── */ /* ── Callbacks ── */
this.onUpdate = null; // cb(stats) this.onUpdate = null; // cb(stats)
this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки
this.onDeleteRequest = null; // cb(obj, deps, softFn, cascadeFn) — подтвердить удаление
this._labelCounter = 0; this._labelCounter = 0;
this._ngonSides = 6; // для инструмента правильного многоугольника this._ngonSides = 6; // для инструмента правильного многоугольника
@@ -498,8 +520,10 @@ class GeoSim {
for (const obj of this.eng.byType('line')) this._drawLine(ctx, obj); for (const obj of this.eng.byType('line')) this._drawLine(ctx, obj);
// Лучи // Лучи
for (const obj of this.eng.byType('ray')) this._drawRay(ctx, obj); for (const obj of this.eng.byType('ray')) this._drawRay(ctx, obj);
// Отрезки // Отрезки (виртуальные стороны полигонов не рисуем — они нарисованы как polygon stroke)
for (const obj of this.eng.byType('segment')) this._drawSegment(ctx, obj); for (const obj of this.eng.byType('segment')) {
if (!obj.virtual) this._drawSegment(ctx, obj);
}
// Стороны многоугольников // Стороны многоугольников
for (const obj of this.eng.byType('polygon')) this._drawPolyStroke(ctx, obj); for (const obj of this.eng.byType('polygon')) this._drawPolyStroke(ctx, obj);
// Окружности // Окружности
@@ -838,6 +862,7 @@ class GeoSim {
const types = ['line','segment','ray']; const types = ['line','segment','ray'];
for (const t of types) { for (const t of types) {
for (const obj of this.eng.byType(t)) { for (const obj of this.eng.byType(t)) {
if (obj.virtual) continue; // виртуальные отрезки-стороны проверяем ниже через полигон
const pts = this._twoPointsOnObj(obj); const pts = this._twoPointsOnObj(obj);
if (!pts) continue; if (!pts) continue;
const d = t === 'segment' ? gDistToSegment(m,pts[0],pts[1]) : gDistToLine(m,pts[0],pts[1]); const d = t === 'segment' ? gDistToSegment(m,pts[0],pts[1]) : gDistToLine(m,pts[0],pts[1]);
@@ -849,9 +874,29 @@ class GeoSim {
if (!pts) continue; if (!pts) continue;
if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj; if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj;
} }
// Стороны полигонов — ищем и при попадании создаём виртуальный отрезок
for (const poly of this.eng.byType('polygon')) {
const ids = poly.pointIds;
for (let i = 0; i < ids.length; i++) {
const j = (i + 1) % ids.length;
const A = this._mpt(ids[i]), B = this._mpt(ids[j]);
if (!A || !B) continue;
if (gDistToSegment(m, A, B) * this.vp.scale < HIT) {
return this._ensurePolySide(poly.id, ids[i], ids[j]);
}
}
}
return null; return null;
} }
/* Найти или создать виртуальный отрезок для стороны полигона */
_ensurePolySide(polyId, p1Id, p2Id) {
for (const obj of this.eng.byType('segment')) {
if (obj.virtual && obj.polyId === polyId && obj.p1Id === p1Id && obj.p2Id === p2Id) return obj;
}
return this.eng.add({ type: 'segment', virtual: true, polyId, p1Id, p2Id });
}
_drawPolyFill(ctx, obj) { _drawPolyFill(ctx, obj) {
const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean); const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
if (pts.length < 3) return; if (pts.length < 3) return;
@@ -1025,6 +1070,47 @@ class GeoSim {
ctx.restore(); ctx.restore();
} }
} }
// Прямые углы для foot-конструкций (основание высоты всегда 90°)
for (const obj of this.eng.points()) {
if (!obj.derived || obj.constr !== 'foot') continue;
const F = this.vp.toCanvas(obj.x, obj.y);
const P = this.eng.get(obj.srcPt);
const sl = this.eng.get(obj.srcLine);
if (!P || !sl) continue;
let L1m, L2m;
if (sl.type === 'derived_line') {
L1m = { x: sl.ptX, y: sl.ptY };
L2m = { x: sl.ptX + sl.dirX, y: sl.ptY + sl.dirY };
} else {
L1m = this._mpt(sl.p1Id); L2m = this._mpt(sl.p2Id);
}
if (!L1m || !L2m) continue;
const L1 = this.vp.toCanvas(L1m.x, L1m.y);
const L2 = this.vp.toCanvas(L2m.x, L2m.y);
const Ppx = this.vp.toCanvas(P.x, P.y);
// Единичный вектор вдоль линии
const ldx = L2.x - L1.x, ldy = L2.y - L1.y;
const llen = Math.hypot(ldx, ldy);
if (llen < 1e-9) continue;
const uLx = ldx / llen, uLy = ldy / llen;
// Единичный вектор F → P (направление перпендикуляра)
const fpx = Ppx.x - F.x, fpy = Ppx.y - F.y;
const fpLen = Math.hypot(fpx, fpy);
if (fpLen < 2) continue; // точка совпадает с основанием — пропустить
const uPx = fpx / fpLen, uPy = fpy / fpLen;
// Квадрат прямого угла
ctx.save();
ctx.strokeStyle = '#4ADE80';
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.moveTo(F.x + uLx*SQ_SZ, F.y + uLy*SQ_SZ);
ctx.lineTo(F.x + uLx*SQ_SZ + uPx*SQ_SZ, F.y + uLy*SQ_SZ + uPy*SQ_SZ);
ctx.lineTo(F.x + uPx*SQ_SZ, F.y + uPy*SQ_SZ);
ctx.stroke();
ctx.restore();
}
} }
/* ── Предпросмотр (строящийся объект) ─────────────────────── */ /* ── Предпросмотр (строящийся объект) ─────────────────────── */
@@ -1272,8 +1358,9 @@ class GeoSim {
if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj; if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj;
} }
} }
// Отрезки // Отрезки (виртуальные стороны полигонов не выбираемы напрямую)
for (const obj of this.eng.byType('segment')) { for (const obj of this.eng.byType('segment')) {
if (obj.virtual) continue;
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id); const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
if (!m1||!m2) continue; if (!m1||!m2) continue;
if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj; if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj;
@@ -1875,8 +1962,40 @@ class GeoSim {
/* ── Удалить выбранный объект ── */ /* ── Удалить выбранный объект ── */
deleteSelected() { deleteSelected() {
if (!this._selected) return; if (!this._selected) return;
const obj = this._selected;
const deps = this.eng.getDependents(obj.id).filter(d => !d.virtual);
if (deps.length > 0 && this.onDeleteRequest) {
// Есть зависимые — делегировать подтверждение наружу
this.onDeleteRequest(obj, deps,
() => this._doDeleteSoft(obj.id),
() => this._doDeleteCascade(obj.id)
);
} else {
this._doDeleteCascade(obj.id);
}
}
_doDeleteCascade(id) {
this._pushUndo(); this._pushUndo();
this.eng.remove(this._selected.id); this.eng.remove(id);
this._selected = null;
this.render();
if (this.onUpdate) this.onUpdate(this.getStats());
}
/* Мягкое удаление: derived-точки становятся свободными, остальное каскадируется */
_doDeleteSoft(id) {
this._pushUndo();
for (const dep of this.eng.getDependents(id)) {
if (dep.type === 'point' && dep.derived) {
// Фиксируем текущие координаты и делаем точку свободной
dep.derived = null; dep.constr = null;
dep.srcLine = dep.srcPt = dep.srcA = dep.srcB =
dep.srcCenter = dep.srcVertex = dep.srcDirPt1 = dep.srcDirPt2 =
dep.ptA = dep.ptB = dep.ptC = undefined;
}
}
this.eng.remove(id);
this._selected = null; this._selected = null;
this.render(); this.render();
if (this.onUpdate) this.onUpdate(this.getStats()); if (this.onUpdate) this.onUpdate(this.getStats());
@@ -1902,7 +2021,7 @@ class GeoSim {
const allPts = this.eng.points(); const allPts = this.eng.points();
const pts = allPts.filter(p => !p.derived).length; const pts = allPts.filter(p => !p.derived).length;
const derivedPts = allPts.filter(p => !!p.derived).length; const derivedPts = allPts.filter(p => !!p.derived).length;
const segs = this.eng.byType('segment').length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0); const segs = this.eng.byType('segment').filter(s=>!s.virtual).length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0);
const circs= this.eng.byType('circle').length; const circs= this.eng.byType('circle').length;
const polys= this.eng.byType('polygon').length; const polys= this.eng.byType('polygon').length;
const derivedCircles = this.eng.byType('circle').filter(c => c.derived).length; const derivedCircles = this.eng.byType('circle').filter(c => c.derived).length;
+59 -2
View File
@@ -748,6 +748,27 @@
pointer-events: none; white-space: nowrap; pointer-events: none; white-space: nowrap;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
.geo-del-confirm {
display: none; position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
gap: 8px; align-items: center; white-space: nowrap;
background: rgba(18,10,32,.96); border: 1px solid rgba(155,93,229,.35);
border-radius: 10px; padding: 8px 12px; z-index: 20;
backdrop-filter: blur(8px); box-shadow: 0 4px 20px rgba(0,0,0,.6);
font-size: 0.72rem; color: rgba(255,255,255,.8);
}
.geo-del-confirm.visible { display: flex; }
.geo-del-confirm span { margin-right: 2px; }
.geo-del-btn {
padding: 3px 9px; border-radius: 6px; border: 1px solid;
font-size: 0.7rem; cursor: pointer; font-family: inherit;
transition: background .15s;
}
.geo-del-btn-soft { border-color: rgba(74,222,128,.4); color: #4ADE80; background: rgba(74,222,128,.08); }
.geo-del-btn-soft:hover { background: rgba(74,222,128,.18); }
.geo-del-btn-hard { border-color: rgba(248,113,113,.4); color: #f87171; background: rgba(248,113,113,.08); }
.geo-del-btn-hard:hover { background: rgba(248,113,113,.18); }
.geo-del-btn-cancel{ border-color: rgba(255,255,255,.15); color: rgba(255,255,255,.5); background: transparent; }
.geo-del-btn-cancel:hover{ background: rgba(255,255,255,.06); }
</style> </style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
@@ -3954,6 +3975,12 @@
<div class="geo-canvas-outer"> <div class="geo-canvas-outer">
<canvas id="geo-canvas"></canvas> <canvas id="geo-canvas"></canvas>
<div class="geo-hint-bar" id="geo-hint">Кликни для добавления точки</div> <div class="geo-hint-bar" id="geo-hint">Кликни для добавления точки</div>
<div class="geo-del-confirm" id="geo-del-confirm">
<span id="geo-del-msg"></span>
<button class="geo-del-btn geo-del-btn-soft" id="geo-del-soft">Только этот</button>
<button class="geo-del-btn geo-del-btn-hard" id="geo-del-hard">Со всеми зависимыми</button>
<button class="geo-del-btn geo-del-btn-cancel" id="geo-del-cancel">Отмена</button>
</div>
</div> </div>
</div><!-- /.sim-body-wrap --> </div><!-- /.sim-body-wrap -->
@@ -5441,6 +5468,35 @@
if (cEl) cEl.textContent = s.constructions || 0; if (cEl) cEl.textContent = s.constructions || 0;
} }
/* Диалог подтверждения удаления объекта с зависимыми */
let _geoDelSoftFn = null, _geoDelHardFn = null;
function _geoShowDeleteConfirm(obj, deps, softFn, hardFn) {
const panel = document.getElementById('geo-del-confirm');
const msg = document.getElementById('geo-del-msg');
if (!panel || !msg) { hardFn(); return; }
const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч',
circle:'окружность', polygon:'многоугольник', derived_line:'построение' };
const n = names[obj.type] || 'объект';
msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`;
_geoDelSoftFn = softFn;
_geoDelHardFn = hardFn;
panel.classList.add('visible');
}
function _geoHideDeleteConfirm() {
document.getElementById('geo-del-confirm')?.classList.remove('visible');
_geoDelSoftFn = _geoDelHardFn = null;
}
// Кнопки диалога — подключаем после DOM ready
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('geo-del-soft')?.addEventListener('click', () => {
_geoDelSoftFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
});
document.getElementById('geo-del-hard')?.addEventListener('click', () => {
_geoDelHardFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
});
document.getElementById('geo-del-cancel')?.addEventListener('click', _geoHideDeleteConfirm);
});
function _openGeometry() { function _openGeometry() {
document.getElementById('sim-topbar-title').textContent = 'Планиметрия'; document.getElementById('sim-topbar-title').textContent = 'Планиметрия';
_simShow('sim-geometry'); _simShow('sim-geometry');
@@ -5457,8 +5513,9 @@
const canvas = document.getElementById('geo-canvas'); const canvas = document.getElementById('geo-canvas');
if (!geomSim) { if (!geomSim) {
geomSim = new GeoSim(canvas); geomSim = new GeoSim(canvas);
geomSim.onUpdate = _geoUpdateStats; geomSim.onUpdate = _geoUpdateStats;
geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase > 1); geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase > 1);
geomSim.onDeleteRequest = _geoShowDeleteConfirm;
// keyboard shortcuts // keyboard shortcuts
canvas.setAttribute('tabindex', '0'); canvas.setAttribute('tabindex', '0');