feat: планиметрия Phase 2 — инструменты построения
- GeoEngine: система производных объектов (recompute, propagateDeps), каскадная цепочка зависимостей при перемещении точек - 6 новых инструментов в GeoSim: * midpoint — середина отрезка (производная точка) * perpbisect — серединный перпендикуляр (derived_line) * anglebisect — биссектриса угла ABC (derived_line) * parallel — параллельная прямая через точку (derived_line) * perpendicular — перпендикуляр через точку (derived_line) * intersect — точка пересечения двух прямых (производная точка) - Производные объекты: пунктирный стиль, светящийся ободок, автообновление при перемещении родительских точек - Двухфазный UI для parallel/perpendicular/intersect: _pendingLineRef + _drawLineRefHighlight (подсветка первой линии) - lab.html: 6 новых кнопок в секции "Построения", счётчик построений, onHintChange callback для контекстных подсказок Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+373
-29
@@ -1,6 +1,8 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
geometry.js — Интерактивная планиметрия для LearnSpace
|
||||
Phase 1: точки, отрезки, прямые, лучи, окружности, многоугольники
|
||||
Phase 2: инструменты построения (середина, биссектрисы, параллельные,
|
||||
перпендикуляры, пересечения) + система производных объектов
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
@@ -151,12 +153,99 @@ class GeoEngine {
|
||||
return obj.centerId === id || obj.edgeId === id;
|
||||
case 'polygon':
|
||||
return obj.pointIds.includes(id);
|
||||
case 'midpoint':
|
||||
return obj.p1Id === id || obj.p2Id === 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;
|
||||
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;
|
||||
}
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Возвращает два математических точки на объекте-линии (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(id) { return this._objects.get(id); }
|
||||
has(id) { return this._objects.has(id); }
|
||||
all() { return [...this._objects.values()]; }
|
||||
@@ -165,8 +254,9 @@ class GeoEngine {
|
||||
|
||||
movePoint(id, x, y) {
|
||||
const obj = this._objects.get(id);
|
||||
if (obj && obj.type === 'point' && !obj.locked) {
|
||||
if (obj && obj.type === 'point' && !obj.derived && !obj.locked) {
|
||||
obj.x = x; obj.y = y;
|
||||
this.propagateDeps(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +288,10 @@ class GeoSim {
|
||||
this.eng = new GeoEngine();
|
||||
|
||||
/* ── Состояние инструментов ── */
|
||||
this.tool = 'select';
|
||||
this._pending = []; // промежуточные клики многошаговых инструментов
|
||||
this._preview = null; // предпросмотр (курсор при рисовании)
|
||||
this.tool = 'select';
|
||||
this._pending = []; // промежуточные клики многошаговых инструментов
|
||||
this._preview = null; // предпросмотр (курсор при рисовании)
|
||||
this._pendingLineRef = null; // первый кликнутый линейный объект для parallel/perp/intersect
|
||||
|
||||
/* ── Состояние drag/pan ── */
|
||||
this._drag = null; // { id, offX, offY } — перетаскиваем точку
|
||||
@@ -228,7 +319,8 @@ class GeoSim {
|
||||
this._hovered = null;
|
||||
|
||||
/* ── Callbacks ── */
|
||||
this.onUpdate = null; // cb(stats)
|
||||
this.onUpdate = null; // cb(stats)
|
||||
this.onHintChange = null; // cb(tool, phase) — уведомить UI о смене подсказки
|
||||
|
||||
this._labelCounter = 0;
|
||||
this._bindEvents();
|
||||
@@ -245,10 +337,11 @@ class GeoSim {
|
||||
}
|
||||
|
||||
setTool(name) {
|
||||
this.tool = name;
|
||||
this._pending = [];
|
||||
this._preview = null;
|
||||
this._selected = null;
|
||||
this.tool = name;
|
||||
this._pending = [];
|
||||
this._preview = null;
|
||||
this._selected = null;
|
||||
this._pendingLineRef = null;
|
||||
this.canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
|
||||
this.render();
|
||||
}
|
||||
@@ -280,6 +373,8 @@ class GeoSim {
|
||||
|
||||
// Заливки многоугольников
|
||||
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);
|
||||
// Лучи
|
||||
@@ -293,10 +388,12 @@ class GeoSim {
|
||||
// Измерения
|
||||
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._snapPt) this._drawSnapIndicator(ctx);
|
||||
}
|
||||
@@ -436,31 +533,43 @@ class GeoSim {
|
||||
const { x: px, y: py } = this.vp.toCanvas(obj.x, obj.y);
|
||||
const sel = this._isSelected(obj);
|
||||
const hov = this._isHovered(obj);
|
||||
const col = obj.style?.color || '#fff';
|
||||
const r = obj.style?.size || 5;
|
||||
// Производные точки рисуем иначе (меньше, другой цвет, пунктирный ободок)
|
||||
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;
|
||||
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();
|
||||
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 = 'bold 14px Manrope,sans-serif';
|
||||
ctx.fillStyle = '#fff';
|
||||
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);
|
||||
@@ -517,6 +626,81 @@ class GeoSim {
|
||||
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) */
|
||||
_drawLineRefHighlight(ctx, obj) {
|
||||
if (!obj) return;
|
||||
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) 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.save();
|
||||
ctx.strokeStyle = '#FFE066'; ctx.lineWidth = 3; ctx.globalAlpha = 0.55;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(p1c.x, p1c.y); ctx.lineTo(p2c.x, p2c.y); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* Вернуть две мат. точки на объекте (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)) {
|
||||
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;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_drawPolyFill(ctx, obj) {
|
||||
const pts = obj.pointIds.map(id => this._p(id)).filter(Boolean);
|
||||
if (pts.length < 3) return;
|
||||
@@ -819,8 +1003,11 @@ class GeoSim {
|
||||
if (this.readOnly) return;
|
||||
const { px, py } = this._evPos(e);
|
||||
|
||||
// ПКМ или Space → отмена текущего построения
|
||||
if (e.button === 2) { this._pending = []; this._preview = null; this.render(); return; }
|
||||
// ПКМ → отмена текущего построения
|
||||
if (e.button === 2) {
|
||||
this._pending = []; this._preview = null; this._pendingLineRef = null;
|
||||
this.render(); return;
|
||||
}
|
||||
|
||||
// Пан (средняя кнопка или Alt+ЛКМ)
|
||||
if (e.button === 1 || e.altKey) {
|
||||
@@ -837,7 +1024,7 @@ class GeoSim {
|
||||
return;
|
||||
}
|
||||
|
||||
this._handleToolClick(snapped);
|
||||
this._handleToolClick(snapped, px, py);
|
||||
}
|
||||
|
||||
_handleSelectDown(m, px, py) {
|
||||
@@ -849,7 +1036,7 @@ class GeoSim {
|
||||
if (Math.hypot(pp.x-px, pp.y-py) < SNAP_PX) { found = pt; break; }
|
||||
}
|
||||
|
||||
if (found && !found.locked) {
|
||||
if (found && !found.locked && !found.derived) {
|
||||
this._drag = { id: found.id };
|
||||
this._selected = found;
|
||||
this.canvas.style.cursor = 'grabbing';
|
||||
@@ -901,10 +1088,16 @@ class GeoSim {
|
||||
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) {
|
||||
_handleToolClick(snapped, px, py) {
|
||||
switch (this.tool) {
|
||||
case 'point':
|
||||
this._pushUndo();
|
||||
@@ -983,6 +1176,154 @@ class GeoSim {
|
||||
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;
|
||||
}
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
@@ -1131,10 +1472,13 @@ class GeoSim {
|
||||
|
||||
/* ══ СТАТИСТИКА ══════════════════════════════════════════════ */
|
||||
getStats() {
|
||||
const pts = this.eng.points().length;
|
||||
const allPts = this.eng.points();
|
||||
const pts = allPts.filter(p => !p.derived).length;
|
||||
const derivedPts = allPts.filter(p => !!p.derived).length;
|
||||
const segs = this.eng.byType('segment').length + this.eng.byType('polygon').reduce((s,p)=>s+p.pointIds.length,0);
|
||||
const circs= this.eng.byType('circle').length;
|
||||
const polys= this.eng.byType('polygon').length;
|
||||
const constructions = this.eng.byType('derived_line').length + derivedPts;
|
||||
|
||||
// Статистика для выбранного объекта
|
||||
let sel = null;
|
||||
@@ -1162,7 +1506,7 @@ class GeoSim {
|
||||
}
|
||||
}
|
||||
|
||||
return { pts, segs, circs, polys, selected: sel };
|
||||
return { pts, segs, circs, polys, constructions, selected: sel };
|
||||
}
|
||||
|
||||
/* ══ ЭКСПОРТ/ИМПОРТ СОСТОЯНИЯ (для classroom sim sync) ════════ */
|
||||
|
||||
Reference in New Issue
Block a user