From 76428db5133182d31a4dd2f314ed0c0eba5f93ea Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 14 Apr 2026 11:00:19 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A4=D0=B0=D0=B7=D1=8B=206.1=E2=80=93?= =?UTF-8?q?6.3=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=E2=80=94=20=D0=B2=D1=8B=D1=81=D0=BE=D1=82=D1=8B?= =?UTF-8?q?,=20=D0=BF=D1=80=D1=8F=D0=BC=D1=8B=D0=B5=20=D1=83=D0=B3=D0=BB?= =?UTF-8?q?=D1=8B,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/js/labs/geometry.js | 143 ++++++++++++++++++++++++++++++++--- frontend/lab.html | 61 ++++++++++++++- 2 files changed, 190 insertions(+), 14 deletions(-) diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index 071de87..fe10e6a 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -165,16 +165,28 @@ class GeoEngine { } remove(id) { - this._objects.delete(id); - // Удалить объекты, зависящие от этой точки - for (const [oid, obj] of this._objects) { - if (this._dependsOn(obj, id)) this._objects.delete(oid); + // BFS-каскад: собрать все транзитивно зависимые объекты, затем удалить + const toDelete = new Set([id]); + let changed = true; + 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) { 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; case 'polygon': 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); } has(id) { return this._objects.has(id); } all() { return [...this._objects.values()]; } @@ -432,8 +453,9 @@ class GeoSim { this._hovered = null; /* ── Callbacks ── */ - this.onUpdate = null; // cb(stats) - this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки + this.onUpdate = null; // cb(stats) + this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки + this.onDeleteRequest = null; // cb(obj, deps, softFn, cascadeFn) — подтвердить удаление this._labelCounter = 0; 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('ray')) this._drawRay(ctx, obj); - // Отрезки - for (const obj of this.eng.byType('segment')) this._drawSegment(ctx, obj); + // Отрезки (виртуальные стороны полигонов не рисуем — они нарисованы как polygon stroke) + 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); // Окружности @@ -838,6 +862,7 @@ class GeoSim { const types = ['line','segment','ray']; for (const t of types) { for (const obj of this.eng.byType(t)) { + if (obj.virtual) continue; // виртуальные отрезки-стороны проверяем ниже через полигон const pts = this._twoPointsOnObj(obj); if (!pts) continue; 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 (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; } + /* Найти или создать виртуальный отрезок для стороны полигона */ + _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) { const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean); if (pts.length < 3) return; @@ -1025,6 +1070,47 @@ class GeoSim { 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; } } - // Отрезки + // Отрезки (виртуальные стороны полигонов не выбираемы напрямую) for (const obj of this.eng.byType('segment')) { + if (obj.virtual) continue; const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id); if (!m1||!m2) continue; if (gDistToSegment(m, m1, m2) * this.vp.scale < HIT) return obj; @@ -1875,8 +1962,40 @@ class GeoSim { /* ── Удалить выбранный объект ── */ deleteSelected() { 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.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.render(); if (this.onUpdate) this.onUpdate(this.getStats()); @@ -1902,7 +2021,7 @@ class GeoSim { const allPts = this.eng.points(); const pts = 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 polys= this.eng.byType('polygon').length; const derivedCircles = this.eng.byType('circle').filter(c => c.derived).length; diff --git a/frontend/lab.html b/frontend/lab.html index 6b68d6d..2d39cc4 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -748,6 +748,27 @@ pointer-events: none; white-space: nowrap; 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); } @@ -3954,6 +3975,12 @@
Кликни для добавления точки
+
+ + + + +
@@ -5441,6 +5468,35 @@ 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() { document.getElementById('sim-topbar-title').textContent = 'Планиметрия'; _simShow('sim-geometry'); @@ -5457,8 +5513,9 @@ const canvas = document.getElementById('geo-canvas'); if (!geomSim) { geomSim = new GeoSim(canvas); - geomSim.onUpdate = _geoUpdateStats; - geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase > 1); + geomSim.onUpdate = _geoUpdateStats; + geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase > 1); + geomSim.onDeleteRequest = _geoShowDeleteConfirm; // keyboard shortcuts canvas.setAttribute('tabindex', '0');