Files
Learn_System/frontend/js/labs/geometry.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

2731 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ═══════════════════════════════════════════════════════════════════════
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);
this._drawAngleMeasures(ctx); // всегда — для arcmark и прямых углов; showAngles управляет авто-подписями
// Точки поверх всего (включая производные)
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;
}
/* Найти вершину полигона под курсором: {poly, idx} или null */
_findVertexOfPoly(px, py, nSides = 0) {
const SNAP_PX = 16;
for (const poly of this.eng.byType('polygon')) {
if (nSides > 0 && poly.pointIds.length !== nSides) continue;
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) return { poly, idx: i };
}
}
return null;
}
/* Найти треугольник под курсором (вершина, сторона или внутренность) */
_findTriangleNear(px, py) {
for (const poly of this.eng.byType('polygon')) {
if (poly.pointIds.length !== 3) continue;
const pts = poly.pointIds.map(id => this._p(id)).filter(Boolean);
if (pts.length !== 3) continue;
// Вершины
for (const p of pts) { if (Math.hypot(p.x - px, p.y - py) < 20) return poly; }
// Внутренность (cross-product тест)
const sign = (P, A, B) => (B.x-A.x)*(P.y-A.y) - (B.y-A.y)*(P.x-A.x);
const P = {x:px, y:py};
const s0 = sign(P,pts[0],pts[1]), s1 = sign(P,pts[1],pts[2]), s2 = sign(P,pts[2],pts[0]);
if ((s0>=0&&s1>=0&&s2>=0) || (s0<=0&&s1<=0&&s2<=0)) return poly;
}
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=авто, 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.85;
ctx.lineWidth = 2;
for (let k = 0; k < explicitMark; k++) {
ctx.beginPath();
ctx.arc(Bpx.x, Bpx.y, ARC_R + k * 8, midAngle - halfSpread, midAngle + halfSpread, false);
ctx.stroke();
}
} else if (this.showAngles) {
// Авто-режим: только если включено
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) {
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 = (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 клик на вершину треугольника → высота из этой вершины на противоположную сторону
const hitAlt = this._findVertexOfPoly(px, py, 3);
if (hitAlt) {
this._pushUndo();
const { poly: polyAlt, idx: iA } = hitAlt;
const ids = polyAlt.pointIds;
const iB = (iA+1)%3, iC = (iA+2)%3;
const ptA = this.eng.get(ids[iA]), ptB = this.eng.get(ids[iB]), ptC = this.eng.get(ids[iC]);
const A = {x:ptA.x,y:ptA.y}, B = {x:ptB.x,y:ptB.y}, C = {x:ptC.x,y:ptC.y};
const f = gFoot(A, B, C);
const nF = this.eng.byType('point').filter(p=>p.constr==='altitude_foot').length;
const foot = this.eng.add({ type:'point', derived:true, constr:'altitude_foot',
ptA:ids[iA], ptB:ids[iB], ptC:ids[iC], x:f.x, y:f.y,
label:'H'+(nF ? String.fromCharCode(0x2080+nF+1) : '\u2081'),
style:{color:'#4ADE80', size:4} });
this.eng.add({ type:'segment', p1Id:ids[iA], p2Id:foot.id,
style:{color:'#4ADE80', width:1.5} });
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
case 'median': {
// 1 клик на вершину треугольника → медиана из этой вершины к середине противоположной стороны
const hitMed = this._findVertexOfPoly(px, py, 3);
if (hitMed) {
this._pushUndo();
const { poly: polyMed, idx: iA } = hitMed;
const ids = polyMed.pointIds;
const iB = (iA+1)%3, iC = (iA+2)%3;
const ptB = this.eng.get(ids[iB]), ptC = this.eng.get(ids[iC]);
const nMid = this.eng.byType('point').filter(p=>p.constr==='midpoint').length;
const mid = this.eng.add({ type:'point', derived:true, constr:'midpoint',
srcA:ids[iB], srcB:ids[iC],
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:ids[iA], p2Id:mid.id,
style:{color:'#22d55e', width:1.5} });
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
case 'centroid': {
// 1 клик на/внутри треугольника → 3 медианы + центроид G
const polyCen = this._findTriangleNear(px, py);
if (polyCen) {
this._pushUndo();
const [idA, idB, idC] = polyCen.pointIds;
const pA = this.eng.get(idA), pB = this.eng.get(idB), pC = this.eng.get(idC);
const nG = this.eng.byType('point').filter(p=>p.constr==='centroid').length;
const col = '#A78BFA';
const mBC = this.eng.add({ type:'point', derived:true, constr:'midpoint', srcA:idB, srcB:idC, 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:idA, srcB:idC, 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:idA, srcB:idB, x:(pA.x+pB.x)/2, y:(pA.y+pB.y)/2, label:'M₃', style:{color:col,size:3} });
this.eng.add({ type:'segment', p1Id:idA, p2Id:mBC.id, style:{color:col, width:1.5} });
this.eng.add({ type:'segment', p1Id:idB, p2Id:mAC.id, style:{color:col, width:1.5} });
this.eng.add({ type:'segment', p1Id:idC, p2Id:mAB.id, style:{color:col, width:1.5} });
this.eng.add({ type:'point', derived:true, constr:'centroid',
ptA:idA, ptB:idB, ptC:idC,
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} });
if (this.onUpdate) this.onUpdate(this.getStats());
}
break;
}
case 'orthocenter': {
// 1 клик на/внутри треугольника → 3 высоты + ортоцентр H
const polyOrt = this._findTriangleNear(px, py);
if (polyOrt) {
this._pushUndo();
const [idA, idB, idC] = polyOrt.pointIds;
const pA = this.eng.get(idA), pB = this.eng.get(idB), pC = this.eng.get(idC);
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:idA, ptB:idB, ptC:idC, ...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:idB, ptB:idA, ptC:idC, ...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:idC, ptB:idA, ptC:idB, ...gFoot(C,A,B), label:'H_c', style:{color:col,size:3} });
this.eng.add({ type:'segment', p1Id:idA, p2Id:Ha.id, style:{color:col, width:1.5} });
this.eng.add({ type:'segment', p1Id:idB, p2Id:Hb.id, style:{color:col, width:1.5} });
this.eng.add({ type:'segment', p1Id:idC, 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:idA, ptB:idB, ptC:idC,
x:orth.x, y:orth.y, valid:true,
label: nH ? 'H'+(nH+1) : 'H', style:{color:col, size:6} });
}
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.1: Теорема Фалеса ══ */
case 'thales': {
// 3 клика: O (центр), A (точка на луче 1), B (точка на луче 2)
// Строит A' = O + k*(A-O), B' = O + k*(B-O), AB ∥ A'B'
this._pending.push(snapped);
if (this._pending.length === 1) {
if (this.onHintChange) this.onHintChange('thales', 2);
} else if (this._pending.length === 2) {
if (this.onHintChange) this.onHintChange('thales', 3);
} else if (this._pending.length === 3) {
this._pushUndo();
const pO = this._ensurePoint(this._pending[0]);
const pA = this._ensurePoint(this._pending[1]);
const pB = this._ensurePoint(this._pending[2]);
const k = this._scaleK;
const col = '#06D6E0';
// A' и B' — производные точки (constr:'scale')
const pA2 = this.eng.add({ type:'point', derived:true, constr:'scale',
srcO:pO.id, srcPt:pA.id, k,
x: pO.x + k*(pA.x-pO.x), y: pO.y + k*(pA.y-pO.y),
label:"A'", style:{color:col, size:4} });
const pB2 = this.eng.add({ type:'point', derived:true, constr:'scale',
srcO:pO.id, srcPt:pB.id, k,
x: pO.x + k*(pB.x-pO.x), y: pO.y + k*(pB.y-pO.y),
label:"B'", style:{color:col, size:4} });
// Лучи O→A'→... (через A и A')
this.eng.add({ type:'segment', p1Id:pO.id, p2Id:pA2.id,
style:{color:'#9CA3AF', width:1, dash:[5,4]} });
this.eng.add({ type:'segment', p1Id:pO.id, p2Id:pB2.id,
style:{color:'#9CA3AF', width:1, dash:[5,4]} });
// Отрезок AB (ближняя параллель)
this.eng.add({ type:'segment', p1Id:pA.id, p2Id:pB.id,
style:{color:'#FFE066', width:2} });
// Отрезок A'B' (дальняя параллель)
this.eng.add({ type:'segment', p1Id:pA2.id, p2Id:pB2.id,
style:{color:col, width:2} });
this._pending = []; this._preview = null;
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');
}
}
/* ─── lab UI init ─────────────────────────────────── */
function geoSetTool(name, btnEl) {
if (!geomSim) return;
geomSim.setTool(name);
document.querySelectorAll('.geo-tool-btn').forEach(b => b.classList.remove('active'));
if (btnEl) btnEl.classList.add('active');
_geoShowHint(name);
}
const _GEO_PHASE_HINTS = {
parallel_2: 'Теперь кликни на точку — через неё проведём прямую',
perpendicular_2: 'Теперь кликни на точку — через неё проведём перпендикуляр',
intersect_2: 'Теперь кликни на вторую прямую',
foot_2: 'Теперь кликни на точку — найдём основание перпендикуляра',
reflect_2: 'Теперь кликни на точку — получишь её симметричное отражение',
tangent_2: 'Теперь кликни на внешнюю точку — получишь две касательные',
translate_2: 'Теперь кликни конец вектора B',
translate_3: 'Теперь кликни точку P — она будет перенесена',
midline_2: 'Кликни вершину B (конец первой стороны)',
midline_3: 'Кликни вершину C (конец второй стороны) — построим среднюю линию',
parallelogram_2: 'Кликни вершину B (смежная с A)',
parallelogram_3: 'Кликни вершину C — построим параллелограмм ABCD',
scale_2: 'Кликни точку P — построим P\' = O + k·(P O)',
thales_2: 'Кликни точку A (на первом луче)',
thales_3: 'Кликни точку B (на втором луче) — построим A\'B\' ∥ AB',
};
function _geoShowHint(name, phase) {
const hint = document.getElementById('geo-hint');
if (!hint) return;
if (phase && phase > 1) {
hint.textContent = _GEO_PHASE_HINTS[`${name}_${phase}`] || _GEO_HINTS[name] || '';
} else {
hint.textContent = _GEO_HINTS[name] || '';
}
}
function geoNgonN(delta) {
if (!geomSim) return;
geomSim.setNgonSides(geomSim._ngonSides + delta);
const el = document.getElementById('geo-ngon-n');
if (el) el.textContent = geomSim._ngonSides;
}
function geoScaleK(delta) {
if (!geomSim) return;
const k = Math.round((geomSim._scaleK + delta) * 10) / 10;
if (k < 0.1) return;
geomSim.setScaleK(k);
const el = document.getElementById('geo-scale-k');
if (el) el.textContent = k;
}
function geoToggle(prop, rowEl) {
if (!geomSim) return;
geomSim[prop] = !geomSim[prop];
const tog = rowEl.querySelector('.geo-toggle');
if (tog) tog.classList.toggle('on', geomSim[prop]);
geomSim.render();
}
function _geoUpdateStats() {
if (!geomSim) return;
const s = geomSim.getStats();
document.getElementById('geo-st-pts').textContent = s.pts;
document.getElementById('geo-st-segs').textContent = s.segs;
document.getElementById('geo-st-circs').textContent = s.circs;
document.getElementById('geo-st-polys').textContent = s.polys;
const cEl = document.getElementById('geo-st-constr');
if (cEl) cEl.textContent = s.constructions || 0;
}
/* Диалог подтверждения удаления объекта с зависимыми */
let _geoDelSoftFn = null, _geoDelHardFn = null;
function _geoShowDeleteConfirm(obj, deps, softFn, hardFn) {
const panel = document.getElementById('geo-del-confirm');
const msg = document.getElementById('geo-del-msg');
if (!panel || !msg) { hardFn(); return; }
const names = { point:'точка', segment:'отрезок', line:'прямая', ray:'луч',
circle:'окружность', polygon:'многоугольник', derived_line:'построение' };
const n = names[obj.type] || 'объект';
msg.textContent = `Удалить ${n}? Зависимых: ${deps.length}.`;
_geoDelSoftFn = softFn;
_geoDelHardFn = hardFn;
panel.classList.add('visible');
}
function _geoHideDeleteConfirm() {
document.getElementById('geo-del-confirm')?.classList.remove('visible');
_geoDelSoftFn = _geoDelHardFn = null;
}
// Кнопки диалога — подключаем после DOM ready
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('geo-del-soft')?.addEventListener('click', () => {
_geoDelSoftFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
});
document.getElementById('geo-del-hard')?.addEventListener('click', () => {
_geoDelHardFn?.(); _geoHideDeleteConfirm(); _geoUpdateStats();
});
document.getElementById('geo-del-cancel')?.addEventListener('click', _geoHideDeleteConfirm);
});
function _openGeometry() {
document.getElementById('sim-topbar-title').textContent = 'Планиметрия';
_simShow('sim-geometry');
_simShow('ctrl-geometry');
_registerSimState(
'geometry',
() => geomSim?.exportState(),
st => { if (geomSim && st) { geomSim.importState(st); _geoUpdateStats(); } }
);
if (_embedMode) _startStateEmit('geometry');
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('geo-canvas');
if (!geomSim) {
geomSim = new GeoSim(canvas);
geomSim.onUpdate = _geoUpdateStats;
geomSim.onHintChange = (tool, phase) => _geoShowHint(tool, phase);
geomSim.onDeleteRequest = _geoShowDeleteConfirm;
// keyboard shortcuts
canvas.setAttribute('tabindex', '0');
canvas.addEventListener('keydown', e => {
if (!geomSim) return;
if (e.key === 'Escape') { geoSetTool('select', document.getElementById('geo-btn-select')); }
if ((e.ctrlKey||e.metaKey) && e.key === 'z') { e.preventDefault(); geomSim.undo(); _geoUpdateStats(); }
if ((e.ctrlKey||e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key==='z'))) { e.preventDefault(); geomSim.redo(); _geoUpdateStats(); }
if (e.key === 'Delete' || e.key === 'Backspace') { geomSim.deleteSelected(); _geoUpdateStats(); }
if (e.key === 'Enter') { geomSim._finishPolygon?.(); _geoUpdateStats(); }
});
}
geomSim.fit();
geomSim.render();
_geoUpdateStats();
// sync toggle UI to current state
['showGrid','showAxes','showLabels','showLengths','showAngles'].forEach(p => {
const el = document.getElementById('geo-tog-' + p);
if (el) el.classList.toggle('on', !!geomSim[p]);
});
}));
}
/* ── trig circle ── */