feat: планиметрия — интерактивная геометрическая симуляция

- Новый файл frontend/js/labs/geometry.js (~1200 строк):
  GeoEngine (граф объектов с каскадным удалением),
  GeoViewport (система координат math↔canvas, зум/пан),
  GeoSim (полный движок: точки, отрезки, прямые, лучи,
  окружности, треугольники, многоугольники, привязка к сетке
  и точкам, undo/redo, экспорт PNG, classroom sync)
- frontend/lab.html: карточка, ctrl, sim-geometry секция,
  функции geoSetTool/geoToggle/_openGeometry, скрипт-тег
- frontend/admin.html: geometry в ADMIN_SIMS
- backend/src/db/migrate.js: таблицы geometry_tasks,
  geometry_submissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-14 09:40:41 +03:00
parent b946a6a187
commit 35849cf231
4 changed files with 1580 additions and 2 deletions
+32
View File
@@ -2867,3 +2867,35 @@ db.exec(`
PRIMARY KEY (session_id, user_id)
)
`);
// ── Geometry (Planimetry) ────────────────────────────────────────────────────
// Saved geometry constructions (teacher-created tasks/templates)
db.exec(`
CREATE TABLE IF NOT EXISTS geometry_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
teacher_id INTEGER NOT NULL REFERENCES users(id),
class_id INTEGER REFERENCES classes(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT 'Без названия',
description TEXT DEFAULT '',
state_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_tasks_teacher ON geometry_tasks(teacher_id)');
// Student submissions for geometry tasks
db.exec(`
CREATE TABLE IF NOT EXISTS geometry_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES geometry_tasks(id) ON DELETE CASCADE,
student_id INTEGER NOT NULL REFERENCES users(id),
state_json TEXT NOT NULL DEFAULT '{}',
score REAL DEFAULT NULL,
feedback TEXT DEFAULT '',
submitted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(task_id, student_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_student ON geometry_submissions(student_id)');
+1
View File
@@ -4446,6 +4446,7 @@
const ADMIN_SIMS = [
{ id: 'graph', cat: 'Математика', title: 'График функции' },
{ id: 'graphtransform', cat: 'Математика', title: 'Трансформации графиков' },
{ id: 'geometry', cat: 'Математика', title: 'Планиметрия' },
{ id: 'triangle', cat: 'Математика', title: 'Геометрия треугольника' },
{ id: 'quadratic', cat: 'Математика', title: 'Корни квадратного уравнения' },
{ id: 'stereo', cat: 'Математика', title: 'Стереометрия 3D' },
File diff suppressed because it is too large Load Diff
+338 -2
View File
@@ -641,6 +641,94 @@
.embed-mode #lab-sim { flex: 1; }
.embed-mode .sim-body-wrap { height: 100vh; }
.embed-mode .graph-panel { max-height: 100vh; }
/* ════════════════════════════════
GEOMETRY SIM STYLES
════════════════════════════════ */
.geo-panel {
width: 210px; flex-shrink: 0;
background: var(--surface);
border-right: 1.5px solid var(--border);
display: flex; flex-direction: column;
overflow-y: auto; padding: 12px 10px; gap: 4px;
}
.geo-tool-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
margin-bottom: 4px;
}
.geo-tool-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 9px; border-radius: 10px;
border: 1.5px solid var(--border);
background: transparent; color: var(--text-2);
font-family: 'Manrope', sans-serif; font-size: 0.73rem; font-weight: 700;
cursor: pointer; transition: all .14s; white-space: nowrap;
}
.geo-tool-btn svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2.2; flex-shrink: 0; }
.geo-tool-btn:hover { border-color: rgba(155,93,229,.4); color: var(--violet); background: rgba(155,93,229,.06); }
.geo-tool-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.12); }
.geo-tool-wide {
grid-column: span 2;
}
.geo-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 4px; border-radius: 8px; cursor: pointer;
transition: background .13s;
}
.geo-toggle-row:hover { background: rgba(255,255,255,.04); }
.geo-toggle-label {
font-size: 0.73rem; font-weight: 600; color: var(--text-2);
display: flex; align-items: center; gap: 6px;
}
.geo-toggle-label svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; opacity: .7; }
.geo-toggle {
width: 28px; height: 16px; border-radius: 8px;
background: rgba(255,255,255,.1); border: 1.5px solid var(--border-h);
position: relative; transition: background .15s; flex-shrink: 0;
}
.geo-toggle::after {
content: ''; position: absolute; left: 2px; top: 50%;
transform: translateY(-50%);
width: 9px; height: 9px; border-radius: 50%;
background: rgba(255,255,255,.4); transition: all .15s;
}
.geo-toggle.on { background: var(--violet); border-color: var(--violet); }
.geo-toggle.on::after { left: calc(100% - 11px); background: #fff; }
.geo-info-box {
background: rgba(155,93,229,.07); border: 1px solid rgba(155,93,229,.15);
border-radius: 10px; padding: 8px 10px; margin-top: 4px;
font-size: 0.72rem; line-height: 1.55; color: var(--text-2);
}
.geo-info-box .geo-info-key { color: var(--violet); font-weight: 700; }
.geo-stat-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.7rem; padding: 2px 0;
border-bottom: 1px dashed rgba(255,255,255,.05);
color: var(--text-2);
}
.geo-stat-row:last-child { border: none; }
.geo-stat-row b { color: var(--text); font-weight: 700; }
.geo-canvas-outer {
flex: 1; min-width: 0; position: relative; background: #0a0718;
}
.geo-canvas-outer canvas {
display: block; position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
}
.geo-hint-bar {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
background: rgba(10,7,24,.82); border: 1px solid rgba(255,255,255,.1);
border-radius: 20px; padding: 4px 14px;
font-size: 0.7rem; color: rgba(255,255,255,.5);
pointer-events: none; white-space: nowrap;
backdrop-filter: blur(6px);
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
@@ -741,6 +829,26 @@
</button>
</div>
<!-- geometry controls -->
<div id="ctrl-geometry" class="sim-zoom-btns" style="display:none">
<button class="zoom-btn" onclick="geomSim&&geomSim.undo()" title="Отменить (Ctrl+Z)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 7v6h6"/><path d="M3 13A9 9 0 1 0 6 6.3L3 7"/></svg>
</button>
<button class="zoom-btn" onclick="geomSim&&geomSim.redo()" title="Повторить (Ctrl+Y)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 7v6h-6"/><path d="M21 13A9 9 0 1 1 18 6.3L21 7"/></svg>
</button>
<button class="zoom-btn" onclick="geomSim&&geomSim.deleteSelected()" title="Удалить выбранное">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
<div style="width:1px;height:20px;background:rgba(255,255,255,0.1);margin:0 2px"></div>
<button class="zoom-btn" onclick="geomSim&&geomSim.resetView()" title="Сброс вида">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>
<button class="zoom-btn" onclick="geomSim&&geomSim.exportPNG()" title="Экспорт PNG">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>
<!-- trig circle controls -->
<div id="ctrl-trigcircle" class="sim-zoom-btns" style="display:none">
<button class="zoom-btn" onclick="trigReset()" title="Сбросить на 45°">
@@ -3634,6 +3742,130 @@
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-hydro -->
<!-- ══════════════════════════════════════════════
ПЛАНИМЕТРИЯ
══════════════════════════════════════════════ -->
<div id="sim-geometry" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<!-- left panel -->
<div class="geo-panel">
<!-- Tool: select + point -->
<div class="gp-section-title">Инструмент</div>
<div class="geo-tool-grid">
<button id="geo-btn-select" class="geo-tool-btn active" onclick="geoSetTool('select',this)" title="Выделить / переместить (Esc)">
<svg viewBox="0 0 24 24" fill="none"><path d="M5 3l14 9-7 1-4 7z" stroke-width="2"/></svg>
Выбор
</button>
<button id="geo-btn-point" class="geo-tool-btn" onclick="geoSetTool('point',this)" title="Поставить точку">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="4" fill="currentColor"/></svg>
Точка
</button>
</div>
<div class="gp-section-title" style="margin-top:4px">Построения</div>
<div class="geo-tool-grid">
<button id="geo-btn-segment" class="geo-tool-btn" onclick="geoSetTool('segment',this)" title="Отрезок — 2 точки">
<svg viewBox="0 0 24 24" fill="none"><line x1="4" y1="20" x2="20" y2="4" stroke-width="2.5"/><circle cx="4" cy="20" r="2.5" fill="currentColor"/><circle cx="20" cy="4" r="2.5" fill="currentColor"/></svg>
Отрезок
</button>
<button id="geo-btn-line" class="geo-tool-btn" onclick="geoSetTool('line',this)" title="Прямая — 2 точки">
<svg viewBox="0 0 24 24" fill="none"><line x1="2" y1="22" x2="22" y2="2" stroke-width="2" stroke-dasharray="3,2"/></svg>
Прямая
</button>
<button id="geo-btn-ray" class="geo-tool-btn" onclick="geoSetTool('ray',this)" title="Луч — начало + направление">
<svg viewBox="0 0 24 24" fill="none"><line x1="4" y1="20" x2="22" y2="4" stroke-width="2"/><polyline points="17 4 22 4 22 9" stroke-width="2"/><circle cx="4" cy="20" r="2.5" fill="currentColor"/></svg>
Луч
</button>
<button id="geo-btn-circle" class="geo-tool-btn" onclick="geoSetTool('circle',this)" title="Окружность — центр + радиус">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="8" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>
Круг
</button>
</div>
<div class="gp-section-title" style="margin-top:4px">Фигуры</div>
<div class="geo-tool-grid">
<button id="geo-btn-triangle" class="geo-tool-btn" onclick="geoSetTool('triangle',this)" title="Треугольник — 3 точки">
<svg viewBox="0 0 24 24" fill="none"><polygon points="12,3 22,21 2,21" stroke-width="2"/></svg>
Треуг.
</button>
<button id="geo-btn-quad" class="geo-tool-btn" onclick="geoSetTool('quad',this)" title="Четырёхугольник — 4 точки">
<svg viewBox="0 0 24 24" fill="none"><polygon points="3,6 21,4 20,19 4,20" stroke-width="2"/></svg>
Четырёх.
</button>
<button id="geo-btn-polygon" class="geo-tool-btn geo-tool-wide" onclick="geoSetTool('polygon',this)" title="Многоугольник — N точек, Enter/двойной клик для завершения">
<svg viewBox="0 0 24 24" fill="none"><polygon points="12,2 22,8 19,21 5,21 2,8" stroke-width="2"/></svg>
Многоугольник
</button>
</div>
<!-- Display options -->
<div class="gp-section-title" style="margin-top:6px">Параметры</div>
<label class="geo-toggle-row" onclick="geoToggle('showGrid',this)">
<span class="geo-toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
Сетка
</span>
<div class="geo-toggle on" id="geo-tog-showGrid"></div>
</label>
<label class="geo-toggle-row" onclick="geoToggle('showAxes',this)">
<span class="geo-toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><line x1="12" y1="20" x2="12" y2="4"/><line x1="4" y1="12" x2="20" y2="12"/></svg>
Оси
</span>
<div class="geo-toggle on" id="geo-tog-showAxes"></div>
</label>
<label class="geo-toggle-row" onclick="geoToggle('showLabels',this)">
<span class="geo-toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Метки
</span>
<div class="geo-toggle on" id="geo-tog-showLabels"></div>
</label>
<label class="geo-toggle-row" onclick="geoToggle('showLengths',this)">
<span class="geo-toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="8" x2="4" y2="16"/><line x1="20" y1="8" x2="20" y2="16"/></svg>
Длины
</span>
<div class="geo-toggle" id="geo-tog-showLengths"></div>
</label>
<label class="geo-toggle-row" onclick="geoToggle('showAngles',this)">
<span class="geo-toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 20 L20 20 L20 4"/><path d="M7 20 A13 13 0 0 1 20 9"/></svg>
Углы
</span>
<div class="geo-toggle" id="geo-tog-showAngles"></div>
</label>
<!-- Stats -->
<div class="gp-section-title" style="margin-top:6px">Объектов</div>
<div style="display:flex;flex-direction:column;gap:0">
<div class="geo-stat-row"><span>Точки</span><b id="geo-st-pts">0</b></div>
<div class="geo-stat-row"><span>Отрезки</span><b id="geo-st-segs">0</b></div>
<div class="geo-stat-row"><span>Окружности</span><b id="geo-st-circs">0</b></div>
<div class="geo-stat-row"><span>Многоугольники</span><b id="geo-st-polys">0</b></div>
</div>
<!-- Actions -->
<div style="margin-top:auto;padding-top:8px;display:flex;flex-direction:column;gap:4px">
<button class="gp-btn" onclick="geomSim&&geomSim.reset()" title="Очистить всё">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
Очистить
</button>
</div>
</div><!-- /.geo-panel -->
<!-- canvas area -->
<div class="geo-canvas-outer">
<canvas id="geo-canvas"></canvas>
<div class="geo-hint-bar" id="geo-hint">Кликни для добавления точки</div>
</div>
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-geometry -->
<!-- ── Theory panel (overlay right) ── -->
<div class="theory-panel" id="theory-panel">
<div class="theory-panel-inner" id="theory-content"></div>
@@ -4188,6 +4420,21 @@
stroke="#F15BB5" stroke-width="2.5" fill="none" opacity="0.9"/>
<text x="135" y="132" font-size="8" fill="rgba(255,255,255,0.35)" text-anchor="middle" font-family="Manrope,sans-serif">v = \u03bbf \u00b7 y = A sin(\u03c9t \u2212 kx) \u00b7 \u0441\u0442\u043e\u044f\u0447\u0438\u0435 \u0432\u043e\u043b\u043d\u044b</text>`);
/* Geometry (planimetry) preview */
const P_GEOMETRY = _svg(`${_grid('rgba(255,255,255,0.04)')}
<circle cx="135" cy="70" r="50" fill="rgba(155,93,229,0.07)" stroke="#9B5DE5" stroke-width="1.5"/>
<polygon points="85,99 185,99 135,20" fill="rgba(6,214,224,0.08)" stroke="#06D6E0" stroke-width="1.8"/>
<line x1="85" y1="99" x2="162" y2="57" stroke="rgba(241,91,181,0.45)" stroke-width="1.2" stroke-dasharray="4,3"/>
<line x1="185" y1="99" x2="109" y2="57" stroke="rgba(241,91,181,0.45)" stroke-width="1.2" stroke-dasharray="4,3"/>
<line x1="135" y1="20" x2="135" y2="99" stroke="rgba(241,91,181,0.45)" stroke-width="1.2" stroke-dasharray="4,3"/>
<circle cx="135" cy="64" r="4" fill="#06D6E0" opacity="0.9"/>
<circle cx="85" cy="99" r="4" fill="#9B5DE5"/>
<circle cx="185" cy="99" r="4" fill="#9B5DE5"/>
<circle cx="135" cy="20" r="4" fill="#9B5DE5"/>
<text x="78" y="111" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">A</text>
<text x="188" y="111" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">B</text>
<text x="131" y="16" font-size="9" fill="rgba(255,255,255,0.5)" font-family="Manrope,sans-serif">C</text>`);
const SIMS = [
/* ── Математика ── */
{ id: 'graph', cat: 'math',
@@ -4198,6 +4445,10 @@
title: 'Трансформации графиков',
desc: 'Наблюдай, как сдвиги, растяжения и отражения меняют вид функции y = a·f(kx+b)+c.',
preview: P_TRANSFORM },
{ id: 'geometry', cat: 'math',
title: 'Планиметрия',
desc: 'Интерактивная среда построений: точки, отрезки, прямые, окружности, многоугольники. Полноценный чертёж с привязкой и измерениями.',
preview: P_GEOMETRY },
{ id: 'triangle', cat: 'math',
title: 'Геометрия треугольника',
desc: 'Интерактивный треугольник: медианы, высоты, биссектрисы, вписанная и описанная окружности.',
@@ -4359,6 +4610,7 @@
let bohrSim = null;
let elecSim = null;
let wavesSim = null;
let geomSim = null;
const ALL_SIM_BODIES = ['sim-graph','sim-proj','sim-coll','sim-tri','sim-trigcircle','sim-mag',
'sim-molphys',
@@ -4368,11 +4620,12 @@
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration',
'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
'sim-waves','sim-hydro'];
'sim-waves','sim-hydro','sim-geometry'];
const ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag',
'ctrl-molphys',
'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro'];
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro',
'ctrl-geometry'];
/* ── sim routing ── */
@@ -4425,6 +4678,7 @@
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
if (id === 'geometry') _openGeometry();
}
function _simShow(elId) {
@@ -5012,6 +5266,87 @@
document.getElementById('tbar-Rr').textContent = f2(s.R) + ' / ' + f2(s.r);
}
/* ── geometry (planimetry) ── */
const _GEO_HINTS = {
select: 'Клик — выбрать объект, перетащи точку для перемещения',
point: 'Клик — поставить точку',
segment: 'Кликни 2 точки для отрезка',
line: 'Кликни 2 точки для прямой',
ray: 'Кликни: начало, затем направление',
circle: 'Клик — центр; второй клик — радиус',
triangle: 'Кликни 3 точки для треугольника',
quad: 'Кликни 4 точки для четырёхугольника',
polygon: 'Кликай точки; двойной клик или Enter — завершить',
};
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');
const hint = document.getElementById('geo-hint');
if (hint) hint.textContent = _GEO_HINTS[name] || '';
}
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;
}
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;
// 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 ── */
let trigSim = null;
@@ -8320,5 +8655,6 @@
<script src="/js/labs/bohratom.js"></script>
<script src="/js/labs/electrolysis.js"></script>
<script src="/js/labs/hydrostatics.js"></script>
<script src="/js/labs/geometry.js"></script>
</body>
</html>