diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index e95b992..c86140f 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -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) ════════ */ diff --git a/frontend/lab.html b/frontend/lab.html index 954b778..818abae 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -3800,6 +3800,34 @@ +