bdb81ba9c8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2549 lines
102 KiB
JavaScript
2549 lines
102 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════════════
|
|
geometry.js — Интерактивная планиметрия для LearnSpace
|
|
Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники
|
|
Phase 2: инструменты построения (середина, биссектрисы, параллельные,
|
|
перпендикуляры, пересечения) + система производных объектов
|
|
═══════════════════════════════════════════════════════════════════════ */
|
|
'use strict';
|
|
|
|
/* ── Утилиты ─────────────────────────────────────────────────────────── */
|
|
function gDist(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }
|
|
function gMid(a, b) { return { x: (a.x+b.x)/2, y: (a.y+b.y)/2 }; }
|
|
function gDot(a, b) { return a.x*b.x + a.y*b.y; }
|
|
function gNorm(v) { const l = Math.hypot(v.x,v.y); return l<1e-12?{x:0,y:0}:{x:v.x/l,y:v.y/l}; }
|
|
function gSub(a, b) { return { x: a.x-b.x, y: a.y-b.y }; }
|
|
function gAdd(a, b) { return { x: a.x+b.x, y: a.y+b.y }; }
|
|
function gScale(v, s) { return { x: v.x*s, y: v.y*s }; }
|
|
|
|
/** Проекция точки P на прямую через L1-L2 */
|
|
function gFoot(P, L1, L2) {
|
|
const dx = L2.x-L1.x, dy = L2.y-L1.y;
|
|
const l2 = dx*dx + dy*dy;
|
|
if (l2 < 1e-12) return { x: L1.x, y: L1.y };
|
|
const t = ((P.x-L1.x)*dx + (P.y-L1.y)*dy) / l2;
|
|
return { x: L1.x + t*dx, y: L1.y + t*dy };
|
|
}
|
|
|
|
/** Расстояние от точки до прямой через L1-L2 */
|
|
function gDistToLine(P, L1, L2) {
|
|
return gDist(P, gFoot(P, L1, L2));
|
|
}
|
|
|
|
/** Расстояние от точки до отрезка L1-L2 */
|
|
function gDistToSegment(P, L1, L2) {
|
|
const dx = L2.x-L1.x, dy = L2.y-L1.y;
|
|
const l2 = dx*dx + dy*dy;
|
|
if (l2 < 1e-12) return gDist(P, L1);
|
|
const t = Math.max(0, Math.min(1, ((P.x-L1.x)*dx+(P.y-L1.y)*dy)/l2));
|
|
return gDist(P, { x: L1.x+t*dx, y: L1.y+t*dy });
|
|
}
|
|
|
|
/** Пересечение двух прямых (через A1-A2 и B1-B2) */
|
|
function gIntersectLines(A1, A2, B1, B2) {
|
|
const dx1 = A2.x-A1.x, dy1 = A2.y-A1.y;
|
|
const dx2 = B2.x-B1.x, dy2 = B2.y-B1.y;
|
|
const det = dx1*dy2 - dy1*dx2;
|
|
if (Math.abs(det) < 1e-10) return null;
|
|
const t = ((B1.x-A1.x)*dy2 - (B1.y-A1.y)*dx2) / det;
|
|
return { x: A1.x + t*dx1, y: A1.y + t*dy1 };
|
|
}
|
|
|
|
/** Описанная окружность треугольника */
|
|
function gCircumcircle(A, B, C) {
|
|
const D = 2*(A.x*(B.y-C.y) + B.x*(C.y-A.y) + C.x*(A.y-B.y));
|
|
if (Math.abs(D) < 1e-10) return null;
|
|
const a2=A.x*A.x+A.y*A.y, b2=B.x*B.x+B.y*B.y, c2=C.x*C.x+C.y*C.y;
|
|
const cx = (a2*(B.y-C.y)+b2*(C.y-A.y)+c2*(A.y-B.y))/D;
|
|
const cy = (a2*(C.x-B.x)+b2*(A.x-C.x)+c2*(B.x-A.x))/D;
|
|
return { cx, cy, r: gDist({x:cx,y:cy}, A) };
|
|
}
|
|
|
|
/** Вписанная окружность треугольника */
|
|
function gIncircle(A, B, C) {
|
|
const a = gDist(B, C), b = gDist(A, C), c = gDist(A, B);
|
|
const s = a + b + c;
|
|
if (s < 1e-12) return null;
|
|
const cx = (a*A.x + b*B.x + c*C.x) / s;
|
|
const cy = (a*A.y + b*B.y + c*C.y) / s;
|
|
const area = gPolygonArea([A, B, C]);
|
|
const r = area / (s / 2);
|
|
return { cx, cy, r };
|
|
}
|
|
|
|
/** Точки касания двух касательных из внешней точки P к окружности (O, r) */
|
|
function gTangentPoints(O, P, r) {
|
|
const d = gDist(O, P);
|
|
if (d <= r + 1e-9) return null; // P внутри или на окружности
|
|
const t = (r * r) / d; // |OM|, M = нога с O на хорду касания
|
|
const h = r * Math.sqrt(d * d - r * r) / d; // |T₁M| = |T₂M|
|
|
const vx = (P.x - O.x) / d, vy = (P.y - O.y) / d; // O→P
|
|
const px = -vy, py = vx; // перпендикуляр
|
|
const M = { x: O.x + vx * t, y: O.y + vy * t };
|
|
return [
|
|
{ x: M.x + px * h, y: M.y + py * h },
|
|
{ x: M.x - px * h, y: M.y - py * h },
|
|
];
|
|
}
|
|
|
|
/** Угол ABC (в градусах, вершина B) */
|
|
function gAngleDeg(A, B, C) {
|
|
const v1 = gSub(A, B), v2 = gSub(C, B);
|
|
const cos = gDot(v1, v2) / (gDist(A,B) * gDist(C,B));
|
|
return Math.acos(Math.max(-1, Math.min(1, cos))) * 180 / Math.PI;
|
|
}
|
|
|
|
/** Ортоцентр треугольника ABC */
|
|
function gOrthocenter(A, B, C) {
|
|
const Ha = gFoot(A, B, C); // основание высоты из A на BC
|
|
const Hb = gFoot(B, A, C); // основание высоты из B на AC
|
|
return gIntersectLines(A, Ha, B, Hb);
|
|
}
|
|
|
|
/** Площадь многоугольника (формула Гаусса) */
|
|
function gPolygonArea(pts) {
|
|
let area = 0;
|
|
for (let i = 0, n = pts.length; i < n; i++) {
|
|
const j = (i+1)%n;
|
|
area += pts[i].x * pts[j].y - pts[j].x * pts[i].y;
|
|
}
|
|
return Math.abs(area) / 2;
|
|
}
|
|
|
|
/* ── Viewport (система координат) ───────────────────────────────────── */
|
|
class GeoViewport {
|
|
constructor() {
|
|
this.cx = 0; // центр в мат. координатах
|
|
this.cy = 0;
|
|
this.scale = 60; // пикселей на единицу
|
|
this.W = 800;
|
|
this.H = 600;
|
|
}
|
|
|
|
/** Математические → пиксельные */
|
|
toCanvas(mx, my) {
|
|
return {
|
|
x: this.W/2 + (mx - this.cx) * this.scale,
|
|
y: this.H/2 - (my - this.cy) * this.scale,
|
|
};
|
|
}
|
|
|
|
/** Пиксельные → математические */
|
|
toMath(px, py) {
|
|
return {
|
|
x: (px - this.W/2) / this.scale + this.cx,
|
|
y: -((py - this.H/2) / this.scale) + this.cy,
|
|
};
|
|
}
|
|
|
|
zoom(factor, px, py) {
|
|
const m = this.toMath(px, py);
|
|
this.scale = Math.max(10, Math.min(400, this.scale * factor));
|
|
// Зафиксировать точку под курсором
|
|
this.cx = m.x - (px - this.W/2) / this.scale;
|
|
this.cy = m.y + (py - this.H/2) / this.scale;
|
|
}
|
|
|
|
pan(dpx, dpy) {
|
|
this.cx -= dpx / this.scale;
|
|
this.cy += dpy / this.scale;
|
|
}
|
|
|
|
/** Расстояние в пикселях между двумя мат. точками */
|
|
toCanvasDist(md) { return md * this.scale; }
|
|
|
|
/** Пиксельное расстояние → мат. единицы */
|
|
toMathDist(pd) { return pd / this.scale; }
|
|
}
|
|
|
|
/* ── GeoEngine (хранилище объектов) ─────────────────────────────────── */
|
|
class GeoEngine {
|
|
constructor() {
|
|
this._objects = new Map();
|
|
this._counter = 0;
|
|
}
|
|
|
|
_newId() { return 'g' + (++this._counter); }
|
|
|
|
add(obj) {
|
|
if (!obj.id) obj.id = this._newId();
|
|
obj.style = obj.style || {};
|
|
this._objects.set(obj.id, obj);
|
|
return obj;
|
|
}
|
|
|
|
remove(id) {
|
|
// 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':
|
|
// Виртуальный отрезок-сторона полигона зависит от самого полигона
|
|
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);
|
|
case 'circle':
|
|
if (obj.derived) return obj.ptA === id || obj.ptB === id || obj.ptC === id;
|
|
return obj.centerId === id || obj.edgeId === id;
|
|
case 'point':
|
|
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 === 'centroid' || obj.constr === 'orthocenter')
|
|
return obj.ptA === id || obj.ptB === id || obj.ptC === id;
|
|
if (obj.constr === 'altitude_foot') return obj.ptA === id || obj.ptB === id || obj.ptC === id;
|
|
if (obj.constr === 'parallelogram_d') return obj.ptA === id || obj.ptB === id || obj.ptC === id;
|
|
if (obj.constr === 'scale') return obj.srcO === id || obj.srcPt === id;
|
|
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;
|
|
if (obj.constr === 'translate') return obj.srcA === id || obj.srcB === id || obj.srcPt === id;
|
|
return false;
|
|
case 'derived_line':
|
|
switch (obj.constr) {
|
|
case 'perpbisect': return obj.srcA === id || obj.srcB === id;
|
|
case 'anglebisect': return obj.srcA === id || obj.srcVtx === id || obj.srcB === id;
|
|
case 'parallel': case 'perpendicular':
|
|
return obj.srcPt === id || obj.srcDirPt1 === id || obj.srcDirPt2 === id;
|
|
case 'tangent': {
|
|
if (obj.srcPt === id || obj.srcCircle === id) return true;
|
|
// Зависим и от точек, определяющих окружность
|
|
const circ = this._objects.get(obj.srcCircle);
|
|
if (!circ) return false;
|
|
if (circ.derived) return circ.ptA === id || circ.ptB === id || circ.ptC === id;
|
|
return circ.centerId === id || circ.edgeId === id;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Перевычислить производный объект из его источников */
|
|
recompute(id) {
|
|
const obj = this._objects.get(id);
|
|
if (!obj || !obj.derived) return;
|
|
const _g = oid => this._objects.get(oid);
|
|
|
|
if (obj.type === 'point') {
|
|
if (obj.constr === 'midpoint') {
|
|
const a = _g(obj.srcA), b = _g(obj.srcB);
|
|
if (a && b) { obj.x = (a.x+b.x)/2; obj.y = (a.y+b.y)/2; }
|
|
} else if (obj.constr === 'intersect') {
|
|
// Вычислить пересечение двух прямых (линии/отрезки/лучи/derived_line)
|
|
const pts1 = this._twoMathPts(obj.src1);
|
|
const pts2 = this._twoMathPts(obj.src2);
|
|
if (pts1 && pts2) {
|
|
const pt = gIntersectLines(pts1[0], pts1[1], pts2[0], pts2[1]);
|
|
if (pt) { obj.x = pt.x; obj.y = pt.y; obj.valid = true; }
|
|
else obj.valid = false;
|
|
}
|
|
} else if (obj.constr === 'foot' || obj.constr === 'reflect') {
|
|
const srcPt = _g(obj.srcPt);
|
|
const sl = _g(obj.srcLine);
|
|
if (!srcPt || !sl) return;
|
|
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 = _g(sl.p1Id); L2 = _g(sl.p2Id);
|
|
}
|
|
if (L1 && L2) {
|
|
const f = gFoot({ x: srcPt.x, y: srcPt.y }, L1, L2);
|
|
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.constr === 'translate') {
|
|
const pA = _g(obj.srcA), pB = _g(obj.srcB), pP = _g(obj.srcPt);
|
|
if (pA && pB && pP) {
|
|
obj.x = pP.x + (pB.x - pA.x);
|
|
obj.y = pP.y + (pB.y - pA.y);
|
|
}
|
|
} else if (obj.constr === 'centroid') {
|
|
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
|
|
if (pA && pB && pC) {
|
|
obj.x = (pA.x + pB.x + pC.x) / 3;
|
|
obj.y = (pA.y + pB.y + pC.y) / 3;
|
|
}
|
|
} else if (obj.constr === 'orthocenter') {
|
|
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
|
|
if (pA && pB && pC) {
|
|
const h = gOrthocenter(pA, pB, pC);
|
|
if (h) { obj.x = h.x; obj.y = h.y; obj.valid = true; }
|
|
else { obj.valid = false; }
|
|
}
|
|
} else if (obj.constr === 'altitude_foot') {
|
|
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
|
|
if (pA && pB && pC) {
|
|
const f = gFoot(pA, pB, pC);
|
|
obj.x = f.x; obj.y = f.y;
|
|
}
|
|
} else if (obj.constr === 'parallelogram_d') {
|
|
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
|
|
if (pA && pB && pC) {
|
|
obj.x = pA.x + pC.x - pB.x;
|
|
obj.y = pA.y + pC.y - pB.y;
|
|
}
|
|
} else if (obj.constr === 'scale') {
|
|
const pO = _g(obj.srcO), pP = _g(obj.srcPt);
|
|
if (pO && pP) {
|
|
obj.x = pO.x + obj.k * (pP.x - pO.x);
|
|
obj.y = pO.y + obj.k * (pP.y - pO.y);
|
|
}
|
|
}
|
|
} else if (obj.type === 'circle' && obj.derived) {
|
|
const pA = _g(obj.ptA), pB = _g(obj.ptB), pC = _g(obj.ptC);
|
|
if (!pA || !pB || !pC) return;
|
|
const A = { x: pA.x, y: pA.y }, B = { x: pB.x, y: pB.y }, C = { x: pC.x, y: pC.y };
|
|
if (obj.constr === 'circumcircle') {
|
|
const cc = gCircumcircle(A, B, C);
|
|
if (cc) { obj.cx = cc.cx; obj.cy = cc.cy; obj.r = cc.r; obj.valid = true; }
|
|
else { obj.valid = false; }
|
|
} else if (obj.constr === 'incircle') {
|
|
const ic = gIncircle(A, B, C);
|
|
if (ic) { obj.cx = ic.cx; obj.cy = ic.cy; obj.r = ic.r; obj.valid = true; }
|
|
else { obj.valid = false; }
|
|
}
|
|
} else if (obj.type === 'derived_line') {
|
|
if (obj.constr === 'perpbisect') {
|
|
const a = _g(obj.srcA), b = _g(obj.srcB);
|
|
if (!a || !b) return;
|
|
obj.ptX = (a.x+b.x)/2; obj.ptY = (a.y+b.y)/2;
|
|
const dx = b.x-a.x, dy = b.y-a.y, len = Math.hypot(dx,dy);
|
|
if (len > 1e-12) { obj.dirX = -dy/len; obj.dirY = dx/len; }
|
|
} else if (obj.constr === 'anglebisect') {
|
|
const a = _g(obj.srcA), vtx = _g(obj.srcVtx), b = _g(obj.srcB);
|
|
if (!a || !vtx || !b) return;
|
|
const va = gNorm({x:a.x-vtx.x, y:a.y-vtx.y});
|
|
const vb = gNorm({x:b.x-vtx.x, y:b.y-vtx.y});
|
|
const bis = gNorm({x:va.x+vb.x, y:va.y+vb.y});
|
|
obj.ptX = vtx.x; obj.ptY = vtx.y;
|
|
obj.dirX = bis.x; obj.dirY = bis.y;
|
|
} else if (obj.constr === 'parallel') {
|
|
const srcPt = _g(obj.srcPt), d1 = _g(obj.srcDirPt1), d2 = _g(obj.srcDirPt2);
|
|
if (!srcPt || !d1 || !d2) return;
|
|
const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
|
|
if (len < 1e-12) return;
|
|
obj.ptX = srcPt.x; obj.ptY = srcPt.y;
|
|
obj.dirX = dx/len; obj.dirY = dy/len;
|
|
} else if (obj.constr === 'perpendicular') {
|
|
const srcPt = _g(obj.srcPt), d1 = _g(obj.srcDirPt1), d2 = _g(obj.srcDirPt2);
|
|
if (!srcPt || !d1 || !d2) return;
|
|
const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
|
|
if (len < 1e-12) return;
|
|
obj.ptX = srcPt.x; obj.ptY = srcPt.y;
|
|
obj.dirX = -dy/len; obj.dirY = dx/len;
|
|
} else if (obj.constr === 'tangent') {
|
|
const circ = _g(obj.srcCircle), pt = _g(obj.srcPt);
|
|
if (!circ || !pt) return;
|
|
let O, r;
|
|
if (circ.derived && circ.cx != null) {
|
|
O = { x: circ.cx, y: circ.cy }; r = circ.r;
|
|
} else {
|
|
const ctr = _g(circ.centerId), edg = _g(circ.edgeId);
|
|
if (!ctr || !edg) return;
|
|
O = { x: ctr.x, y: ctr.y }; r = gDist(O, { x: edg.x, y: edg.y });
|
|
}
|
|
const P = { x: pt.x, y: pt.y };
|
|
const tpts = gTangentPoints(O, P, r);
|
|
if (!tpts) { obj.valid = false; return; }
|
|
const T = tpts[obj.which];
|
|
const dx = T.x - P.x, dy = T.y - P.y, len = Math.hypot(dx, dy);
|
|
if (len < 1e-12) { obj.valid = false; return; }
|
|
obj.ptX = P.x; obj.ptY = P.y;
|
|
obj.dirX = dx/len; obj.dirY = dy/len;
|
|
obj.valid = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Возвращает два математических точки на объекте-линии (line/segment/ray/derived_line) */
|
|
_twoMathPts(id) {
|
|
const obj = this._objects.get(id);
|
|
if (!obj) return null;
|
|
if (obj.type === 'derived_line') {
|
|
return [{ x:obj.ptX, y:obj.ptY }, { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }];
|
|
}
|
|
const p1 = this._objects.get(obj.p1Id), p2 = this._objects.get(obj.p2Id);
|
|
if (!p1 || !p2) return null;
|
|
return [{ x:p1.x, y:p1.y }, { x:p2.x, y:p2.y }];
|
|
}
|
|
|
|
/* Перевычислить все производные объекты, зависящие от changedId */
|
|
propagateDeps(changedId) {
|
|
for (const obj of this._objects.values()) {
|
|
if (this._dependsOn(obj, changedId)) {
|
|
this.recompute(obj.id);
|
|
// Каскадная цепочка: производная точка может быть источником для других
|
|
if (obj.derived) this.propagateDeps(obj.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Прямые зависимые объекты (один уровень) */
|
|
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()]; }
|
|
byType(t) { return this.all().filter(o => o.type === t); }
|
|
points() { return this.byType('point'); }
|
|
|
|
movePoint(id, x, y) {
|
|
const obj = this._objects.get(id);
|
|
if (obj && obj.type === 'point' && !obj.derived && !obj.locked) {
|
|
obj.x = x; obj.y = y;
|
|
this.propagateDeps(id);
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
this._objects.clear();
|
|
this._counter = 0;
|
|
}
|
|
|
|
serialize() {
|
|
return JSON.stringify([...this._objects.values()]);
|
|
}
|
|
|
|
deserialize(json) {
|
|
this._objects.clear();
|
|
const arr = JSON.parse(json);
|
|
for (const obj of arr) this._objects.set(obj.id, obj);
|
|
this._counter = Math.max(...arr.map(o => parseInt(o.id.slice(1))||0), 0);
|
|
}
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════
|
|
GeoSim — главный класс интерактивной планиметрии
|
|
══════════════════════════════════════════════════════════════════════ */
|
|
class GeoSim {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.vp = new GeoViewport();
|
|
this.eng = new GeoEngine();
|
|
|
|
/* ── Состояние инструментов ── */
|
|
this.tool = 'select';
|
|
this._pending = []; // промежуточные клики многошаговых инструментов
|
|
this._preview = null; // предпросмотр (курсор при рисовании)
|
|
this._pendingLineRef = null; // первый кликнутый объект для parallel/perp/intersect/reflect/foot
|
|
this._pendingCircRef = null; // первый кликнутый объект-окружность для tangent
|
|
this._pendingScaleO = null; // центр подобия для инструмента scale
|
|
this._scaleK = 2; // коэффициент подобия
|
|
|
|
/* ── Состояние drag/pan ── */
|
|
this._drag = null; // { id, offX, offY } — перетаскиваем точку
|
|
this._panning = false;
|
|
this._panLast = null;
|
|
|
|
/* ── Snap ── */
|
|
this._snapPt = null; // { x, y } в мат. координатах
|
|
this._snapId = null; // ID точки-снапа
|
|
|
|
/* ── Undo/Redo ── */
|
|
this._undoStack = [];
|
|
this._redoStack = [];
|
|
|
|
/* ── Опции ── */
|
|
this.showGrid = true;
|
|
this.showAxes = true;
|
|
this.showLabels = true;
|
|
this.showLengths = false;
|
|
this.showAngles = false;
|
|
this.readOnly = false;
|
|
|
|
/* ── Выбранный объект ── */
|
|
this._selected = null;
|
|
this._hovered = null;
|
|
|
|
/* ── Callbacks ── */
|
|
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; // для инструмента правильного многоугольника
|
|
this._bindEvents();
|
|
}
|
|
|
|
setNgonSides(n) {
|
|
this._ngonSides = Math.max(3, Math.min(20, n));
|
|
}
|
|
|
|
setScaleK(k) {
|
|
this._scaleK = +k || 2;
|
|
}
|
|
|
|
/* ── Инициализация ─────────────────────────────────────────── */
|
|
fit() {
|
|
const c = this.canvas;
|
|
c.width = c.offsetWidth || c.parentElement?.offsetWidth || 800;
|
|
c.height = c.offsetHeight || c.parentElement?.offsetHeight || 600;
|
|
this.vp.W = c.width;
|
|
this.vp.H = c.height;
|
|
this.render();
|
|
}
|
|
|
|
setTool(name) {
|
|
this.tool = name;
|
|
this._pending = [];
|
|
this._preview = null;
|
|
this._selected = null;
|
|
this._pendingLineRef = null;
|
|
this._pendingCircRef = null;
|
|
this._pendingScaleO = null;
|
|
this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
|
|
this.render();
|
|
}
|
|
|
|
setReadOnly(v) {
|
|
this.readOnly = v;
|
|
if (v) { this.setTool('select'); this.canvas.style.cursor = 'default'; }
|
|
}
|
|
|
|
/* ── Следующая буква-метка ─────────────────────────────────── */
|
|
_nextLabel() {
|
|
const used = new Set(this.eng.points().map(p => p.label));
|
|
for (let i = 0; i < 26; i++) {
|
|
const l = String.fromCharCode(65+i);
|
|
if (!used.has(l)) return l;
|
|
}
|
|
return String(++this._labelCounter);
|
|
}
|
|
|
|
/* ══ РЕНДЕР ══════════════════════════════════════════════════ */
|
|
render() {
|
|
const ctx = this.ctx;
|
|
const { W, H } = this.vp;
|
|
if (!W || !H) return;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
this._drawBg(ctx, W, H);
|
|
if (this.showGrid) this._drawGrid(ctx, W, H);
|
|
|
|
// Заливки многоугольников
|
|
for (const obj of this.eng.byType('polygon')) this._drawPolyFill(ctx, obj);
|
|
// Производные прямые (под основными объектами)
|
|
for (const obj of this.eng.byType('derived_line')) this._drawDerivedLine(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);
|
|
// Отрезки (виртуальные стороны полигонов не рисуем — они нарисованы как 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);
|
|
// Окружности
|
|
for (const obj of this.eng.byType('circle')) this._drawCircle(ctx, obj);
|
|
// Измерения
|
|
if (this.showLengths) this._drawLengths(ctx);
|
|
if (this.showAngles) this._drawAngleMeasures(ctx);
|
|
// Точки поверх всего (включая производные)
|
|
for (const obj of this.eng.points()) this._drawPoint(ctx, obj);
|
|
// Предпросмотр строящегося объекта
|
|
this._drawPreview(ctx);
|
|
// Подсветка первого объекта при инструментах построения
|
|
if (this._pendingLineRef) this._drawLineRefHighlight(ctx, this._pendingLineRef);
|
|
if (this._pendingCircRef) this._drawLineRefHighlight(ctx, this._pendingCircRef);
|
|
// Индикатор снапа
|
|
if (this._snapPt) this._drawSnapIndicator(ctx);
|
|
}
|
|
|
|
_drawBg(ctx, W, H) {
|
|
const bg = ctx.createLinearGradient(0, 0, W, H);
|
|
bg.addColorStop(0, '#0a0718');
|
|
bg.addColorStop(1, '#0e0d22');
|
|
ctx.fillStyle = bg;
|
|
ctx.fillRect(0, 0, W, H);
|
|
}
|
|
|
|
_drawGrid(ctx, W, H) {
|
|
const vp = this.vp;
|
|
// Определяем шаг сетки в мат. единицах (красивые числа)
|
|
const rawStep = 80 / vp.scale; // примерно 80px между линиями
|
|
const exp = Math.floor(Math.log10(rawStep));
|
|
const frac = rawStep / Math.pow(10, exp);
|
|
let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
|
|
step *= Math.pow(10, exp);
|
|
step = Math.max(0.001, step);
|
|
|
|
const pxStep = step * vp.scale;
|
|
|
|
// Математические границы видимой области
|
|
const mLeft = vp.toMath(0, 0).x;
|
|
const mRight = vp.toMath(W, 0).x;
|
|
const mBot = vp.toMath(0, H).y;
|
|
const mTop = vp.toMath(0, 0).y;
|
|
|
|
const x0 = Math.floor(mLeft / step) * step;
|
|
const y0 = Math.floor(mBot / step) * step;
|
|
|
|
// Подсетка (× 0.2)
|
|
if (pxStep > 30) {
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.025)';
|
|
ctx.lineWidth = 0.5;
|
|
const sub = step / 5;
|
|
for (let mx = Math.floor(mLeft/sub)*sub; mx <= mRight+sub; mx += sub) {
|
|
const px = vp.toCanvas(mx, 0).x;
|
|
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
|
}
|
|
for (let my = Math.floor(mBot/sub)*sub; my <= mTop+sub; my += sub) {
|
|
const py = vp.toCanvas(0, my).y;
|
|
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Основная сетка
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
|
ctx.lineWidth = 1;
|
|
for (let mx = x0; mx <= mRight + step; mx += step) {
|
|
const px = vp.toCanvas(mx, 0).x;
|
|
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
|
}
|
|
for (let my = y0; my <= mTop + step; my += step) {
|
|
const py = vp.toCanvas(0, my).y;
|
|
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
|
|
}
|
|
|
|
if (!this.showAxes) return;
|
|
|
|
// Оси
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
|
|
ctx.lineWidth = 1.5;
|
|
const ox = vp.toCanvas(0, 0).x, oy = vp.toCanvas(0, 0).y;
|
|
if (ox >= 0 && ox <= W) {
|
|
ctx.beginPath(); ctx.moveTo(ox, 0); ctx.lineTo(ox, H); ctx.stroke();
|
|
}
|
|
if (oy >= 0 && oy <= H) {
|
|
ctx.beginPath(); ctx.moveTo(0, oy); ctx.lineTo(W, oy); ctx.stroke();
|
|
}
|
|
|
|
// Подписи осей
|
|
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
|
ctx.font = '11px Manrope,sans-serif';
|
|
ctx.textAlign = 'center';
|
|
|
|
// Подписи по X
|
|
for (let mx = x0; mx <= mRight + step; mx += step) {
|
|
if (Math.abs(mx) < step*0.01) continue;
|
|
const px = vp.toCanvas(mx, 0).x;
|
|
const py = Math.min(H-10, Math.max(14, oy + 14));
|
|
ctx.fillText(String(Math.round(mx*1000)/1000), px, py);
|
|
}
|
|
// Подписи по Y
|
|
ctx.textAlign = 'right';
|
|
for (let my = y0; my <= mTop + step; my += step) {
|
|
if (Math.abs(my) < step*0.01) continue;
|
|
const py = vp.toCanvas(0, my).y;
|
|
const px = Math.max(24, Math.min(W-4, ox - 6));
|
|
ctx.fillText(String(Math.round(my*1000)/1000), px, py + 4);
|
|
}
|
|
|
|
// Стрелки на концах осей
|
|
ctx.fillStyle = 'rgba(255,255,255,0.25)';
|
|
if (ox >= 0 && ox <= W) {
|
|
ctx.beginPath(); ctx.moveTo(ox,4); ctx.lineTo(ox-4,14); ctx.lineTo(ox+4,14); ctx.closePath(); ctx.fill();
|
|
}
|
|
if (oy >= 0 && oy <= H) {
|
|
ctx.beginPath(); ctx.moveTo(W-4,oy); ctx.lineTo(W-14,oy-4); ctx.lineTo(W-14,oy+4); ctx.closePath(); ctx.fill();
|
|
}
|
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
ctx.font = 'italic 13px serif';
|
|
ctx.textAlign = 'left';
|
|
if (oy > 4 && oy < H) ctx.fillText('y', ox+6, 14);
|
|
if (ox > 0 && ox < W) ctx.fillText('x', W-14, oy-6);
|
|
}
|
|
|
|
/* ── Отрисовка объектов ──────────────────────────────────────── */
|
|
_p(id) {
|
|
const o = this.eng.get(id);
|
|
return o ? this.vp.toCanvas(o.x, o.y) : null;
|
|
}
|
|
|
|
_mpt(id) {
|
|
const o = this.eng.get(id);
|
|
return o ? { x: o.x, y: o.y } : null;
|
|
}
|
|
|
|
_lineColor(obj) {
|
|
return obj.style?.color || this._catColor(obj._cat || 'default');
|
|
}
|
|
|
|
_catColor(cat) {
|
|
const MAP = {
|
|
segment: '#9B5DE5', line: '#06D6E0', ray: '#F15BB5',
|
|
circle: '#FFB347', polygon: '#22d55e', default: '#9B5DE5',
|
|
};
|
|
return MAP[cat] || MAP.default;
|
|
}
|
|
|
|
_isSelected(obj) { return this._selected?.id === obj.id; }
|
|
_isHovered(obj) { return this._hovered?.id === obj.id; }
|
|
|
|
_drawPoint(ctx, obj) {
|
|
const { x: px, y: py } = this.vp.toCanvas(obj.x, obj.y);
|
|
const sel = this._isSelected(obj);
|
|
const hov = this._isHovered(obj);
|
|
// Производные точки рисуем иначе (меньше, другой цвет, пунктирный ободок)
|
|
const isDerived = !!obj.derived;
|
|
const col = obj.style?.color || (isDerived ? '#22d55e' : '#fff');
|
|
const r = isDerived ? 4 : (obj.style?.size || 5);
|
|
|
|
if (sel || hov) {
|
|
ctx.save();
|
|
ctx.shadowColor = col; ctx.shadowBlur = 16;
|
|
ctx.strokeStyle = col; ctx.lineWidth = 1.5;
|
|
if (isDerived) ctx.setLineDash([3,3]);
|
|
ctx.beginPath(); ctx.arc(px, py, r+5, 0, Math.PI*2); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.shadowColor = col; ctx.shadowBlur = 8;
|
|
if (isDerived) {
|
|
// Производные точки: только контур + центр
|
|
ctx.globalAlpha = 0.85;
|
|
ctx.strokeStyle = col; ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.stroke();
|
|
ctx.fillStyle = col; ctx.globalAlpha = 0.5;
|
|
ctx.beginPath(); ctx.arc(px, py, r*0.5, 0, Math.PI*2); ctx.fill();
|
|
} else {
|
|
ctx.fillStyle = col;
|
|
ctx.beginPath(); ctx.arc(px, py, r, 0, Math.PI*2); ctx.fill();
|
|
ctx.shadowBlur = 0;
|
|
ctx.fillStyle = 'rgba(255,255,255,0.6)';
|
|
ctx.beginPath(); ctx.arc(px, py, r*0.38, 0, Math.PI*2); ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
|
|
if (this.showLabels && obj.label) {
|
|
ctx.save();
|
|
ctx.font = isDerived ? '12px Manrope,sans-serif' : 'bold 14px Manrope,sans-serif';
|
|
ctx.fillStyle = isDerived ? col : '#fff';
|
|
ctx.globalAlpha = isDerived ? 0.85 : 1;
|
|
ctx.shadowColor = 'rgba(0,0,0,0.8)';
|
|
ctx.shadowBlur = 4;
|
|
ctx.fillText(obj.label, px+9, py-9);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
_drawSegment(ctx, obj) {
|
|
const p1 = this._p(obj.p1Id), p2 = this._p(obj.p2Id);
|
|
if (!p1 || !p2) return;
|
|
const col = obj.style?.color || '#9B5DE5';
|
|
const sel = this._isSelected(obj);
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
|
|
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
|
|
if (obj.style?.dash) ctx.setLineDash(obj.style.dash);
|
|
ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke();
|
|
ctx.restore();
|
|
if (obj.tickMark) this._drawTickMark(ctx, p1, p2, obj.tickMark, col);
|
|
if (obj.parallelMark) this._drawParallelMark(ctx, p1, p2, obj.parallelMark, col);
|
|
if (this.showLabels && obj.label) this._drawObjLabel(ctx, obj.label, {x:(p1.x+p2.x)/2, y:(p1.y+p2.y)/2}, col);
|
|
}
|
|
|
|
/* Метки равных сторон (штрихи поперёк отрезка) */
|
|
_drawTickMark(ctx, p1, p2, count, col) {
|
|
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
|
const len = Math.hypot(dx, dy);
|
|
if (len < 1e-9 || !count) return;
|
|
const ux = dx / len, uy = dy / len; // вдоль отрезка
|
|
const nx = -uy, ny = ux; // перпендикуляр
|
|
const TICK = 7, SPACING = 5;
|
|
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
|
ctx.save();
|
|
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.globalAlpha = 0.9;
|
|
ctx.shadowColor = col; ctx.shadowBlur = 3;
|
|
for (let k = 0; k < count; k++) {
|
|
const off = (k - (count - 1) / 2) * SPACING;
|
|
const cx = mx + ux * off, cy = my + uy * off;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + nx * TICK, cy + ny * TICK);
|
|
ctx.lineTo(cx - nx * TICK, cy - ny * TICK);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/* Метки параллельных линий (шевроны >) */
|
|
_drawParallelMark(ctx, p1, p2, count, col) {
|
|
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
|
const len = Math.hypot(dx, dy);
|
|
if (len < 1e-9 || !count) return;
|
|
const ux = dx / len, uy = dy / len;
|
|
const nx = -uy, ny = ux;
|
|
const W = 5, H = 5, SPACING = 8; // полуширина, полувысота, отступ между шевронами
|
|
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
|
ctx.save();
|
|
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.globalAlpha = 0.9;
|
|
ctx.shadowColor = col; ctx.shadowBlur = 3;
|
|
for (let k = 0; k < count; k++) {
|
|
const off = (k - (count - 1) / 2) * SPACING;
|
|
const cx = mx + ux * off, cy = my + uy * off;
|
|
// Шеврон: два отрезка от "хвоста" к "острию"
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx - ux*W + nx*H, cy - uy*W + ny*H);
|
|
ctx.lineTo(cx + ux*W, cy + uy*W);
|
|
ctx.lineTo(cx - ux*W - nx*H, cy - uy*W - ny*H);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawLine(ctx, obj) {
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
if (!m1 || !m2) return;
|
|
const { W, H, vp } = { W: this.vp.W, H: this.vp.H, vp: this.vp };
|
|
// Расширить до границ экрана
|
|
const [p1c, p2c] = this._extendToEdges(m1, m2);
|
|
if (!p1c || !p2c) return;
|
|
const col = obj.style?.color || '#06D6E0';
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = obj.style?.width || 1.5;
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.shadowColor = col; ctx.shadowBlur = 3;
|
|
ctx.setLineDash([6, 6]);
|
|
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawRay(ctx, obj) {
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
if (!m1 || !m2) return;
|
|
const endC = this._extendOneWay(m1, m2);
|
|
const p1c = this.vp.toCanvas(m1.x, m1.y);
|
|
if (!endC) return;
|
|
const col = obj.style?.color || '#F15BB5';
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = obj.style?.width || 1.5;
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.shadowColor = col; ctx.shadowBlur = 3;
|
|
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(endC.x, endC.y); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ── Производная прямая (dashed, lighter) ── */
|
|
_drawDerivedLine(ctx, obj) {
|
|
if (!obj.ptX && obj.ptX !== 0) return;
|
|
const m1 = { x: obj.ptX, y: obj.ptY };
|
|
const m2 = { x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY };
|
|
const [p1c, p2c] = this._extendToEdges(m1, m2);
|
|
const col = obj.style?.color || '#4CC9F0';
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.globalAlpha = 0.7;
|
|
ctx.setLineDash([8, 5]);
|
|
ctx.shadowColor = col; ctx.shadowBlur = 4;
|
|
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
|
ctx.restore();
|
|
// Подпись типа
|
|
if (this.showLabels && obj.label) {
|
|
const mid = { x:(p1c.x+p2c.x)/2, y:(p1c.y+p2c.y)/2 };
|
|
ctx.save(); ctx.font = '11px Manrope,sans-serif';
|
|
ctx.fillStyle = col; ctx.globalAlpha = 0.8;
|
|
ctx.fillText(obj.label, mid.x + 6, mid.y - 6);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
/* Подсветить объект (первый клик в parallel/perpendicular/intersect/tangent/...) */
|
|
_drawLineRefHighlight(ctx, obj) {
|
|
if (!obj) return;
|
|
ctx.save();
|
|
ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.globalAlpha = 0.55;
|
|
ctx.setLineDash([6, 4]);
|
|
if (obj.type === 'circle') {
|
|
// Подсветка окружности (для инструмента tangent)
|
|
let cx, cy, r;
|
|
if (obj.derived && obj.cx != null) {
|
|
const c = this.vp.toCanvas(obj.cx, obj.cy);
|
|
cx = c.x; cy = c.y; r = this.vp.toCanvasDist(obj.r);
|
|
} else {
|
|
const c = this._p(obj.centerId), e = this._p(obj.edgeId);
|
|
if (!c || !e) { ctx.restore(); return; }
|
|
cx = c.x; cy = c.y; r = gDist(c, e);
|
|
}
|
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.stroke();
|
|
} else {
|
|
let p1c, p2c;
|
|
if (obj.type === 'derived_line') {
|
|
const m1 = { x:obj.ptX, y:obj.ptY }, m2 = { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY };
|
|
[p1c, p2c] = this._extendToEdges(m1, m2);
|
|
} else {
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
if (!m1 || !m2) { ctx.restore(); return; }
|
|
if (obj.type === 'segment') { p1c = this.vp.toCanvas(m1.x,m1.y); p2c = this.vp.toCanvas(m2.x,m2.y); }
|
|
else [p1c, p2c] = this._extendToEdges(m1, m2);
|
|
}
|
|
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/* Найти окружность под курсором (для инструмента tangent) */
|
|
_hitTestCircle(px, py) {
|
|
const HIT = 12, m = this.vp.toMath(px, py);
|
|
for (const obj of this.eng.byType('circle')) {
|
|
let O, r;
|
|
if (obj.derived && obj.cx != null) {
|
|
O = { x: obj.cx, y: obj.cy }; r = obj.r;
|
|
} else {
|
|
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
|
|
if (!mc || !me) continue;
|
|
O = mc; r = gDist(mc, me);
|
|
}
|
|
if (Math.abs(gDist(m, O) - r) * this.vp.scale < HIT) return obj;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* Вернуть две мат. точки на объекте (line/segment/ray/derived_line) — для хит-теста и пересечений */
|
|
_twoPointsOnObj(obj) {
|
|
if (!obj) return null;
|
|
if (obj.type === 'derived_line') {
|
|
return [{ x:obj.ptX, y:obj.ptY }, { x:obj.ptX+obj.dirX, y:obj.ptY+obj.dirY }];
|
|
}
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
return (m1 && m2) ? [m1, m2] : null;
|
|
}
|
|
|
|
/* Найти линейный объект под курсором (для инструментов построения) */
|
|
_hitTestLine(px, py) {
|
|
const HIT = 12, m = this.vp.toMath(px, py);
|
|
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]);
|
|
if (d * this.vp.scale < HIT) return obj;
|
|
}
|
|
}
|
|
for (const obj of this.eng.byType('derived_line')) {
|
|
const pts = this._twoPointsOnObj(obj);
|
|
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;
|
|
const col = obj.style?.fillColor || 'rgba(155,93,229,0.12)';
|
|
ctx.save();
|
|
ctx.fillStyle = col;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0].x, pts[0].y);
|
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
ctx.closePath(); ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawPolyStroke(ctx, obj) {
|
|
const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
|
|
if (pts.length < 2) return;
|
|
const col = obj.style?.color || '#22d55e';
|
|
const sel = this._isSelected(obj);
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
|
|
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0].x, pts[0].y);
|
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
ctx.closePath(); ctx.stroke();
|
|
ctx.restore();
|
|
// Метки равных сторон (штрихи) и параллельных сторон (шевроны)
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const j = (i+1) % pts.length;
|
|
if (obj.sideMarks?.[i]) this._drawTickMark(ctx, pts[i], pts[j], obj.sideMarks[i], col);
|
|
if (obj.parallelSideMarks?.[i]) this._drawParallelMark(ctx, pts[i], pts[j], obj.parallelSideMarks[i], col);
|
|
}
|
|
}
|
|
|
|
_drawCircle(ctx, obj) {
|
|
let cx, cy, r;
|
|
const isDerived = obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle');
|
|
if (isDerived) {
|
|
if (!obj.valid) return;
|
|
const c = this.vp.toCanvas(obj.cx, obj.cy);
|
|
cx = c.x; cy = c.y;
|
|
r = this.vp.toCanvasDist(obj.r);
|
|
} else {
|
|
const c = this._p(obj.centerId), e = this._p(obj.edgeId);
|
|
if (!c || !e) return;
|
|
cx = c.x; cy = c.y;
|
|
r = gDist(c, e);
|
|
}
|
|
const col = obj.style?.color || '#FFB347';
|
|
const sel = this._isSelected(obj);
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = obj.style?.width || (sel ? 2.5 : 2);
|
|
ctx.shadowColor = col; ctx.shadowBlur = sel ? 10 : 4;
|
|
ctx.globalAlpha = isDerived ? 0.75 : 0.9;
|
|
if (isDerived) ctx.setLineDash([7, 4]);
|
|
ctx.fillStyle = obj.style?.fillColor || `rgba(255,179,71,0.04)`;
|
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.restore();
|
|
if (this.showLabels && obj.label)
|
|
this._drawObjLabel(ctx, obj.label, { x: cx, y: cy - r - 8 }, col);
|
|
}
|
|
|
|
_drawObjLabel(ctx, label, pos, col) {
|
|
ctx.save();
|
|
ctx.font = '12px Manrope,sans-serif';
|
|
ctx.fillStyle = col || '#fff';
|
|
ctx.textAlign = 'center';
|
|
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 4;
|
|
ctx.fillText(label, pos.x, pos.y);
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawLengths(ctx) {
|
|
for (const seg of this.eng.byType('segment')) {
|
|
const m1 = this._mpt(seg.p1Id), m2 = this._mpt(seg.p2Id);
|
|
if (!m1 || !m2) continue;
|
|
const len = gDist(m1, m2);
|
|
const mid = this.vp.toCanvas((m1.x+m2.x)/2, (m1.y+m2.y)/2);
|
|
const dx = m2.x-m1.x, dy = m2.y-m1.y;
|
|
const nx = -dy / Math.hypot(dx,dy) * 14;
|
|
const ny = dx / Math.hypot(dx,dy) * 14; // отступ перпендикулярно
|
|
ctx.save();
|
|
ctx.font = '11px Manrope,sans-serif';
|
|
ctx.fillStyle = seg.style?.color || '#9B5DE5';
|
|
ctx.textAlign = 'center';
|
|
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 4;
|
|
ctx.fillText(len.toFixed(2), mid.x + nx, mid.y - ny + 4);
|
|
ctx.restore();
|
|
}
|
|
for (const poly of this.eng.byType('polygon')) {
|
|
const ids = poly.pointIds;
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const m1 = this._mpt(ids[i]), m2 = this._mpt(ids[(i+1)%ids.length]);
|
|
if (!m1 || !m2) continue;
|
|
const len = gDist(m1, m2);
|
|
const mid = this.vp.toCanvas((m1.x+m2.x)/2, (m1.y+m2.y)/2);
|
|
ctx.save();
|
|
ctx.font = '11px Manrope,sans-serif';
|
|
ctx.fillStyle = poly.style?.color || '#22d55e';
|
|
ctx.textAlign = 'center';
|
|
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 4;
|
|
ctx.fillText(len.toFixed(2), mid.x, mid.y - 8);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
}
|
|
|
|
_drawAngleMeasures(ctx) {
|
|
const ARC_R = 22; // радиус дуги угла, пикселей
|
|
const SQ_SZ = 11; // сторона квадрата прямого угла, пикселей
|
|
const LBL_D = 14; // отступ подписи от дуги/квадрата, пикселей
|
|
|
|
for (const poly of this.eng.byType('polygon')) {
|
|
const ids = poly.pointIds;
|
|
const n = ids.length;
|
|
for (let i = 0; i < n; i++) {
|
|
const A = this._mpt(ids[(i-1+n)%n]);
|
|
const B = this._mpt(ids[i]);
|
|
const C = this._mpt(ids[(i+1)%n]);
|
|
if (!A || !B || !C) continue;
|
|
|
|
const angle = gAngleDeg(A, B, C);
|
|
const Apx = this.vp.toCanvas(A.x, A.y);
|
|
const Bpx = this.vp.toCanvas(B.x, B.y);
|
|
const Cpx = this.vp.toCanvas(C.x, C.y);
|
|
const col = poly.style?.color || '#FFE066';
|
|
|
|
// Единичные векторы B→A и B→C в пиксельных координатах
|
|
const dAx = Apx.x - Bpx.x, dAy = Apx.y - Bpx.y;
|
|
const dCx = Cpx.x - Bpx.x, dCy = Cpx.y - Bpx.y;
|
|
const lenA = Math.hypot(dAx, dAy), lenC = Math.hypot(dCx, dCy);
|
|
if (lenA < 1e-4 || lenC < 1e-4) continue;
|
|
const uAx = dAx/lenA, uAy = dAy/lenA;
|
|
const uCx = dCx/lenC, uCy = dCy/lenC;
|
|
|
|
// Биссектриса угла в пиксельных координатах
|
|
const bisX = uAx + uCx, bisY = uAy + uCy;
|
|
const bisLen = Math.hypot(bisX, bisY);
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = col;
|
|
ctx.lineWidth = 1.5;
|
|
|
|
const explicitMark = poly.angleMarks?.[i] || 0; // 0=auto, 1-3=явный
|
|
if (explicitMark > 0 && bisLen > 1e-6) {
|
|
// Явные метки: 1-3 концентрические дуги
|
|
const midAngle = Math.atan2(bisY, bisX);
|
|
const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2;
|
|
ctx.globalAlpha = 0.7;
|
|
for (let k = 0; k < explicitMark; k++) {
|
|
ctx.beginPath();
|
|
ctx.arc(Bpx.x, Bpx.y, ARC_R + k * 7, midAngle - halfSpread, midAngle + halfSpread, false);
|
|
ctx.stroke();
|
|
}
|
|
} else if (Math.abs(angle - 90) < 2) {
|
|
// Прямой угол — маленький квадрат
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(Bpx.x + uAx*SQ_SZ, Bpx.y + uAy*SQ_SZ);
|
|
ctx.lineTo(Bpx.x + uAx*SQ_SZ + uCx*SQ_SZ, Bpx.y + uAy*SQ_SZ + uCy*SQ_SZ);
|
|
ctx.lineTo(Bpx.x + uCx*SQ_SZ, Bpx.y + uCy*SQ_SZ);
|
|
ctx.stroke();
|
|
} else if (bisLen > 1e-6) {
|
|
// Дуга угла — от midAngle-halfSpread до midAngle+halfSpread через биссектрису
|
|
const midAngle = Math.atan2(bisY, bisX);
|
|
const halfSpread = Math.acos(Math.max(-1, Math.min(1, uAx*uCx + uAy*uCy))) / 2;
|
|
ctx.globalAlpha = 0.7;
|
|
ctx.beginPath();
|
|
ctx.arc(Bpx.x, Bpx.y, ARC_R, midAngle - halfSpread, midAngle + halfSpread, false);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Подпись угла вдоль биссектрисы
|
|
if (bisLen > 1e-6) {
|
|
const ldist = (explicitMark > 0 ? ARC_R + (explicitMark-1)*7 : Math.abs(angle - 90) < 2 ? SQ_SZ : ARC_R) + LBL_D;
|
|
const bx = bisX / bisLen, by = bisY / bisLen;
|
|
ctx.font = '10px Manrope,sans-serif';
|
|
ctx.fillStyle = col;
|
|
ctx.globalAlpha = 0.9;
|
|
ctx.textAlign = 'center';
|
|
ctx.shadowColor = 'rgba(0,0,0,0.9)'; ctx.shadowBlur = 3;
|
|
ctx.fillText(angle.toFixed(1) + '°', Bpx.x + bx*ldist, Bpx.y + by*ldist + 3);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// Прямые углы для foot и altitude_foot конструкций
|
|
for (const obj of this.eng.points()) {
|
|
if (!obj.derived || (obj.constr !== 'foot' && obj.constr !== 'altitude_foot')) continue;
|
|
const F = this.vp.toCanvas(obj.x, obj.y);
|
|
let P, L1m, L2m;
|
|
if (obj.constr === 'altitude_foot') {
|
|
P = this.eng.get(obj.ptA);
|
|
L1m = this._mpt(obj.ptB); L2m = this._mpt(obj.ptC);
|
|
} else {
|
|
P = this.eng.get(obj.srcPt);
|
|
const sl = this.eng.get(obj.srcLine);
|
|
if (!sl) continue;
|
|
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 (!P || !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();
|
|
}
|
|
}
|
|
|
|
/* ── Предпросмотр (строящийся объект) ─────────────────────── */
|
|
_drawPreview(ctx) {
|
|
if (this._pending.length === 0 || !this._preview) return;
|
|
const lastM = this._pending[this._pending.length-1];
|
|
const curM = this._preview;
|
|
const p1c = this.vp.toCanvas(lastM.x, lastM.y);
|
|
const p2c = this.vp.toCanvas(curM.x, curM.y);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.55;
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([4, 4]);
|
|
|
|
if (this.tool === 'circle') {
|
|
// Предпросмотр окружности
|
|
const r = gDist(p1c, p2c);
|
|
ctx.beginPath(); ctx.arc(p1c.x, p1c.y, r, 0, Math.PI*2); ctx.stroke();
|
|
} else if (this.tool === 'line') {
|
|
// Расширить до краёв
|
|
const ext = this._extendToEdges(lastM, curM);
|
|
if (ext) {
|
|
ctx.beginPath(); ctx.moveTo(ext[0].x, ext[0].y); ctx.lineTo(ext[1].x, ext[1].y); ctx.stroke();
|
|
}
|
|
} else if (this.tool === 'ray') {
|
|
const end = this._extendOneWay(lastM, curM);
|
|
if (end) {
|
|
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(end.x, end.y); ctx.stroke();
|
|
}
|
|
} else {
|
|
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
|
}
|
|
|
|
// Для полигона — показать цепочку
|
|
if ((this.tool === 'polygon' || this.tool === 'triangle' || this.tool === 'quad') && this._pending.length > 1) {
|
|
ctx.setLineDash([]);
|
|
ctx.strokeStyle = '#22d55e';
|
|
ctx.globalAlpha = 0.4;
|
|
ctx.beginPath();
|
|
const p0 = this.vp.toCanvas(this._pending[0].x, this._pending[0].y);
|
|
ctx.moveTo(p0.x, p0.y);
|
|
for (let i = 1; i < this._pending.length; i++) {
|
|
const pp = this.vp.toCanvas(this._pending[i].x, this._pending[i].y);
|
|
ctx.lineTo(pp.x, pp.y);
|
|
}
|
|
ctx.lineTo(p2c.x, p2c.y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
// Фантомная точка в позиции курсора
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.5;
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath(); ctx.arc(p2c.x, p2c.y, 4, 0, Math.PI*2); ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
_drawSnapIndicator(ctx) {
|
|
const p = this.vp.toCanvas(this._snapPt.x, this._snapPt.y);
|
|
ctx.save();
|
|
ctx.strokeStyle = '#FFE066';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.shadowColor = '#FFE066'; ctx.shadowBlur = 8;
|
|
// Крестик
|
|
const s = 8;
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x-s, p.y); ctx.lineTo(p.x+s, p.y);
|
|
ctx.moveTo(p.x, p.y-s); ctx.lineTo(p.x, p.y+s);
|
|
ctx.stroke();
|
|
// Кольцо
|
|
ctx.beginPath(); ctx.arc(p.x, p.y, s+2, 0, Math.PI*2); ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/* ── Геом. вспомогательные ───────────────────────────────────── */
|
|
/** Расширить прямую через m1-m2 до границ экрана */
|
|
_extendToEdges(m1, m2) {
|
|
const vp = this.vp;
|
|
const W = vp.W, H = vp.H;
|
|
const dx = m2.x-m1.x, dy = m2.y-m1.y;
|
|
if (Math.abs(dx)<1e-12 && Math.abs(dy)<1e-12) return null;
|
|
const big = 1e6;
|
|
const A = { x: m1.x - dx*big, y: m1.y - dy*big };
|
|
const B = { x: m1.x + dx*big, y: m1.y + dy*big };
|
|
const Apx = vp.toCanvas(A.x, A.y);
|
|
const Bpx = vp.toCanvas(B.x, B.y);
|
|
// Обрезаем по viewport
|
|
const clipped = this._clipSegment(Apx.x, Apx.y, Bpx.x, Bpx.y, 0, 0, W, H);
|
|
return clipped;
|
|
}
|
|
|
|
_extendOneWay(m1, m2) {
|
|
const vp = this.vp;
|
|
const dx = m2.x-m1.x, dy = m2.y-m1.y;
|
|
if (Math.abs(dx)<1e-12 && Math.abs(dy)<1e-12) return null;
|
|
const big = 1e6;
|
|
const B = { x: m1.x + dx*big, y: m1.y + dy*big };
|
|
return vp.toCanvas(B.x, B.y);
|
|
}
|
|
|
|
/** Cohen-Sutherland line clipping */
|
|
_clipSegment(x0,y0,x1,y1,xmin,ymin,xmax,ymax) {
|
|
const code = (x,y) =>
|
|
(x<xmin?1:x>xmax?2:0) | (y<ymin?4:y>ymax?8:0);
|
|
let c0=code(x0,y0), c1=code(x1,y1);
|
|
while (true) {
|
|
if (!(c0|c1)) return [{x:x0,y:y0},{x:x1,y:y1}];
|
|
if (c0&c1) return null;
|
|
const c = c0||c1;
|
|
let x,y;
|
|
if (c&8) { x=x0+(x1-x0)*(ymax-y0)/(y1-y0); y=ymax; }
|
|
else if (c&4) { x=x0+(x1-x0)*(ymin-y0)/(y1-y0); y=ymin; }
|
|
else if (c&2) { y=y0+(y1-y0)*(xmax-x0)/(x1-x0); x=xmax; }
|
|
else { y=y0+(y1-y0)*(xmin-x0)/(x1-x0); x=xmin; }
|
|
if (c===c0) { x0=x;y0=y;c0=code(x,y); }
|
|
else { x1=x;y1=y;c1=code(x,y); }
|
|
}
|
|
}
|
|
|
|
/* ══ SNAP ════════════════════════════════════════════════════ */
|
|
_computeSnap(mx, my) {
|
|
const SNAP_DIST_PX = 14; // радиус снапа в пикселях
|
|
const snapDist = this.vp.toMathDist(SNAP_DIST_PX);
|
|
|
|
this._snapPt = null;
|
|
this._snapId = null;
|
|
|
|
// 1. Снап к существующим точкам
|
|
let best = Infinity, bestId = null;
|
|
for (const pt of this.eng.points()) {
|
|
const d = Math.hypot(pt.x - mx, pt.y - my);
|
|
if (d < snapDist && d < best) { best = d; bestId = pt.id; }
|
|
}
|
|
if (bestId) {
|
|
const pt = this.eng.get(bestId);
|
|
this._snapPt = { x: pt.x, y: pt.y };
|
|
this._snapId = bestId;
|
|
return { x: pt.x, y: pt.y };
|
|
}
|
|
|
|
// 2. Снап к сетке
|
|
if (this.showGrid) {
|
|
const step = this._gridStep();
|
|
const sx = Math.round(mx / step) * step;
|
|
const sy = Math.round(my / step) * step;
|
|
const dpx = this.vp.toCanvasDist(Math.hypot(sx-mx, sy-my));
|
|
if (dpx < SNAP_DIST_PX * 0.7) {
|
|
this._snapPt = { x: sx, y: sy };
|
|
return { x: sx, y: sy };
|
|
}
|
|
}
|
|
|
|
return { x: mx, y: my };
|
|
}
|
|
|
|
_gridStep() {
|
|
const rawStep = 80 / this.vp.scale;
|
|
const exp = Math.floor(Math.log10(rawStep));
|
|
const frac = rawStep / Math.pow(10, exp);
|
|
let step = frac < 1.5 ? 1 : frac < 3.5 ? 2 : frac < 7.5 ? 5 : 10;
|
|
return step * Math.pow(10, exp);
|
|
}
|
|
|
|
/* ══ СОБЫТИЯ ══════════════════════════════════════════════════ */
|
|
_bindEvents() {
|
|
const c = this.canvas;
|
|
c.addEventListener('pointerdown', e => this._onDown(e));
|
|
c.addEventListener('pointermove', e => this._onMove(e));
|
|
c.addEventListener('pointerup', e => this._onUp(e));
|
|
c.addEventListener('pointerleave', e => this._onLeave(e));
|
|
c.addEventListener('wheel', e => this._onWheel(e), { passive: false });
|
|
c.addEventListener('contextmenu', e => e.preventDefault());
|
|
}
|
|
|
|
_evPos(e) {
|
|
const r = this.canvas.getBoundingClientRect();
|
|
return { px: e.clientX - r.left, py: e.clientY - r.top };
|
|
}
|
|
|
|
_onDown(e) {
|
|
e.preventDefault();
|
|
if (this.readOnly) return;
|
|
const { px, py } = this._evPos(e);
|
|
|
|
// ПКМ → отмена текущего построения
|
|
if (e.button === 2) {
|
|
this._pending = []; this._preview = null; this._pendingLineRef = null;
|
|
this.render(); return;
|
|
}
|
|
|
|
// Пан (средняя кнопка или Alt+ЛКМ)
|
|
if (e.button === 1 || e.altKey) {
|
|
this._panning = true; this._panLast = { px, py };
|
|
this.canvas.style.cursor = 'grabbing';
|
|
return;
|
|
}
|
|
|
|
const m = this.vp.toMath(px, py);
|
|
const snapped = this._computeSnap(m.x, m.y);
|
|
|
|
if (this.tool === 'select') {
|
|
this._handleSelectDown(snapped, px, py);
|
|
return;
|
|
}
|
|
|
|
this._handleToolClick(snapped, px, py);
|
|
}
|
|
|
|
_handleSelectDown(m, px, py) {
|
|
// Найти точку под курсором
|
|
const SNAP_PX = 12;
|
|
let found = null;
|
|
for (const pt of this.eng.points()) {
|
|
const pp = this.vp.toCanvas(pt.x, pt.y);
|
|
if (Math.hypot(pp.x-px, pp.y-py) < SNAP_PX) { found = pt; break; }
|
|
}
|
|
|
|
if (found && !found.locked && !found.derived) {
|
|
this._drag = { id: found.id };
|
|
this._selected = found;
|
|
this.canvas.style.cursor = 'grabbing';
|
|
} else {
|
|
// Выбрать объект (отрезок, окружность, полигон...)
|
|
this._selected = this._hitTest(px, py);
|
|
this._drag = null;
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
/** Hit-test для не-точечных объектов */
|
|
_hitTest(px, py) {
|
|
const HIT = 8; // pixels
|
|
const m = this.vp.toMath(px, py);
|
|
|
|
// Полигоны (проверяем стороны)
|
|
for (const obj of this.eng.byType('polygon')) {
|
|
const ids = obj.pointIds;
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const m1 = this._mpt(ids[i]), m2 = this._mpt(ids[(i+1)%ids.length]);
|
|
if (!m1||!m2) continue;
|
|
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;
|
|
}
|
|
// Окружности
|
|
for (const obj of this.eng.byType('circle')) {
|
|
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
|
|
if (!mc||!me) continue;
|
|
const r = gDist(mc, me);
|
|
const d = Math.abs(gDist(m, mc) - r);
|
|
if (d * this.vp.scale < HIT) return obj;
|
|
}
|
|
// Прямые
|
|
for (const obj of this.eng.byType('line')) {
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
if (!m1||!m2) continue;
|
|
if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
|
|
}
|
|
// Лучи
|
|
for (const obj of this.eng.byType('ray')) {
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
if (!m1||!m2) continue;
|
|
if (gDistToLine(m, m1, m2) * this.vp.scale < HIT) return obj;
|
|
}
|
|
// Производные прямые
|
|
for (const obj of this.eng.byType('derived_line')) {
|
|
const pts = this._twoPointsOnObj(obj);
|
|
if (!pts) continue;
|
|
if (gDistToLine(m, pts[0], pts[1]) * this.vp.scale < HIT) return obj;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_handleToolClick(snapped, px, py) {
|
|
switch (this.tool) {
|
|
case 'point':
|
|
this._pushUndo();
|
|
this._addPoint(snapped);
|
|
break;
|
|
|
|
case 'segment':
|
|
case 'line':
|
|
case 'ray': {
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 2) {
|
|
this._pushUndo();
|
|
const [p1, p2] = this._pending;
|
|
const pt1 = this._ensurePoint(p1);
|
|
const pt2 = this._ensurePoint(p2);
|
|
if (this.tool === 'segment') {
|
|
this.eng.add({ type:'segment', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#9B5DE5'} });
|
|
} else if (this.tool === 'line') {
|
|
this.eng.add({ type:'line', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#06D6E0'} });
|
|
} else {
|
|
this.eng.add({ type:'ray', p1Id:pt1.id, p2Id:pt2.id, style:{color:'#F15BB5'} });
|
|
}
|
|
this._pending = [];
|
|
this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'circle':
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 2) {
|
|
this._pushUndo();
|
|
const [c, e] = this._pending;
|
|
const pc = this._ensurePoint(c);
|
|
const pe = this._ensurePoint(e);
|
|
this.eng.add({ type:'circle', centerId:pc.id, edgeId:pe.id, style:{color:'#FFB347'} });
|
|
this._pending = [];
|
|
this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
|
|
case 'triangle':
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const pts = this._pending.map(p => this._ensurePoint(p));
|
|
this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
|
|
style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
|
|
this._pending = [];
|
|
this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
|
|
case 'quad':
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 4) {
|
|
this._pushUndo();
|
|
const pts = this._pending.map(p => this._ensurePoint(p));
|
|
this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
|
|
style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
|
|
this._pending = [];
|
|
this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
|
|
case 'polygon':
|
|
// Незамкнутый многоугольник — двойной клик или клик на первую точку замыкает
|
|
if (this._pending.length > 0 && this._snapId === this._pending[0]._id) {
|
|
// Замкнуть
|
|
this._finishPolygon();
|
|
} else {
|
|
this._pending.push({ ...snapped, _id: this._snapId });
|
|
}
|
|
break;
|
|
|
|
/* ══ Phase 2: Инструменты построения ══ */
|
|
|
|
case 'midpoint': {
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 2) {
|
|
this._pushUndo();
|
|
const pt1 = this._ensurePoint(this._pending[0]);
|
|
const pt2 = this._ensurePoint(this._pending[1]);
|
|
const lbl = 'M' + (this.eng.byType('point').filter(p=>p.constr==='midpoint').length+1||'');
|
|
this.eng.add({
|
|
type:'point', derived:true, constr:'midpoint',
|
|
srcA:pt1.id, srcB:pt2.id,
|
|
x:(pt1.x+pt2.x)/2, y:(pt1.y+pt2.y)/2,
|
|
label:lbl, style:{color:'#22d55e', size:4}
|
|
});
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'perpbisect': {
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 2) {
|
|
this._pushUndo();
|
|
const pt1 = this._ensurePoint(this._pending[0]);
|
|
const pt2 = this._ensurePoint(this._pending[1]);
|
|
const dx = pt2.x-pt1.x, dy = pt2.y-pt1.y, len = Math.hypot(dx,dy);
|
|
if (len > 1e-12) {
|
|
const cnt = this.eng.byType('derived_line').filter(d=>d.constr==='perpbisect').length;
|
|
this.eng.add({
|
|
type:'derived_line', derived:true, constr:'perpbisect',
|
|
srcA:pt1.id, srcB:pt2.id,
|
|
ptX:(pt1.x+pt2.x)/2, ptY:(pt1.y+pt2.y)/2,
|
|
dirX:-dy/len, dirY:dx/len,
|
|
label: cnt ? 'l'+(cnt+1) : 'l₁',
|
|
style:{color:'#A78BFA'}
|
|
});
|
|
}
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'anglebisect': {
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const ptA = this._ensurePoint(this._pending[0]);
|
|
const ptVtx = this._ensurePoint(this._pending[1]);
|
|
const ptB = this._ensurePoint(this._pending[2]);
|
|
const va = gNorm({x:ptA.x-ptVtx.x, y:ptA.y-ptVtx.y});
|
|
const vb = gNorm({x:ptB.x-ptVtx.x, y:ptB.y-ptVtx.y});
|
|
const bis = gNorm({x:va.x+vb.x, y:va.y+vb.y});
|
|
if (Math.hypot(bis.x,bis.y) > 1e-12) {
|
|
const cnt = this.eng.byType('derived_line').filter(d=>d.constr==='anglebisect').length;
|
|
this.eng.add({
|
|
type:'derived_line', derived:true, constr:'anglebisect',
|
|
srcA:ptA.id, srcVtx:ptVtx.id, srcB:ptB.id,
|
|
ptX:ptVtx.x, ptY:ptVtx.y,
|
|
dirX:bis.x, dirY:bis.y,
|
|
label: cnt ? 'b'+(cnt+1) : 'b₁',
|
|
style:{color:'#FB923C'}
|
|
});
|
|
}
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'parallel':
|
|
case 'perpendicular': {
|
|
if (!this._pendingLineRef) {
|
|
// Первый клик: ищем линейный объект
|
|
const hit = this._hitTestLine(px, py);
|
|
if (hit) {
|
|
this._pendingLineRef = hit;
|
|
if (this.onHintChange) this.onHintChange(this.tool, 2);
|
|
}
|
|
} else {
|
|
// Второй клик: точка через которую проводим прямую
|
|
this._pushUndo();
|
|
const throughPt = this._ensurePoint(snapped);
|
|
const hit = this._pendingLineRef;
|
|
let d1, d2;
|
|
if (hit.type === 'derived_line') {
|
|
d1 = { x:hit.ptX, y:hit.ptY };
|
|
d2 = { x:hit.ptX+hit.dirX, y:hit.ptY+hit.dirY };
|
|
} else {
|
|
d1 = this._mpt(hit.p1Id); d2 = this._mpt(hit.p2Id);
|
|
}
|
|
if (d1 && d2) {
|
|
const dx = d2.x-d1.x, dy = d2.y-d1.y, len = Math.hypot(dx,dy);
|
|
if (len > 1e-12) {
|
|
let dirX, dirY;
|
|
const srcDirPt1 = hit.p1Id || null, srcDirPt2 = hit.p2Id || null;
|
|
if (this.tool === 'parallel') {
|
|
dirX = dx/len; dirY = dy/len;
|
|
} else {
|
|
dirX = -dy/len; dirY = dx/len;
|
|
}
|
|
const cnt = this.eng.byType('derived_line').filter(d=>d.constr===this.tool).length;
|
|
this.eng.add({
|
|
type:'derived_line', derived:true, constr:this.tool,
|
|
srcPt:throughPt.id,
|
|
srcDirPt1: srcDirPt1, srcDirPt2: srcDirPt2,
|
|
ptX:throughPt.x, ptY:throughPt.y, dirX, dirY,
|
|
label: (this.tool==='parallel' ? 'p' : '⊥') + (cnt+1||''),
|
|
style:{color: this.tool==='parallel' ? '#4CC9F0' : '#FF9F43'}
|
|
});
|
|
}
|
|
}
|
|
this._pendingLineRef = null; this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'intersect': {
|
|
const hit = this._hitTestLine(px, py);
|
|
if (!hit) break;
|
|
if (!this._pendingLineRef) {
|
|
this._pendingLineRef = hit;
|
|
if (this.onHintChange) this.onHintChange('intersect', 2);
|
|
} else if (hit !== this._pendingLineRef) {
|
|
this._pushUndo();
|
|
const pts1 = this._twoPointsOnObj(this._pendingLineRef);
|
|
const pts2 = this._twoPointsOnObj(hit);
|
|
if (pts1 && pts2) {
|
|
const iPt = gIntersectLines(pts1[0], pts1[1], pts2[0], pts2[1]);
|
|
if (iPt) {
|
|
const lbl = this._nextLabel();
|
|
this.eng.add({
|
|
type:'point', derived:true, constr:'intersect',
|
|
src1:this._pendingLineRef.id, src2:hit.id,
|
|
x:iPt.x, y:iPt.y, valid:true,
|
|
label:lbl, style:{color:'#F15BB5', size:5}
|
|
});
|
|
}
|
|
}
|
|
this._pendingLineRef = null; this._pending = [];
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 3: foot, circumcircle, incircle ══ */
|
|
|
|
case 'foot': {
|
|
if (!this._pendingLineRef) {
|
|
// Первый клик: выбрать прямую
|
|
const hit = this._hitTestLine(px, py);
|
|
if (hit) {
|
|
this._pendingLineRef = hit;
|
|
if (this.onHintChange) this.onHintChange('foot', 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: 'foot',
|
|
srcLine: sl.id, srcPt: srcPt.id,
|
|
x: f.x, y: f.y,
|
|
label: lbl, style: { color: '#4ADE80', size: 4 }
|
|
});
|
|
}
|
|
this._pendingLineRef = null; this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'circumcircle':
|
|
case 'incircle': {
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const pts = this._pending.map(p => this._ensurePoint(p));
|
|
const A = { x: pts[0].x, y: pts[0].y };
|
|
const B = { x: pts[1].x, y: pts[1].y };
|
|
const C = { x: pts[2].x, y: pts[2].y };
|
|
const cc = this.tool === 'circumcircle'
|
|
? gCircumcircle(A, B, C)
|
|
: gIncircle(A, B, C);
|
|
if (cc) {
|
|
const sameConstr = this.eng.byType('circle').filter(c => c.constr === this.tool).length;
|
|
const isCircum = this.tool === 'circumcircle';
|
|
this.eng.add({
|
|
type: 'circle', derived: true, constr: this.tool,
|
|
ptA: pts[0].id, ptB: pts[1].id, ptC: pts[2].id,
|
|
cx: cc.cx, cy: cc.cy, r: cc.r, valid: true,
|
|
label: isCircum
|
|
? (sameConstr ? 'C' + (sameConstr+1) : 'C₁')
|
|
: (sameConstr ? 'I' + (sameConstr+1) : 'I₁'),
|
|
style: { color: isCircum ? '#38BDF8' : '#34D399' }
|
|
});
|
|
}
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
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;
|
|
}
|
|
|
|
/* ══ Phase 5: tangent, translate ══ */
|
|
|
|
case 'tangent': {
|
|
if (!this._pendingCircRef) {
|
|
// Первый клик: выбрать окружность
|
|
const hit = this._hitTestCircle(px, py);
|
|
if (hit) {
|
|
this._pendingCircRef = hit;
|
|
if (this.onHintChange) this.onHintChange('tangent', 2);
|
|
}
|
|
} else {
|
|
// Второй клик: внешняя точка → создать 2 касательные
|
|
this._pushUndo();
|
|
const extPt = this._ensurePoint(snapped);
|
|
const circ = this._pendingCircRef;
|
|
let O, r;
|
|
if (circ.derived && circ.cx != null) {
|
|
O = { x: circ.cx, y: circ.cy }; r = circ.r;
|
|
} else {
|
|
const mc = this._mpt(circ.centerId), me = this._mpt(circ.edgeId);
|
|
O = mc; r = mc && me ? gDist(mc, me) : 0;
|
|
}
|
|
if (O && r > 0) {
|
|
const tpts = gTangentPoints(O, { x: extPt.x, y: extPt.y }, r);
|
|
if (tpts) {
|
|
const cnt = this.eng.byType('derived_line').filter(d => d.constr === 'tangent').length;
|
|
for (let w = 0; w < 2; w++) {
|
|
const T = tpts[w];
|
|
const dx = T.x - extPt.x, dy = T.y - extPt.y;
|
|
const len = Math.hypot(dx, dy);
|
|
if (len < 1e-12) continue;
|
|
const lbl = cnt + w === 0 ? 't₁' : 't' + (cnt + w + 1);
|
|
this.eng.add({
|
|
type: 'derived_line', derived: true, constr: 'tangent',
|
|
srcCircle: circ.id, srcPt: extPt.id, which: w,
|
|
ptX: extPt.x, ptY: extPt.y,
|
|
dirX: dx/len, dirY: dy/len,
|
|
valid: true,
|
|
label: lbl, style: { color: '#FCD34D' }
|
|
});
|
|
}
|
|
}
|
|
}
|
|
this._pendingCircRef = null; this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'translate': {
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 1) {
|
|
if (this.onHintChange) this.onHintChange('translate', 2);
|
|
} else if (this._pending.length === 2) {
|
|
if (this.onHintChange) this.onHintChange('translate', 3);
|
|
} else if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const ptA = this._ensurePoint(this._pending[0]);
|
|
const ptB = this._ensurePoint(this._pending[1]);
|
|
const ptP = this._ensurePoint(this._pending[2]);
|
|
const lbl = this._nextLabel();
|
|
this.eng.add({
|
|
type: 'point', derived: true, constr: 'translate',
|
|
srcA: ptA.id, srcB: ptB.id, srcPt: ptP.id,
|
|
x: ptP.x + (ptB.x - ptA.x),
|
|
y: ptP.y + (ptB.y - ptA.y),
|
|
label: lbl, style: { color: '#60A5FA', size: 4 }
|
|
});
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 8: tick marks, arc marks ══ */
|
|
|
|
case 'tick': {
|
|
// Клик на отрезок или сторону полигона → циклически меняет метку (0→1→2→3→0)
|
|
const line = this._hitTestLine(px, py);
|
|
if (line) {
|
|
this._pushUndo();
|
|
if (line.virtual && line.polyId) {
|
|
const poly = this.eng.get(line.polyId);
|
|
if (poly) {
|
|
if (!poly.sideMarks) poly.sideMarks = new Array(poly.pointIds.length).fill(0);
|
|
const si = poly.pointIds.indexOf(line.p1Id);
|
|
if (si >= 0) poly.sideMarks[si] = ((poly.sideMarks[si] || 0) + 1) % 4;
|
|
}
|
|
} else {
|
|
const seg = this.eng.get(line.id);
|
|
if (seg) seg.tickMark = ((seg.tickMark || 0) + 1) % 4;
|
|
}
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'arcmark': {
|
|
// Клик на вершину полигона → циклически меняет метку дуги (0→1→2→3→0)
|
|
const SNAP_PX = 14;
|
|
let foundPoly = null, foundIdx = -1;
|
|
outer8:
|
|
for (const poly of this.eng.byType('polygon')) {
|
|
for (let i = 0; i < poly.pointIds.length; i++) {
|
|
const p = this._p(poly.pointIds[i]);
|
|
if (p && Math.hypot(p.x - px, p.y - py) < SNAP_PX) {
|
|
foundPoly = poly; foundIdx = i; break outer8;
|
|
}
|
|
}
|
|
}
|
|
if (foundPoly && foundIdx >= 0) {
|
|
this._pushUndo();
|
|
if (!foundPoly.angleMarks) foundPoly.angleMarks = new Array(foundPoly.pointIds.length).fill(0);
|
|
foundPoly.angleMarks[foundIdx] = (foundPoly.angleMarks[foundIdx] + 1) % 4;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 7: altitude, median, centroid, orthocenter ══ */
|
|
|
|
case 'altitude': {
|
|
// Шаг 1: выбрать прямую (сторону), Шаг 2: выбрать вершину
|
|
if (!this._pendingLineRef) {
|
|
const hit = this._hitTestLine(px, py);
|
|
if (hit) {
|
|
this._pendingLineRef = hit;
|
|
if (this.onHintChange) this.onHintChange('altitude', 2);
|
|
}
|
|
} else {
|
|
this._pushUndo();
|
|
const vertex = 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: vertex.x, y: vertex.y }, L1, L2);
|
|
const nFoot = this.eng.byType('point').filter(p=>p.constr==='foot').length;
|
|
const foot = this.eng.add({
|
|
type: 'point', derived: true, constr: 'foot',
|
|
srcLine: sl.id, srcPt: vertex.id,
|
|
x: f.x, y: f.y,
|
|
label: 'H' + (nFoot ? String.fromCharCode(0x2081 + nFoot) : '\u2081'),
|
|
style: { color: '#4ADE80', size: 4 }
|
|
});
|
|
// Отрезок-высота: вершина → основание
|
|
this.eng.add({ type: 'segment', p1Id: vertex.id, p2Id: foot.id,
|
|
style: { color: '#4ADE80', width: 1.5 } });
|
|
}
|
|
this._pendingLineRef = null; this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'median': {
|
|
// 3 клика: вершина A, затем B и C (концы стороны) → середина M + отрезок AM
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 1) {
|
|
if (this.onHintChange) this.onHintChange('median', 2);
|
|
} else if (this._pending.length === 2) {
|
|
if (this.onHintChange) this.onHintChange('median', 3);
|
|
} else if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const ptA = this._ensurePoint(this._pending[0]);
|
|
const ptB = this._ensurePoint(this._pending[1]);
|
|
const ptC = this._ensurePoint(this._pending[2]);
|
|
const nMid = this.eng.byType('point').filter(p=>p.constr==='midpoint').length;
|
|
const mid = this.eng.add({
|
|
type: 'point', derived: true, constr: 'midpoint',
|
|
srcA: ptB.id, srcB: ptC.id,
|
|
x: (ptB.x+ptC.x)/2, y: (ptB.y+ptC.y)/2,
|
|
label: 'M' + (nMid+1), style: { color: '#22d55e', size: 4 }
|
|
});
|
|
this.eng.add({ type: 'segment', p1Id: ptA.id, p2Id: mid.id,
|
|
style: { color: '#22d55e', width: 1.5 } });
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'centroid': {
|
|
// 3 клика: A, B, C → 3 медианы + точка центроид G
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 1) {
|
|
if (this.onHintChange) this.onHintChange('centroid', 2);
|
|
} else if (this._pending.length === 2) {
|
|
if (this.onHintChange) this.onHintChange('centroid', 3);
|
|
} else if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const pA = this._ensurePoint(this._pending[0]);
|
|
const pB = this._ensurePoint(this._pending[1]);
|
|
const pC = this._ensurePoint(this._pending[2]);
|
|
const nG = this.eng.byType('point').filter(p=>p.constr==='centroid').length;
|
|
const col = '#A78BFA';
|
|
// 3 середины сторон
|
|
const mBC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:pB.id, srcB:pC.id, x:(pB.x+pC.x)/2, y:(pB.y+pC.y)/2, label:'M₁', style:{color:col,size:3} });
|
|
const mAC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:pA.id, srcB:pC.id, x:(pA.x+pC.x)/2, y:(pA.y+pC.y)/2, label:'M₂', style:{color:col,size:3} });
|
|
const mAB = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:pA.id, srcB:pB.id, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2, label:'M₃', style:{color:col,size:3} });
|
|
// 3 медианы
|
|
this.eng.add({ type:'segment', p1Id:pA.id, p2Id:mBC.id, style:{color:col, width:1.5} });
|
|
this.eng.add({ type:'segment', p1Id:pB.id, p2Id:mAC.id, style:{color:col, width:1.5} });
|
|
this.eng.add({ type:'segment', p1Id:pC.id, p2Id:mAB.id, style:{color:col, width:1.5} });
|
|
// Центроид
|
|
this.eng.add({ type:'point', derived:true, constr:'centroid',
|
|
ptA:pA.id, ptB:pB.id, ptC:pC.id,
|
|
x:(pA.x+pB.x+pC.x)/3, y:(pA.y+pB.y+pC.y)/3,
|
|
label: nG ? 'G'+(nG+1) : 'G', style:{color:col, size:6} });
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'orthocenter': {
|
|
// 3 клика: A, B, C → 3 высоты + точка ортоцентра H
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 1) {
|
|
if (this.onHintChange) this.onHintChange('orthocenter', 2);
|
|
} else if (this._pending.length === 2) {
|
|
if (this.onHintChange) this.onHintChange('orthocenter', 3);
|
|
} else if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const pA = this._ensurePoint(this._pending[0]);
|
|
const pB = this._ensurePoint(this._pending[1]);
|
|
const pC = this._ensurePoint(this._pending[2]);
|
|
const nH = this.eng.byType('point').filter(p=>p.constr==='orthocenter').length;
|
|
const col = '#F97316';
|
|
const A = {x:pA.x,y:pA.y}, B = {x:pB.x,y:pB.y}, C = {x:pC.x,y:pC.y};
|
|
// Основания высот
|
|
const Ha = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:pA.id, ptB:pB.id, ptC:pC.id, ...gFoot(A,B,C), label:'H_a', style:{color:col,size:3} });
|
|
const Hb = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:pB.id, ptB:pA.id, ptC:pC.id, ...gFoot(B,A,C), label:'H_b', style:{color:col,size:3} });
|
|
const Hc = this.eng.add({ type:'point', derived:true, constr:'altitude_foot', ptA:pC.id, ptB:pA.id, ptC:pB.id, ...gFoot(C,A,B), label:'H_c', style:{color:col,size:3} });
|
|
// Отрезки-высоты
|
|
this.eng.add({ type:'segment', p1Id:pA.id, p2Id:Ha.id, style:{color:col, width:1.5} });
|
|
this.eng.add({ type:'segment', p1Id:pB.id, p2Id:Hb.id, style:{color:col, width:1.5} });
|
|
this.eng.add({ type:'segment', p1Id:pC.id, p2Id:Hc.id, style:{color:col, width:1.5} });
|
|
// Ортоцентр
|
|
const orth = gOrthocenter(A, B, C);
|
|
if (orth) {
|
|
this.eng.add({ type:'point', derived:true, constr:'orthocenter',
|
|
ptA:pA.id, ptB:pB.id, ptC:pC.id,
|
|
x:orth.x, y:orth.y, valid:true,
|
|
label: nH ? 'H'+(nH+1) : 'H', style:{color:col, size:6} });
|
|
}
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 8.3: Метка параллельности ══ */
|
|
|
|
case 'parallelmark': {
|
|
// Клик на отрезок или сторону полигона → циклически меняет parallelMark (0→1→2→0)
|
|
const line = this._hitTestLine(px, py);
|
|
if (line) {
|
|
this._pushUndo();
|
|
if (line.virtual && line.polyId) {
|
|
const poly = this.eng.get(line.polyId);
|
|
if (poly) {
|
|
if (!poly.parallelSideMarks) poly.parallelSideMarks = new Array(poly.pointIds.length).fill(0);
|
|
const si = poly.pointIds.indexOf(line.p1Id);
|
|
if (si >= 0) poly.parallelSideMarks[si] = ((poly.parallelSideMarks[si] || 0) + 1) % 3;
|
|
}
|
|
} else {
|
|
const seg = this.eng.get(line.id);
|
|
if (seg) seg.parallelMark = ((seg.parallelMark || 0) + 1) % 3;
|
|
}
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 9.1: Средняя линия треугольника ══ */
|
|
|
|
case 'midline': {
|
|
// 3 клика: A, B, C → середины AB и AC, отрезок M₁M₂ параллельный BC
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 1) {
|
|
if (this.onHintChange) this.onHintChange('midline', 2);
|
|
} else if (this._pending.length === 2) {
|
|
if (this.onHintChange) this.onHintChange('midline', 3);
|
|
} else if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const pA = this._ensurePoint(this._pending[0]);
|
|
const pB = this._ensurePoint(this._pending[1]);
|
|
const pC = this._ensurePoint(this._pending[2]);
|
|
const n = this.eng.byType('point').filter(p => p.constr === 'midpoint').length;
|
|
const col = '#06D6E0';
|
|
const mAB = this.eng.add({ type:'point', derived:true, constr:'midpoint',
|
|
srcA:pA.id, srcB:pB.id, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2,
|
|
label: n ? `M${n+1}` : 'M₁', style:{color:col, size:3} });
|
|
const mAC = this.eng.add({ type:'point', derived:true, constr:'midpoint',
|
|
srcA:pA.id, srcB:pC.id, x:(pA.x+pC.x)/2, y:(pA.y+pC.y)/2,
|
|
label: n ? `M${n+2}` : 'M₂', style:{color:col, size:3} });
|
|
this.eng.add({ type:'segment', p1Id:mAB.id, p2Id:mAC.id,
|
|
style:{color:col, width:2} });
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 9.2: Параллелограмм ══ */
|
|
|
|
case 'parallelogram': {
|
|
// 3 клика: A, B, C → D = A + C - B, полигон ABCD
|
|
this._pending.push(snapped);
|
|
if (this._pending.length === 1) {
|
|
if (this.onHintChange) this.onHintChange('parallelogram', 2);
|
|
} else if (this._pending.length === 2) {
|
|
if (this.onHintChange) this.onHintChange('parallelogram', 3);
|
|
} else if (this._pending.length === 3) {
|
|
this._pushUndo();
|
|
const pA = this._ensurePoint(this._pending[0]);
|
|
const pB = this._ensurePoint(this._pending[1]);
|
|
const pC = this._ensurePoint(this._pending[2]);
|
|
const col = '#F97316';
|
|
const dx = pA.x + pC.x - pB.x, dy = pA.y + pC.y - pB.y;
|
|
const pD = this.eng.add({ type:'point', derived:true, constr:'parallelogram_d',
|
|
ptA:pA.id, ptB:pB.id, ptC:pC.id, x:dx, y:dy,
|
|
label:this._nextLabel(), style:{color:col, size:5} });
|
|
this.eng.add({ type:'polygon', pointIds:[pA.id, pB.id, pC.id, pD.id],
|
|
style:{color:col, fillColor:'rgba(249,115,22,0.08)'} });
|
|
this._pending = []; this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 9.3: Диагонали полигона ══ */
|
|
|
|
case 'diagonal': {
|
|
// Клик на полигон → добавляет все диагонали (соединяет несмежные вершины)
|
|
const SNAP_PX = 18;
|
|
let hitPoly = null;
|
|
outer9:
|
|
for (const poly of this.eng.byType('polygon')) {
|
|
const pts = poly.pointIds.map(id => this._p(id)).filter(Boolean);
|
|
// Проверяем попадание внутрь полигона (упрощённо — bbox)
|
|
if (pts.length < 4) continue;
|
|
const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
|
|
const bx = Math.min(...xs), bX = Math.max(...xs);
|
|
const by = Math.min(...ys), bY = Math.max(...ys);
|
|
if (px >= bx - SNAP_PX && px <= bX + SNAP_PX && py >= by - SNAP_PX && py <= bY + SNAP_PX) {
|
|
hitPoly = poly; break outer9;
|
|
}
|
|
}
|
|
if (hitPoly) {
|
|
this._pushUndo();
|
|
const ids = hitPoly.pointIds;
|
|
const n = ids.length;
|
|
for (let i = 0; i < n - 2; i++) {
|
|
for (let j = i + 2; j < n; j++) {
|
|
if (i === 0 && j === n - 1) continue; // стороны, не диагонали
|
|
this.eng.add({ type:'segment', p1Id:ids[i], p2Id:ids[j],
|
|
style:{color:'#9CA3AF', width:1.5} });
|
|
}
|
|
}
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
break;
|
|
}
|
|
|
|
/* ══ Phase 10.2: Подобие (масштаб) ══ */
|
|
|
|
case 'scale': {
|
|
// Шаг 1: клик → центр подобия O
|
|
// Шаг 2: клик → точка P → строит P' = O + k*(P - O)
|
|
if (!this._pendingScaleO) {
|
|
this._pendingScaleO = this._ensurePoint(snapped);
|
|
if (this.onHintChange) this.onHintChange('scale', 2);
|
|
} else {
|
|
this._pushUndo();
|
|
const pO = this._pendingScaleO;
|
|
const pP = this._ensurePoint(snapped);
|
|
const k = this._scaleK;
|
|
const nx = pO.x + k * (pP.x - pO.x);
|
|
const ny = pO.y + k * (pP.y - pO.y);
|
|
this.eng.add({ type:'point', derived:true, constr:'scale',
|
|
srcO:pO.id, srcPt:pP.id, k,
|
|
x:nx, y:ny,
|
|
label:this._nextLabel(),
|
|
style:{color:'#F15BB5', size:5} });
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
// Продолжаем с тем же O — можно строить следующие точки
|
|
if (this.onHintChange) this.onHintChange('scale', 2);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
_finishPolygon() {
|
|
if (this._pending.length < 3) { this._pending = []; this._preview = null; this.render(); return; }
|
|
this._pushUndo();
|
|
const pts = this._pending.map(p => this._ensurePoint(p));
|
|
this.eng.add({ type:'polygon', pointIds:pts.map(p=>p.id),
|
|
style:{color:'#22d55e', fillColor:'rgba(34,213,94,0.08)'} });
|
|
this._pending = [];
|
|
this._preview = null;
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
this.render();
|
|
}
|
|
|
|
_addPoint(m) {
|
|
const pt = this.eng.add({ type:'point', x:m.x, y:m.y, label:this._nextLabel(),
|
|
style:{color:'#9B5DE5', size:5} });
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
return pt;
|
|
}
|
|
|
|
/** Найти или создать точку в мат. координатах */
|
|
_ensurePoint(m) {
|
|
if (m._id && this.eng.has(m._id)) return this.eng.get(m._id);
|
|
// Ищем существующую точку в этой позиции (snap)
|
|
for (const pt of this.eng.points()) {
|
|
if (Math.abs(pt.x - m.x) < 1e-9 && Math.abs(pt.y - m.y) < 1e-9) return pt;
|
|
}
|
|
return this._addPoint(m);
|
|
}
|
|
|
|
_onMove(e) {
|
|
const { px, py } = this._evPos(e);
|
|
|
|
if (this._panning) {
|
|
this.vp.pan(px - this._panLast.px, py - this._panLast.py);
|
|
this._panLast = { px, py };
|
|
this.render(); return;
|
|
}
|
|
|
|
const m = this.vp.toMath(px, py);
|
|
|
|
if (this._drag) {
|
|
const snapped = this._computeSnap(m.x, m.y);
|
|
this.eng.movePoint(this._drag.id, snapped.x, snapped.y);
|
|
this.render(); return;
|
|
}
|
|
|
|
// Обновить snap для предпросмотра
|
|
const snapped = this._computeSnap(m.x, m.y);
|
|
this._preview = snapped;
|
|
|
|
// Hover
|
|
if (this.tool === 'select') {
|
|
let h = null;
|
|
for (const pt of this.eng.points()) {
|
|
const pp = this.vp.toCanvas(pt.x, pt.y);
|
|
if (Math.hypot(pp.x-px, pp.y-py) < 12) { h = pt; break; }
|
|
}
|
|
if (!h) h = this._hitTest(px, py);
|
|
this._hovered = h;
|
|
this.canvas.style.cursor = h ? 'pointer' : 'default';
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
|
|
_onUp(e) {
|
|
if (this._panning) {
|
|
this._panning = false;
|
|
this.canvas.style.cursor = this.tool === 'select' ? 'default' : 'crosshair';
|
|
}
|
|
if (this._drag) {
|
|
this._drag = null;
|
|
this.canvas.style.cursor = 'default';
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
}
|
|
|
|
_onLeave(e) {
|
|
this._preview = null;
|
|
this._panning = false;
|
|
this._snapPt = null;
|
|
this.render();
|
|
}
|
|
|
|
_onWheel(e) {
|
|
e.preventDefault();
|
|
const { px, py } = this._evPos(e);
|
|
const factor = e.deltaY < 0 ? 1.12 : 1/1.12;
|
|
this.vp.zoom(factor, px, py);
|
|
this.render();
|
|
}
|
|
|
|
/* ══ UNDO/REDO ════════════════════════════════════════════════ */
|
|
_pushUndo() {
|
|
this._undoStack.push(this.eng.serialize());
|
|
this._redoStack = [];
|
|
if (this._undoStack.length > 80) this._undoStack.shift();
|
|
}
|
|
|
|
undo() {
|
|
if (!this._undoStack.length) return;
|
|
this._redoStack.push(this.eng.serialize());
|
|
this.eng.deserialize(this._undoStack.pop());
|
|
this._selected = null; this._pending = []; this._preview = null;
|
|
this.render();
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
|
|
redo() {
|
|
if (!this._redoStack.length) return;
|
|
this._undoStack.push(this.eng.serialize());
|
|
this.eng.deserialize(this._redoStack.pop());
|
|
this._selected = null;
|
|
this.render();
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
|
|
/* ── Удалить выбранный объект ── */
|
|
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(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());
|
|
}
|
|
|
|
/* ── Очистить всё ── */
|
|
reset() {
|
|
this._pushUndo();
|
|
this.eng.clear();
|
|
this._pending = []; this._preview = null; this._selected = null;
|
|
this.render();
|
|
if (this.onUpdate) this.onUpdate(this.getStats());
|
|
}
|
|
|
|
/* ── Центрировать вид ── */
|
|
resetView() {
|
|
this.vp.cx = 0; this.vp.cy = 0; this.vp.scale = 60;
|
|
this.render();
|
|
}
|
|
|
|
/* ══ СТАТИСТИКА ══════════════════════════════════════════════ */
|
|
getStats() {
|
|
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').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;
|
|
const constructions = this.eng.byType('derived_line').length + derivedPts + derivedCircles;
|
|
|
|
// Статистика для выбранного объекта
|
|
let sel = null;
|
|
if (this._selected) {
|
|
const obj = this._selected;
|
|
if (obj.type === 'segment') {
|
|
const m1 = this._mpt(obj.p1Id), m2 = this._mpt(obj.p2Id);
|
|
if (m1&&m2) sel = { type:'segment', len: gDist(m1,m2).toFixed(3), mid: gMid(m1,m2) };
|
|
} else if (obj.type === 'circle') {
|
|
let r = null;
|
|
if (obj.derived && (obj.constr === 'circumcircle' || obj.constr === 'incircle')) {
|
|
if (obj.valid) r = obj.r;
|
|
} else {
|
|
const mc = this._mpt(obj.centerId), me = this._mpt(obj.edgeId);
|
|
if (mc && me) r = gDist(mc, me);
|
|
}
|
|
if (r != null) sel = { type:'circle', r:r.toFixed(3), perimeter:(2*Math.PI*r).toFixed(3), area:(Math.PI*r*r).toFixed(3) };
|
|
} else if (obj.type === 'polygon') {
|
|
const pts2 = obj.pointIds.map(id=>this._mpt(id)).filter(Boolean);
|
|
if (pts2.length >= 3) {
|
|
const perimeter = pts2.reduce((s,p,i)=>s+gDist(p,pts2[(i+1)%pts2.length]),0);
|
|
const area = gPolygonArea(pts2);
|
|
const angles = pts2.map((_,i)=>gAngleDeg(pts2[(i-1+pts2.length)%pts2.length],pts2[i],pts2[(i+1)%pts2.length]));
|
|
sel = { type:'polygon', n:pts2.length, perimeter:perimeter.toFixed(3), area:area.toFixed(3), angles };
|
|
}
|
|
} else if (obj.type === 'point') {
|
|
sel = { type:'point', x:obj.x.toFixed(3), y:obj.y.toFixed(3), label:obj.label };
|
|
}
|
|
}
|
|
|
|
return { pts, segs, circs, polys, constructions, selected: sel };
|
|
}
|
|
|
|
/* ══ ЭКСПОРТ/ИМПОРТ СОСТОЯНИЯ (для classroom sim sync) ════════ */
|
|
exportState() {
|
|
return {
|
|
objects: this.eng.serialize(),
|
|
viewport: { cx: this.vp.cx, cy: this.vp.cy, scale: this.vp.scale },
|
|
showGrid: this.showGrid,
|
|
showAxes: this.showAxes,
|
|
showLabels: this.showLabels,
|
|
showLengths:this.showLengths,
|
|
showAngles: this.showAngles,
|
|
};
|
|
}
|
|
|
|
importState(st) {
|
|
if (!st) return;
|
|
try {
|
|
if (st.objects) this.eng.deserialize(st.objects);
|
|
if (st.viewport) {
|
|
this.vp.cx = st.viewport.cx;
|
|
this.vp.cy = st.viewport.cy;
|
|
this.vp.scale = st.viewport.scale;
|
|
}
|
|
if (st.showGrid !== undefined) this.showGrid = st.showGrid;
|
|
if (st.showAxes !== undefined) this.showAxes = st.showAxes;
|
|
if (st.showLabels !== undefined) this.showLabels = st.showLabels;
|
|
if (st.showLengths !== undefined) this.showLengths = st.showLengths;
|
|
if (st.showAngles !== undefined) this.showAngles = st.showAngles;
|
|
this._selected = null; this._pending = []; this._preview = null;
|
|
this.render();
|
|
} catch(err) { console.warn('GeoSim.importState error:', err); }
|
|
}
|
|
|
|
/* ── Экспорт PNG ── */
|
|
exportPNG() {
|
|
this.canvas.toBlob(blob => {
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = 'geometry.png';
|
|
a.click();
|
|
}, 'image/png');
|
|
}
|
|
}
|