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:
+131
-12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user