Files
Learn_System/frontend/js/labs/_sim_engine.js
T

1419 lines
66 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.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
SimEngine — рантайм спек-симуляций конструктора (Фаза 0).
Берёт JSON-спеку (данные, не код) и монтирует интерактивную сцену в host:
- canvas для геометрии/объектов (мир→экран по viewport, ось Y вверх);
- оверлей-слой <div> для подписей с LaTeX (рендер через KaTeX — тем же путём,
что graph.js: katex.renderToString);
- панель контролов: слайдеры из spec.params[] + кнопки play/pause/reset.
Любое числовое свойство объекта может быть числом ИЛИ строкой-выражением.
Выражения компилируются ОДИН РАЗ при mount (через SimExpr), в rAF-цикле — только
evaluate по окружению env = { t, ...params, <obj>.x, <obj>.y, w, h }.
Никакого eval/new Function. Кривая формула не роняет цикл (SimExpr.evaluate -> 0).
── ФОРМАТ СПЕКИ v1 ──────────────────────────────────────────────────────
{
specVersion: 1,
meta: { title, desc }, // подпись топбара/каталога
viewport: { xmin, xmax, ymin, ymax, // мировые границы (мат. оси)
grid?:true, axes?:true,
bg?:'#0D0D1A' },
params: [ // слайдеры -> переменные env
{ name:'v', label:'Скорость', min:0, max:30, step:0.5, value:18, unit:'м/с' },
{ name:'theta', label:'Угол', min:0, max:90, step:1, value:45, unit:'°' }
],
time: { autoplay?:false, loop?:true, duration?:0, speed?:1 }, // t-цикл
objects: [ // числа ИЛИ строки-выражения
{ id:'p', type:'point', x:'v*cos(theta*pi/180)*t', y:'...', r:6, color:'#06D6E0',
trail?:true, trailColor?:'#06D6E0' },
{ type:'segment', x1:0,y1:0, x2:'p.x', y2:'p.y', color:'#fff', width:2 },
{ type:'vector', x1:..,y1:.., x2:..,y2:.., color, width },
{ type:'circle', x, y, r, color, fill?, width },
{ type:'rect', x, y, w, h, color, fill?, width }, // x,y = центр
{ type:'polyline', points:[[x,y],...], color, width, closed? },
{ type:'path', points:[[x,y],...], ... }, // alias polyline
{ type:'label', x, y, text:'LaTeX', latex?:true, color, size?:14 },
// ── Фаза 1 ──
{ type:'plot', // график выражения в мир-коорд.
expr:'sin(x)', // f(<var>) (или f(t) при trace)
var:'x', // имя свободной переменной (деф. 'x')
range:[a,b], // отрезок построения (деф. xmin..xmax)
samples?:200, // число точек (деф. 200, клампится)
trace?:false, // true -> точка (varValue=t) пишется в след по времени
color?, width? },
{ type:'vector', origin:[ox,oy], dx, dy, // стрелка из origin на (dx,dy)
color?, width? }, // (x1/y1/x2/y2 тоже поддерживаются)
{ type:'readout', // живой числовой бейдж
label?:'R', expr:'...', unit?:'м', precision?:2,
x?, y?, // мир-координаты (деф. угол вьюпорта)
color? },
// Любой point/circle может стать перетаскиваемой ручкой:
{ type:'point', x:'x0', y:'y0',
drag:{ param:'x0', axis:'x'|'y'|'xy', min?, max?, paramY? } },
// ── Фаза 2 (физика) ──
// Любой point/circle может стать физическим телом (интегрируется движком,
// а не формулой). Начальные x/y/vx/vy — числа ИЛИ выражения от params/констант
// (вычисляются один раз при reset/init, далее интегрируются).
{ id:'ball', type:'circle', r:0.6,
x:'x0', y:'y0', // начальная позиция (вычисляется на reset)
body:{ mass?:1, vx?:'0', vy?:'0', fixed?:false } }
],
// ── ФИЗИКА (Фаза 2) ── глобальный блок сил/мира. enabled включает интегратор.
physics: {
enabled: true,
gravity: { x:0, y:-9.8 }, // ускорение свободного падения (мир/с^2)
friction?: 0, // линейное вязкое затухание скорости (1/с)
restitution?: 0.9, // упругость столкновений 0..1 (деф. 1)
dt?: 1/240, // фикс. шаг интегратора (деф. 1/240, кламп)
walls?: [ // отражающие стены (мир-координаты)
{ side:'bottom'|'top'|'left'|'right' }, // авто из viewport-границы
{ x1,y1, x2,y2 } // произвольный отрезок-стена
],
springs?: [
{ a:'ballId'|[x,y], b:'ballId'|[x,y], // концы: id тела ИЛИ якорь-точка
k:40, length:2, damping?:0.5 }
]
}
}
Выражения видят: t, все params по имени, w/h (мир-размер вьюпорта), а также
<objId>.x / <objId>.y для объектов, у которых заданы числовые/выраж. x,y.
Для физических тел (body) в env кладутся <objId>.x/.y/.vx/.vy ИЗ СОСТОЯНИЯ
интегратора (а не из выражения) — это снимает проблему forward-ref однопроходного
env для тел: их позиция/скорость не пересчитываются формулой каждый кадр.
── ИНТЕРАКЦИИ (Фаза 1) ──────────────────────────────────────────────────
Объект с полем drag:{param, axis, min?, max?, paramY?} становится ручкой:
перетаскивание мышью/тачем (pointer events) пишет мир-координату курсора в
параметр(ы). axis:'x' -> param=x; 'y' -> param=y; 'xy' -> param=x, paramY=y
(или param используется для x, paramY для y). Позиция ручки следует за
параметром, когда её не тащат (x/y объекта обычно = тот же параметр).
Хит-тест — в экранных пикселях с допуском; ручки имеют приоритет.
── API инстанса ──────────────────────────────────────────────────────────
var inst = SimEngine.mount(host, spec);
inst.play() inst.pause() inst.reset()
inst.setParam(name, value)
inst.getParam(name) -> number
inst.isRunning() -> bool
inst.destroy()
inst.el -> корневой DOM-узел (для скрытия/показа адаптером)
════════════════════════════════════════════════════════════════════════ */
(function (global) {
var DEFAULT_BG = '#0D0D1A';
function num(v, dflt) { return typeof v === 'number' && isFinite(v) ? v : dflt; }
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
function bind(value, dflt) {
if (value === undefined || value === null) {
var d = dflt;
return { ev: function () { return d; }, constant: true };
}
var c = global.SimExpr ? global.SimExpr.compileValue(value)
: { fn: function () { return num(value, dflt); }, constant: true };
return { ev: c.fn, constant: !!c.constant, error: c.error || null };
}
/* ════════════════════════════════════════════════════════════════════════
SimPhysics — фиксированно-шаговый физический интегратор для спек-движка.
Опирается на ту же математику, что и LabFX.motion.spring (_fx_motion.js):
полу-неявный (симплектический) метод Эйлера
a = F/m ; v += a*dt ; x += v*dt
— но обобщён на набор связанных тел с гравитацией, пружинами, вязким
трением и упругими столкновениями (круг-круг, круг-стена). Шаг dt
фиксированный (накопитель), что даёт устойчивость (энергия не «взрывается»).
Тело: { id, x, y, vx, vy, mass, radius, fixed }.
world.step(bodies, springs, opts, dt) — продвигает состояние на один кадр.
Без eval/Function, без DOM — переиспользуемо headless (Ф3/билдер/тесты).
════════════════════════════════════════════════════════════════════════ */
var SimPhysics = (function () {
var MAX_V = 1e4; // кламп скорости (защита от взрыва численной нестабильности)
var MAX_SUBSTEPS = 8; // не более N фикс-шагов на кадр (защита от спирали смерти)
function clampV(v) { return v > MAX_V ? MAX_V : (v < -MAX_V ? -MAX_V : v); }
/* Один фиксированный шаг интегратора (полу-неявный Эйлер). */
function integrate(bodies, springs, opts, dt) {
var gx = opts.gx || 0, gy = opts.gy || 0;
var friction = opts.friction || 0; // вязкое затухание (1/с)
var i, b;
// 1) силы -> ускорение -> скорость (симплектический Эйлер): начинаем с гравитации
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.fixed) { b.vx = 0; b.vy = 0; continue; }
b.fx = gx * b.mass;
b.fy = gy * b.mass;
}
// 2) силы пружин (Гука + демпфирование по относительной скорости вдоль оси)
for (i = 0; i < springs.length; i++) {
applySpring(springs[i], dt);
}
// 3) интеграция скорости и позиции
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.fixed) continue;
var ax = b.fx / b.mass, ay = b.fy / b.mass;
b.vx += ax * dt;
b.vy += ay * dt;
// вязкое трение: экспоненциальное затухание скорости (устойчиво при любом dt)
if (friction > 0) {
var damp = Math.exp(-friction * dt);
b.vx *= damp; b.vy *= damp;
}
b.vx = clampV(b.vx); b.vy = clampV(b.vy);
b.x += b.vx * dt;
b.y += b.vy * dt;
}
}
/* Пружина между двумя концами. Конец — тело {x,y,vx,vy,mass,fixed} или
якорь {x,y,fixed:true,mass:Infinity}. Сила добавляется в b.fx/b.fy тел. */
function applySpring(s, dt) {
var a = s.a, b = s.b;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1e-6) return; // совпали — направление не определено
var nx = dx / dist, ny = dy / dist;
var ext = dist - s.length; // растяжение (>0) / сжатие (<0)
var fMag = s.k * ext; // закон Гука
// демпфирование вдоль оси пружины
if (s.damping) {
var rvx = (b.vx || 0) - (a.vx || 0);
var rvy = (b.vy || 0) - (a.vy || 0);
var relAlong = rvx * nx + rvy * ny;
fMag += s.damping * relAlong;
}
var fxs = fMag * nx, fys = fMag * ny;
// сила тянет a к b (если растянута) и отталкивает (если сжата)
if (!a.fixed && a.fx !== undefined) { a.fx += fxs; a.fy += fys; }
if (!b.fixed && b.fx !== undefined) { b.fx -= fxs; b.fy -= fys; }
}
/* Столкновения круг-круг (упругие, по нормали) + круг-стена/границы. */
function resolveCollisions(bodies, walls, restitution) {
var e = (typeof restitution === 'number') ? restitution : 1;
var i, j;
// круг-круг (broadphase O(n^2) — N мало)
for (i = 0; i < bodies.length; i++) {
for (j = i + 1; j < bodies.length; j++) {
collideCircles(bodies[i], bodies[j], e);
}
}
// круг-стена
for (i = 0; i < bodies.length; i++) {
for (j = 0; j < walls.length; j++) {
collideWall(bodies[i], walls[j], e);
}
}
}
function collideCircles(a, b, e) {
if (a.fixed && b.fixed) return;
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy);
var minD = a.radius + b.radius;
if (dist >= minD || dist < 1e-9) return;
var nx = dx / dist, ny = dy / dist;
// позиционная коррекция (распихать, чтобы не слипались) — по обратным массам
var imA = a.fixed ? 0 : 1 / a.mass;
var imB = b.fixed ? 0 : 1 / b.mass;
var imSum = imA + imB; if (imSum === 0) return;
var pen = minD - dist;
a.x -= nx * pen * (imA / imSum);
a.y -= ny * pen * (imA / imSum);
b.x += nx * pen * (imB / imSum);
b.y += ny * pen * (imB / imSum);
// относительная скорость вдоль нормали
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var velN = rvx * nx + rvy * ny;
if (velN > 0) return; // уже разлетаются
var jImp = -(1 + e) * velN / imSum;
var ix = jImp * nx, iy = jImp * ny;
a.vx -= ix * imA; a.vy -= iy * imA;
b.vx += ix * imB; b.vy += iy * imB;
}
/* Стена: либо нормаль+offset (из границы viewport), либо отрезок x1y1-x2y2. */
function collideWall(b, wall, e) {
if (b.fixed) return;
var nx = wall.nx, ny = wall.ny; // нормаль внутрь рабочей области
// расстояние от центра тела до плоскости стены вдоль нормали
var d = (b.x - wall.px) * nx + (b.y - wall.py) * ny;
if (d >= b.radius) return; // не касается
var pen = b.radius - d;
b.x += nx * pen; b.y += ny * pen; // вытолкнуть
var velN = b.vx * nx + b.vy * ny;
if (velN < 0) { // движется в стену — отразить
b.vx -= (1 + e) * velN * nx;
b.vy -= (1 + e) * velN * ny;
}
}
/* Продвинуть мир на dtFrame секунд фиксированными подшагами dt (накопитель). */
function step(state, dtFrame) {
var dt = state.dt;
state.acc += dtFrame;
var n = 0;
while (state.acc >= dt && n < MAX_SUBSTEPS) {
integrate(state.bodies, state.springs, state.opts, dt);
resolveCollisions(state.bodies, state.walls, state.opts.restitution);
state.acc -= dt;
n++;
}
// защита от спирали смерти: если накопилось слишком много — сбросить остаток
if (state.acc > dt) state.acc = 0;
}
return {
step: step,
integrate: integrate,
resolveCollisions: resolveCollisions,
_MAX_V: MAX_V
};
})();
global.SimPhysics = SimPhysics;
/* ════════════════════ Instance ════════════════════ */
function SimEngineInstance(host, spec) {
this.host = host;
this.spec = spec || {};
this.params = {}; // name -> текущее значение (number)
this._sliders = {}; // name -> input element
this._paramRange = {}; // name -> { min, max } (для clamp при drag)
this._objs = []; // подготовленные объекты с привязками
this._trails = {}; // objId -> [[x,y],...] (мир-координаты)
this._t = 0;
this._running = false;
this._raf = 0;
this._last = 0;
this._dpr = 1;
this._cw = 0; this._ch = 0;
this._destroyed = false;
this._ro = null;
this._dragging = null; // текущая перетаскиваемая ручка (drag)
this._readoutSlot = 0; // счётчик автопозиционируемых readout-бейджей
// ── физика (Фаза 2) ──
this._phys = null; // состояние интегратора { bodies, springs, walls, opts, dt, acc }
this._bodyById = {}; // objId -> body (для drag/env/пружин)
this._dragBody = null; // активный захват физ-тела { body, lastW, lastT, vx, vy }
this._build();
}
SimEngineInstance.prototype._vp = function () {
var v = this.spec.viewport || {};
return {
xmin: num(v.xmin, -1), xmax: num(v.xmax, 10),
ymin: num(v.ymin, -1), ymax: num(v.ymax, 10),
grid: v.grid !== false,
axes: v.axes !== false,
bg: v.bg || DEFAULT_BG
};
};
SimEngineInstance.prototype._build = function () {
var self = this;
var spec = this.spec;
// корень
var root = document.createElement('div');
root.className = 'sim-spec-root';
root.style.cssText = 'flex:1;min-height:0;display:flex;width:100%;height:100%;background:' +
(this._vp().bg) + ';color:#fff;font-family:Manrope,system-ui,sans-serif';
this.el = root;
// ── панель контролов слева ──
var panel = document.createElement('div');
panel.className = 'sim-spec-panel';
panel.style.cssText = 'width:260px;flex-shrink:0;background:rgba(13,13,26,0.92);' +
'border-right:1px solid rgba(255,255,255,0.08);display:flex;flex-direction:column;' +
'gap:10px;padding:16px 14px;overflow-y:auto';
// заголовок + кнопки play/pause/reset
var ctrlRow = document.createElement('div');
ctrlRow.style.cssText = 'display:flex;gap:6px;align-items:center';
var btnPlay = this._btn(this._playIcon(true), 'Запустить / пауза');
var btnReset = this._btn(this._resetIcon(), 'Сброс');
this._btnPlay = btnPlay;
btnPlay.addEventListener('click', function () { self._running ? self.pause() : self.play(); });
btnReset.addEventListener('click', function () { self.reset(); });
ctrlRow.appendChild(btnPlay);
ctrlRow.appendChild(btnReset);
panel.appendChild(ctrlRow);
// слайдеры параметров
var params = Array.isArray(spec.params) ? spec.params : [];
params.forEach(function (p) {
if (!p || !p.name) return;
var min = num(p.min, 0), max = num(p.max, 100), step = num(p.step, 1);
var val = num(p.value, min);
self.params[p.name] = val;
self._paramRange[p.name] = { min: min, max: max }; // для clamp при drag
var wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px';
var lblRow = document.createElement('div');
lblRow.style.cssText = 'display:flex;justify-content:space-between;font-size:.74rem;color:rgba(255,255,255,.6)';
var lblName = document.createElement('span');
lblName.textContent = p.label || p.name;
var lblVal = document.createElement('span');
lblVal.style.cssText = 'color:#06D6E0;font-weight:700;font-variant-numeric:tabular-nums';
lblVal.textContent = _fmt(val) + (p.unit ? ' ' + p.unit : '');
lblRow.appendChild(lblName); lblRow.appendChild(lblVal);
var slider = document.createElement('input');
slider.type = 'range';
slider.min = String(min); slider.max = String(max); slider.step = String(step);
slider.value = String(val);
slider.style.cssText = 'width:100%;accent-color:#9B5DE5;cursor:pointer';
slider.addEventListener('input', function () {
var v = parseFloat(slider.value);
self.params[p.name] = v;
lblVal.textContent = _fmt(v) + (p.unit ? ' ' + p.unit : '');
if (!self._running) {
// на паузе в начале — пересобрать нач. условия физ-тел (предпросмотр старта)
if (self._phys && self._t === 0) self._preparePhysics();
self._renderFrame(); // живой предпросмотр на паузе
}
});
wrap.appendChild(lblRow);
wrap.appendChild(slider);
panel.appendChild(wrap);
self._sliders[p.name] = slider;
});
// ── сцена справа (canvas + оверлей подписей) ──
var stage = document.createElement('div');
stage.className = 'sim-spec-stage';
stage.style.cssText = 'flex:1;min-width:0;position:relative;overflow:hidden';
var canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block';
stage.appendChild(canvas);
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
var labels = document.createElement('div');
labels.className = 'sim-spec-labels';
labels.style.cssText = 'position:absolute;inset:0;pointer-events:none';
stage.appendChild(labels);
this._labelLayer = labels;
root.appendChild(panel);
root.appendChild(stage);
this.host.appendChild(root);
// подготовить объекты (компиляция привязок один раз)
this._prepareObjects();
// resize
if (global.ResizeObserver) {
this._ro = new ResizeObserver(function () { self._fit(); self._renderFrame(); });
this._ro.observe(stage);
}
// drag-интеракции (мышь + тач через pointer events)
this._setupDrag();
// первичная подгонка после layout
requestAnimationFrame(function () {
self._fit();
self.reset();
var time = spec.time || {};
if (time.autoplay) self.play();
});
};
/* кнопка контрола (inline SVG, без эмодзи) */
SimEngineInstance.prototype._btn = function (innerSvg, title) {
var b = document.createElement('button');
b.title = title || '';
b.innerHTML = innerSvg;
b.style.cssText = 'min-width:34px;height:34px;border-radius:10px;border:1.5px solid rgba(255,255,255,.18);' +
'background:transparent;color:rgba(255,255,255,.75);cursor:pointer;display:flex;align-items:center;' +
'justify-content:center;padding:0 9px;transition:all .15s';
b.addEventListener('mouseenter', function () { b.style.borderColor = '#9B5DE5'; b.style.color = '#9B5DE5'; });
b.addEventListener('mouseleave', function () { b.style.borderColor = 'rgba(255,255,255,.18)'; b.style.color = 'rgba(255,255,255,.75)'; });
return b;
};
SimEngineInstance.prototype._playIcon = function (play) {
return play
? '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>'
: '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>';
};
SimEngineInstance.prototype._resetIcon = function () {
return '<svg viewBox="0 0 24 24" width="15" height="15" 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>';
};
SimEngineInstance.prototype._syncPlayBtn = function () {
if (this._btnPlay) this._btnPlay.innerHTML = this._playIcon(!this._running);
};
/* Компиляция привязок объектов один раз. */
SimEngineInstance.prototype._prepareObjects = function () {
var raw = Array.isArray(this.spec.objects) ? this.spec.objects : [];
var out = [];
for (var i = 0; i < raw.length; i++) {
var o = raw[i] || {};
var type = o.type || 'point';
var prep = { id: o.id || ('obj' + i), type: type, raw: o, b: {} };
// общие визуальные поля (не привязки)
prep.color = o.color || '#06D6E0';
prep.fillColor = o.fill || o.fillColor || null;
prep.width = num(o.width, 2);
prep.trail = !!o.trail;
prep.trailColor = o.trailColor || prep.color;
prep.latex = o.latex !== false; // подписи по умолчанию рендерятся KaTeX
prep.size = num(o.size, 14);
prep.text = o.text != null ? String(o.text) : '';
// числовые/выражения-привязки по типу
var B = prep.b;
function bp(key, dflt) { B[key] = bind(o[key], dflt); }
if (type === 'point') { bp('x', 0); bp('y', 0); B.r = bind(o.r, 6); }
else if (type === 'segment') { bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1); }
else if (type === 'vector') {
// vector: либо x1/y1/x2/y2, либо origin:[ox,oy] + dx/dy
if (Array.isArray(o.origin) || o.dx !== undefined || o.dy !== undefined) {
var org = Array.isArray(o.origin) ? o.origin : [0, 0];
B.x1 = bind(org[0], 0); B.y1 = bind(org[1], 0);
// конец = origin + (dx,dy): сумма привязок
var ox = B.x1, oy = B.y1;
var dxB = bind(o.dx, 1), dyB = bind(o.dy, 1);
B.x2 = { ev: function (env) { return ox.ev(env) + dxB.ev(env); } };
B.y2 = { ev: function (env) { return oy.ev(env) + dyB.ev(env); } };
} else {
bp('x1', 0); bp('y1', 0); bp('x2', 1); bp('y2', 1);
}
}
else if (type === 'circle') { bp('x', 0); bp('y', 0); bp('r', 1); }
else if (type === 'rect') { bp('x', 0); bp('y', 0); bp('w', 1); bp('h', 1); }
else if (type === 'polyline' || type === 'path') {
prep.pts = (Array.isArray(o.points) ? o.points : []).map(function (pt) {
return { x: bind(pt[0], 0), y: bind(pt[1], 0) };
});
prep.closed = !!o.closed;
} else if (type === 'label') {
bp('x', 0); bp('y', 0);
} else if (type === 'plot') {
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
var rng = Array.isArray(o.range) ? o.range : null;
prep.rangeA = bind(rng ? rng[0] : null, null);
prep.rangeB = bind(rng ? rng[1] : null, null);
prep.hasRange = !!rng;
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
prep.trace = !!o.trace;
} else if (type === 'readout') {
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
: { fn: function () { return 0; }, ast: null };
prep.exprFn = { ev: rc.fn };
prep.exprAst = rc.ast;
prep.label = o.label != null ? String(o.label) : '';
prep.unit = o.unit != null ? String(o.unit) : '';
prep.precision = Math.max(0, Math.min(8, num(o.precision, 2) | 0));
prep.hasPos = (o.x !== undefined && o.y !== undefined);
if (prep.hasPos) { bp('x', 0); bp('y', 0); }
}
// физическое тело (point/circle): интегрируется движком, а не формулой.
// Начальные x/y/vx/vy компилируем как выражения от params/констант (вычисляются
// один раз при reset/init), масса/радиус/fixed — статические.
if (o.body && (type === 'point' || type === 'circle')) {
var bd = o.body;
prep.body = {
mass0: bind(bd.mass !== undefined ? bd.mass : 1, 1), // масса: число/выражение от params
fixed: !!bd.fixed,
vx0: bind(bd.vx, 0), // нач. скорость X (число/выражение)
vy0: bind(bd.vy, 0), // нач. скорость Y
// радиус: для circle — мировой r (выражение); для point — экранный, мир-эквивалент
isCircle: (type === 'circle')
};
}
// drag-интеракция (point/circle): объект становится ручкой
if (o.drag && (type === 'point' || type === 'circle')) {
var dg = o.drag;
prep.drag = {
param: dg.param || null, // axis x|y -> этот параметр; xy -> X
paramY: dg.paramY || null, // axis xy -> Y (обязателен для 2D-ручки)
axis: (dg.axis === 'y' || dg.axis === 'xy') ? dg.axis : 'x',
min: typeof dg.min === 'number' ? dg.min : -Infinity,
max: typeof dg.max === 'number' ? dg.max : Infinity
};
}
// привязки для центра объекта (для obj.x/obj.y в env): point/circle/rect/label
if (B.x && B.y) { prep.hasCenter = true; }
out.push(prep);
}
this._objs = out;
};
/* ── физика: есть ли в спеке тела/включён ли интегратор ── */
SimEngineInstance.prototype._physEnabled = function () {
var ph = this.spec.physics;
if (!ph || ph.enabled === false) return false;
for (var i = 0; i < this._objs.length; i++) if (this._objs[i].body) return true;
return false;
};
/* Собрать/пересобрать состояние физики (вызывается на reset/init).
Начальные позиции/скорости вычисляются из выражений по env с params (без t). */
SimEngineInstance.prototype._preparePhysics = function () {
this._phys = null;
this._bodyById = {};
if (!this._physEnabled() || !global.SimPhysics) return;
var ph = this.spec.physics || {};
var vp = this._vp();
// env без t (нач. условия зависят только от params/констант/размеров вьюпорта)
var env = this._buildParamEnv();
var bodies = [];
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (!o.body) continue;
var x0 = o.b.x ? o.b.x.ev(env) : 0;
var y0 = o.b.y ? o.b.y.ev(env) : 0;
// радиус тела в МИРОВЫХ единицах (для коллизий). circle: o.r — мировой.
// point: o.r — экранные px -> переводим в мир через текущий масштаб (фолбэк 0.3).
var rWorld;
if (o.body.isCircle) {
rWorld = o.b.r ? Math.abs(o.b.r.ev(env)) : 0.5;
if (!isFinite(rWorld) || rWorld <= 0) rWorld = 0.5;
} else {
var rpx = o.b.r ? o.b.r.ev(env) : 6;
rWorld = (this._scale && isFinite(rpx)) ? rpx / this._scale : 0.3;
if (!isFinite(rWorld) || rWorld <= 0) rWorld = 0.3;
}
var mass = num(o.body.mass0.ev(env), 1);
if (!(mass > 0) || !isFinite(mass)) mass = 1; // защита: масса > 0
var body = {
id: o.id,
x: num(x0, 0), y: num(y0, 0),
vx: num(o.body.vx0.ev(env), 0),
vy: num(o.body.vy0.ev(env), 0),
mass: mass,
radius: rWorld,
fixed: o.body.fixed,
fx: 0, fy: 0
};
bodies.push(body);
this._bodyById[o.id] = body;
}
// пружины: концы — id тела или якорь-точка [x,y]
var springs = [];
var rawSprings = Array.isArray(ph.springs) ? ph.springs : [];
for (var s = 0; s < rawSprings.length; s++) {
var rs = rawSprings[s] || {};
var endA = this._resolveSpringEnd(rs.a, env);
var endB = this._resolveSpringEnd(rs.b, env);
if (!endA || !endB) continue;
springs.push({
a: endA, b: endB,
k: num(bind(rs.k, 20).ev(env), 20), // k/length/damping — числа ИЛИ выражения от params
length: num(bind(rs.length, 1).ev(env), 1),
damping: num(bind(rs.damping, 0).ev(env), 0)
});
}
// стены: именованные стороны (из границ viewport) + произвольные отрезки
var walls = [];
var rawWalls = Array.isArray(ph.walls) ? ph.walls : [];
for (var w = 0; w < rawWalls.length; w++) {
var wl = this._buildWall(rawWalls[w], vp);
if (wl) walls.push(wl);
}
var dt = num(ph.dt, 1 / 240);
dt = _clamp(dt, 1 / 2000, 1 / 30); // кламп шага для устойчивости
this._phys = {
bodies: bodies,
springs: springs,
walls: walls,
opts: {
// gravity/friction/restitution — числа ИЛИ выражения от params (вычисляются на reset)
gx: ph.gravity ? num(bind(ph.gravity.x, 0).ev(env), 0) : 0,
gy: ph.gravity ? num(bind(ph.gravity.y, 0).ev(env), 0) : 0,
friction: num(bind(ph.friction, 0).ev(env), 0),
restitution: (ph.restitution === undefined || ph.restitution === null)
? 1 : _clamp(num(bind(ph.restitution, 1).ev(env), 1), 0, 1)
},
dt: dt,
acc: 0
};
};
/* конец пружины: строка-id тела -> сам body; массив [x,y]/[xExpr,yExpr] -> якорь. */
SimEngineInstance.prototype._resolveSpringEnd = function (end, env) {
if (typeof end === 'string') {
return this._bodyById[end] || null;
}
if (Array.isArray(end)) {
var ax = bind(end[0], 0).ev(env);
var ay = bind(end[1], 0).ev(env);
// якорь — «бесконечно тяжёлое» неподвижное тело (без fx-аккумуляции)
return { x: num(ax, 0), y: num(ay, 0), vx: 0, vy: 0, fixed: true };
}
return null;
};
/* стена -> { px,py (точка на стене), nx,ny (нормаль внутрь области) }. */
SimEngineInstance.prototype._buildWall = function (wl, vp) {
if (!wl) return null;
if (wl.side) {
switch (wl.side) {
case 'bottom': return { px: 0, py: vp.ymin, nx: 0, ny: 1 };
case 'top': return { px: 0, py: vp.ymax, nx: 0, ny: -1 };
case 'left': return { px: vp.xmin, py: 0, nx: 1, ny: 0 };
case 'right': return { px: vp.xmax, py: 0, nx: -1, ny: 0 };
}
return null;
}
// произвольный отрезок: нормаль = перпендикуляр, ориентированная к центру области
if (wl.x1 !== undefined && wl.x2 !== undefined) {
var x1 = num(wl.x1, 0), y1 = num(wl.y1, 0), x2 = num(wl.x2, 0), y2 = num(wl.y2, 0);
var dx = x2 - x1, dy = y2 - y1;
var len = Math.sqrt(dx * dx + dy * dy) || 1;
var nx = -dy / len, ny = dx / len;
// ориентировать нормаль к центру viewport
var cx = (vp.xmin + vp.xmax) / 2, cy = (vp.ymin + vp.ymax) / 2;
var mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
if ((cx - mx) * nx + (cy - my) * ny < 0) { nx = -nx; ny = -ny; }
return { px: x1, py: y1, nx: nx, ny: ny };
}
return null;
};
/* env только из params/вьюпорта (без t, без obj.x) — для нач. условий тел. */
SimEngineInstance.prototype._buildParamEnv = function () {
var env = {};
var p = this.params;
for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k];
env.t = 0;
var vp = this._vp();
env.w = vp.xmax - vp.xmin; env.h = vp.ymax - vp.ymin;
env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax;
return env;
};
/* Окружение для evaluate: t, params, w/h, и центры объектов (obj.x/obj.y). */
SimEngineInstance.prototype._buildEnv = function () {
var env = {};
var p = this.params;
for (var k in p) if (Object.prototype.hasOwnProperty.call(p, k)) env[k] = p[k];
env.t = this._t;
var vp = this._vp();
env.w = vp.xmax - vp.xmin;
env.h = vp.ymax - vp.ymin;
env.xmin = vp.xmin; env.xmax = vp.xmax; env.ymin = vp.ymin; env.ymax = vp.ymax;
// 1) физ-тела: x/y/vx/vy берутся ИЗ СОСТОЯНИЯ ИНТЕГРАТОРА, а не из выражения.
// Кладём их в env ПЕРВЫМИ — снимает forward-ref проблему однопроходного env:
// кинематические объекты, ссылающиеся на тело, видят его актуальную позицию.
if (this._phys) {
var bs = this._phys.bodies;
for (var bi = 0; bi < bs.length; bi++) {
var bb = bs[bi];
env[bb.id + '.x'] = bb.x;
env[bb.id + '.y'] = bb.y;
env[bb.id + '.vx'] = bb.vx;
env[bb.id + '.vy'] = bb.vy;
}
}
// 2) центры формульных объектов (одношагово; тела пропускаем — их x/y уже в env).
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.hasCenter && !o.body) {
var x = o.b.x.ev(env);
var y = o.b.y.ev(env);
env[o.id + '.x'] = x;
env[o.id + '.y'] = y;
}
}
return env;
};
/* ── трансформация мир→экран (ось Y вверх) с сохранением пропорций ── */
SimEngineInstance.prototype._fit = function () {
var c = this.canvas; if (!c) return;
var dpr = Math.min(global.devicePixelRatio || 1, 2);
var stage = c.parentElement || c;
var r = stage.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
var h = Math.max(1, Math.round(r.height));
this._dpr = dpr; this._cw = w; this._ch = h;
c.width = w * dpr; c.height = h * dpr;
if (this.ctx) this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// вычислить масштаб «вписать viewport, равные оси»
var vp = this._vp();
var vw = vp.xmax - vp.xmin || 1;
var vh = vp.ymax - vp.ymin || 1;
var pad = 16;
var sx = (w - pad * 2) / vw;
var sy = (h - pad * 2) / vh;
var s = Math.min(sx, sy);
this._scale = s;
// центр мира в центре canvas
var cxWorld = (vp.xmin + vp.xmax) / 2;
var cyWorld = (vp.ymin + vp.ymax) / 2;
this._offX = w / 2 - cxWorld * s;
this._offY = h / 2 + cyWorld * s; // +: т.к. Y инвертируется
};
SimEngineInstance.prototype._toPx = function (mx, my) {
return [this._offX + mx * this._scale, this._offY - my * this._scale];
};
/* экран (px, относит. stage) -> мир (обратно к _toPx) */
SimEngineInstance.prototype._toWorld = function (px, py) {
var s = this._scale || 1;
return [(px - this._offX) / s, (this._offY - py) / s];
};
/* ════════════════════ Drag-интеракции (мышь + тач) ════════════════════
Перетаскиваемы: (1) объекты с prep.drag — ручки, пишут мир-коорд. в параметр;
(2) физ-тела (prep.body, не fixed) — тащишь напрямую: задаёт позицию тела, при
отпускании сообщает скорость (бросок). Слушаем pointer events на canvas. */
SimEngineInstance.prototype._hasHandles = function () {
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.drag) return true;
if (o.body && !o.body.fixed) return true;
}
return false;
};
SimEngineInstance.prototype._setupDrag = function () {
if (!this.canvas || !this._hasHandles()) return;
var self = this;
var HIT_PX = 16; // допуск хит-теста в пикселях
function localXY(ev) {
var r = self.canvas.getBoundingClientRect();
return [ev.clientX - r.left, ev.clientY - r.top];
}
function pickHandle(lx, ly) {
// хит-тест в экранных пикселях, ближайшая ручка/тело в пределах допуска
var env = self._buildEnv();
var best = null, bestD = HIT_PX * HIT_PX;
for (var i = 0; i < self._objs.length; i++) {
var o = self._objs[i];
var ox, oy, hit = false;
if (o.body && !o.body.fixed) {
var body = self._bodyById[o.id];
if (!body) continue;
ox = body.x; oy = body.y; hit = true;
// у тела допуск = max(HIT_PX, его экранный радиус)
} else if (o.drag && o.b.x && o.b.y) {
ox = o.b.x.ev(env); oy = o.b.y.ev(env); hit = true;
}
if (!hit) continue;
var p = self._toPx(ox, oy);
var dx = p[0] - lx, dy = p[1] - ly;
var d = dx * dx + dy * dy;
var tol = bestD;
if (o.body) {
var rb = self._bodyById[o.id];
var rpx = rb ? rb.radius * (self._scale || 1) : HIT_PX;
var t = Math.max(HIT_PX, rpx); t = t * t;
if (d <= t && d <= bestD) { bestD = d; best = o; }
continue;
}
if (d <= tol) { bestD = d; best = o; }
}
return best;
}
this._onPointerDown = function (ev) {
if (ev.button != null && ev.button !== 0) return; // только левая кнопка/тач
var xy = localXY(ev);
var h = pickHandle(xy[0], xy[1]);
if (!h) return;
self._dragging = h;
ev.preventDefault();
try { self.canvas.setPointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'grabbing';
if (h.body) {
// захват тела: запоминаем для оценки скорости броска
var body = self._bodyById[h.id];
self._dragBody = { body: body, lastW: self._toWorld(xy[0], xy[1]), lastT: _nowMs(), vx: 0, vy: 0 };
if (body) { body.vx = 0; body.vy = 0; }
} else {
self._dragBody = null;
}
self._applyDrag(h, xy[0], xy[1]);
};
this._onPointerMove = function (ev) {
var xy = localXY(ev);
if (self._dragging) {
ev.preventDefault();
self._applyDrag(self._dragging, xy[0], xy[1]);
} else {
// hover-курсор над ручкой/телом
self.canvas.style.cursor = pickHandle(xy[0], xy[1]) ? 'grab' : 'default';
}
};
this._onPointerUp = function (ev) {
if (!self._dragging) return;
// тело: при отпускании сообщить накопленную скорость (бросок)
if (self._dragBody && self._dragBody.body && !self._dragBody.body.fixed) {
self._dragBody.body.vx = SimEngineInstance._clampThrow(self._dragBody.vx);
self._dragBody.body.vy = SimEngineInstance._clampThrow(self._dragBody.vy);
}
self._dragging = null;
self._dragBody = null;
try { self.canvas.releasePointerCapture(ev.pointerId); } catch (e) {}
self.canvas.style.cursor = 'default';
};
var c = this.canvas;
c.style.touchAction = 'none'; // не давать браузеру скроллить/зумить при тач-drag
c.addEventListener('pointerdown', this._onPointerDown);
c.addEventListener('pointermove', this._onPointerMove);
c.addEventListener('pointerup', this._onPointerUp);
c.addEventListener('pointercancel', this._onPointerUp);
};
/* кламп скорости броска (мир/с), чтобы рывок мыши не запускал тело в космос */
SimEngineInstance._clampThrow = function (v) {
var MAX = 40;
if (!isFinite(v)) return 0;
return v > MAX ? MAX : (v < -MAX ? -MAX : v);
};
/* записать мир-координату курсора: в параметр(ы) ручки ИЛИ в позицию тела */
SimEngineInstance.prototype._applyDrag = function (h, lx, ly) {
var w = this._toWorld(lx, ly);
// ── перетаскивание физ-тела: ставим позицию, оцениваем скорость для броска ──
if (h.body && this._dragBody && this._dragBody.body) {
var body = this._dragBody.body;
body.x = w[0]; body.y = w[1];
var now = _nowMs();
var dt = (now - this._dragBody.lastT) / 1000;
if (dt > 0.0005) {
// экспоненциальное сглаживание оценки скорости
var ivx = (w[0] - this._dragBody.lastW[0]) / dt;
var ivy = (w[1] - this._dragBody.lastW[1]) / dt;
this._dragBody.vx = this._dragBody.vx * 0.5 + ivx * 0.5;
this._dragBody.vy = this._dragBody.vy * 0.5 + ivy * 0.5;
this._dragBody.lastW = w;
this._dragBody.lastT = now;
}
body.vx = 0; body.vy = 0; // пока держим — тело не интегрируется по скорости
if (!this._running) this._renderFrame();
return;
}
// ── перетаскивание ручки (Ф1): пишем в параметр(ы) ──
var d = h.drag;
if (d.axis === 'x') {
if (d.param) this._setParamClamped(d.param, w[0], d);
} else if (d.axis === 'y') {
if (d.param) this._setParamClamped(d.param, w[1], d);
} else { // 'xy'
if (d.param) this._setParamClamped(d.param, w[0], d);
if (d.paramY) this._setParamClamped(d.paramY, w[1], d);
}
if (!this._running) this._renderFrame(); // живой отклик на паузе
};
SimEngineInstance.prototype._setParamClamped = function (name, value, d) {
var v = _clamp(value, d.min, d.max);
// дополнительно зажать в диапазон самого параметра (слайдера), не полагаясь на DOM
var pr = this._paramRange[name];
if (pr) v = _clamp(v, pr.min, pr.max);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) {
sl.value = String(v);
// синхронизировать подпись значения слайдера (input-обработчик обновит её)
sl.dispatchEvent(new Event('input'));
}
};
/* ════════════════════ Рендер кадра ════════════════════ */
SimEngineInstance.prototype._renderFrame = function () {
var ctx = this.ctx; if (!ctx) return;
var W = this._cw, H = this._ch;
if (!W || !H) return;
var vp = this._vp();
ctx.fillStyle = vp.bg;
ctx.fillRect(0, 0, W, H);
if (vp.grid) this._drawGrid(ctx, W, H, vp);
if (vp.axes) this._drawAxes(ctx, W, H, vp);
var env = this._buildEnv();
this._readoutSlot = 0; // сброс счётчика бейджей-readout на каждый кадр
// обновить трассы (point/circle с trail + plot с trace)
for (var i = 0; i < this._objs.length; i++) {
var o = this._objs[i];
if (o.trail && o.hasCenter) {
var tx = env[o.id + '.x'], ty = env[o.id + '.y'];
if (typeof tx === 'number' && typeof ty === 'number') {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([tx, ty]);
if (arr.length > 2000) arr.shift();
}
} else if (o.type === 'plot' && o.trace) {
this._accumPlotTrace(o, env);
}
}
// нарисовать трассы под объектами
for (var ti = 0; ti < this._objs.length; ti++) {
var ot = this._objs[ti];
if ((ot.trail || (ot.type === 'plot' && ot.trace)) &&
this._trails[ot.id] && this._trails[ot.id].length > 1) {
this._drawTrail(ctx, this._trails[ot.id], ot.trailColor || ot.color);
}
}
// пружины (под объектами, поверх трасс)
if (this._phys && this._phys.springs.length) this._drawSprings(ctx);
// объекты
this._labelLayer.innerHTML = '';
for (var j = 0; j < this._objs.length; j++) {
this._drawObject(ctx, this._objs[j], env);
}
};
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
SimEngineInstance.prototype._drawSprings = function (ctx) {
var sp = this._phys.springs;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round';
for (var i = 0; i < sp.length; i++) {
var s = sp[i];
var a = this._toPx(s.a.x, s.a.y);
var b = this._toPx(s.b.x, s.b.y);
var dx = b[0] - a[0], dy = b[1] - a[1];
var len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) { ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke(); continue; }
var ux = dx / len, uy = dy / len; // вдоль
var px = -uy, py = ux; // перпендикуляр
var coils = Math.max(4, Math.min(24, Math.round(len / 14)));
var amp = 5;
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
for (var c = 1; c < coils; c++) {
var f = c / coils;
var off = (c % 2 === 0) ? amp : -amp;
ctx.lineTo(a[0] + ux * len * f + px * off, a[1] + uy * len * f + py * off);
}
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
ctx.restore();
};
/* накопить точку plot-trace (var=t) во след по времени */
SimEngineInstance.prototype._accumPlotTrace = function (o, env) {
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
var prev = env[o.varName];
env[o.varName] = env.t;
var ty = o.exprFn.ev(env);
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
if (typeof ty === 'number' && isFinite(ty)) {
var arr = this._trails[o.id] || (this._trails[o.id] = []);
arr.push([env.t, ty]);
if (arr.length > 2000) arr.shift();
}
};
SimEngineInstance.prototype._drawTrail = function (ctx, pts, color) {
ctx.save();
ctx.strokeStyle = color;
ctx.globalAlpha = 0.55;
ctx.lineWidth = 1.6;
ctx.lineJoin = 'round';
ctx.beginPath();
for (var i = 0; i < pts.length; i++) {
var px = this._toPx(pts[i][0], pts[i][1]);
i === 0 ? ctx.moveTo(px[0], px[1]) : ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
};
SimEngineInstance.prototype._drawObject = function (ctx, o, env) {
var B = o.b;
switch (o.type) {
case 'point': {
// физ-тело: позиция из интегратора (env уже содержит obj.x/obj.y тела);
// формульная точка: из выражения.
var pxw = o.body ? env[o.id + '.x'] : B.x.ev(env);
var pyw = o.body ? env[o.id + '.y'] : B.y.ev(env);
var p = this._toPx(pxw, pyw);
// r точки — экранный радиус в пикселях (выражение допустимо)
var r = Math.max(1, B.r.ev(env) || 6);
ctx.save();
ctx.fillStyle = o.color;
ctx.shadowColor = o.color; ctx.shadowBlur = 8;
ctx.beginPath(); ctx.arc(p[0], p[1], r, 0, Math.PI * 2); ctx.fill();
ctx.restore();
break;
}
case 'segment':
case 'vector': {
var a = this._toPx(B.x1.ev(env), B.y1.ev(env));
var b = this._toPx(B.x2.ev(env), B.y2.ev(env));
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke();
if (o.type === 'vector') this._arrowHead(ctx, a, b, o.color);
ctx.restore();
break;
}
case 'circle': {
var cxw = o.body ? env[o.id + '.x'] : B.x.ev(env);
var cyw = o.body ? env[o.id + '.y'] : B.y.ev(env);
var c0 = this._toPx(cxw, cyw);
var rad = Math.abs(B.r.ev(env)) * this._scale;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
if (o.fillColor) { ctx.fillStyle = o.fillColor; }
ctx.beginPath(); ctx.arc(c0[0], c0[1], rad, 0, Math.PI * 2);
if (o.fillColor) ctx.fill();
ctx.stroke();
ctx.restore();
break;
}
case 'rect': {
var cx = B.x.ev(env), cy = B.y.ev(env);
var rw = Math.abs(B.w.ev(env)), rh = Math.abs(B.h.ev(env));
var tl = this._toPx(cx - rw / 2, cy + rh / 2); // верх-лево (Y вверх)
var pw = rw * this._scale, ph = rh * this._scale;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width;
if (o.fillColor) { ctx.fillStyle = o.fillColor; ctx.fillRect(tl[0], tl[1], pw, ph); }
ctx.strokeRect(tl[0], tl[1], pw, ph);
ctx.restore();
break;
}
case 'polyline':
case 'path': {
if (!o.pts || !o.pts.length) break;
ctx.save();
ctx.strokeStyle = o.color; ctx.lineWidth = o.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
if (o.fillColor) ctx.fillStyle = o.fillColor;
ctx.beginPath();
for (var k = 0; k < o.pts.length; k++) {
var pp = this._toPx(o.pts[k].x.ev(env), o.pts[k].y.ev(env));
k === 0 ? ctx.moveTo(pp[0], pp[1]) : ctx.lineTo(pp[0], pp[1]);
}
if (o.closed) ctx.closePath();
if (o.fillColor) ctx.fill();
ctx.stroke();
ctx.restore();
break;
}
case 'label': {
var lp = this._toPx(B.x.ev(env), B.y.ev(env));
this._drawLabel(o, lp[0], lp[1]);
break;
}
case 'plot': {
this._drawPlot(ctx, o, env);
break;
}
case 'readout': {
this._drawReadout(o, env);
break;
}
}
};
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
// trace без явного range — только накапливаемый след (статической кривой нет)
if (o.trace && !o.hasRange) return;
var vp = this._vp();
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
if (a === b) return;
var n = o.samples;
var step = (b - a) / (n - 1);
// env-копия с дополнительной свободной переменной (не мутируем общий env навсегда)
var prev = env[o.varName];
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
ctx.save();
ctx.strokeStyle = o.color;
ctx.lineWidth = o.width;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
var started = false;
for (var i = 0; i < n; i++) {
var xv = a + step * i;
env[o.varName] = xv;
var yv = o.exprFn.ev(env);
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; }
var px = this._toPx(xv, yv);
if (!started) { ctx.moveTo(px[0], px[1]); started = true; }
else ctx.lineTo(px[0], px[1]);
}
ctx.stroke();
ctx.restore();
// восстановить env
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
};
/* ── readout: живое значение выражения как бейдж на оверлее ── */
SimEngineInstance.prototype._drawReadout = function (o, env) {
// мягкая ошибка через evalSafe (NaN/∞/синтаксис -> error, бейдж покажет «—»)
var res = (global.SimExpr && o.exprAst)
? global.SimExpr.evalSafe(o.exprAst, env)
: { value: o.exprFn.ev(env), error: null };
var el = document.createElement('div');
var px, py;
if (o.hasPos) {
var p = this._toPx(o.b.x.ev(env), o.b.y.ev(env));
px = p[0]; py = p[1];
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);' + _readoutBadgeCss(o.color);
} else {
// дефолт: верх-правый угол сцены, бейджи столбиком
var idx = (this._readoutSlot = (this._readoutSlot || 0) + 1) - 1;
el.style.cssText = 'position:absolute;right:10px;top:' + (10 + idx * 30) + 'px;' +
_readoutBadgeCss(o.color);
}
if (o.hasPos) { el.style.left = px + 'px'; el.style.top = py + 'px'; }
var valTxt = res.error ? '—' : _fmtFixed(res.value, o.precision);
var html = '';
if (o.label) html += '<span style="opacity:.7;margin-right:5px">' + _esc(o.label) + '</span>';
html += '<span style="font-weight:700;font-variant-numeric:tabular-nums">' + _esc(valTxt) + '</span>';
if (o.unit && !res.error) html += '<span style="opacity:.6;margin-left:3px">' + _esc(o.unit) + '</span>';
el.innerHTML = html;
this._labelLayer.appendChild(el);
};
SimEngineInstance.prototype._arrowHead = function (ctx, a, b, color) {
var ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
var s = 9;
ctx.save();
ctx.fillStyle = color;
ctx.translate(b[0], b[1]); ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.55); ctx.lineTo(-s * 1.6, s * 0.55);
ctx.closePath(); ctx.fill();
ctx.restore();
};
/* Подпись: KaTeX (тем же путём, что graph.js) с фолбэком на текст. */
SimEngineInstance.prototype._drawLabel = function (o, px, py) {
var el = document.createElement('div');
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);font-size:' + o.size +
'px;color:' + o.color + ';white-space:nowrap;text-shadow:0 1px 4px rgba(0,0,0,.6)';
el.style.left = px + 'px';
el.style.top = py + 'px';
var txt = o.text || '';
if (o.latex && typeof global.katex !== 'undefined' && txt) {
try {
el.innerHTML = global.katex.renderToString(txt, { throwOnError: false, strict: false, displayMode: false });
} catch (e) { el.textContent = txt; }
} else {
el.textContent = txt;
}
this._labelLayer.appendChild(el);
};
/* ── сетка/оси (мат. координаты, Y вверх) ── */
SimEngineInstance.prototype._drawGrid = function (ctx, W, H, vp) {
var step = this._niceStep(vp);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
ctx.lineWidth = 1;
var x;
var x0 = Math.ceil(vp.xmin / step) * step;
for (x = x0; x <= vp.xmax + 1e-9; x += step) {
var pxv = this._toPx(x, 0)[0];
ctx.beginPath(); ctx.moveTo(pxv, 0); ctx.lineTo(pxv, H); ctx.stroke();
}
var y0 = Math.ceil(vp.ymin / step) * step, y;
for (y = y0; y <= vp.ymax + 1e-9; y += step) {
var pyv = this._toPx(0, y)[1];
ctx.beginPath(); ctx.moveTo(0, pyv); ctx.lineTo(W, pyv); ctx.stroke();
}
ctx.restore();
};
SimEngineInstance.prototype._niceStep = function (vp) {
var span = Math.max(vp.xmax - vp.xmin, vp.ymax - vp.ymin);
var raw = span / 10;
var p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
var arr = [1, 2, 5, 10];
for (var i = 0; i < arr.length; i++) if (arr[i] * p >= raw) return arr[i] * p;
return p * 10;
};
SimEngineInstance.prototype._drawAxes = function (ctx, W, H, vp) {
var o = this._toPx(0, 0);
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1.5;
// ось X (если 0 в диапазоне Y)
if (0 >= vp.ymin && 0 <= vp.ymax) {
ctx.beginPath(); ctx.moveTo(0, o[1]); ctx.lineTo(W, o[1]); ctx.stroke();
}
// ось Y (если 0 в диапазоне X)
if (0 >= vp.xmin && 0 <= vp.xmax) {
ctx.beginPath(); ctx.moveTo(o[0], 0); ctx.lineTo(o[0], H); ctx.stroke();
}
ctx.restore();
};
/* ════════════════════ Жизненный цикл ════════════════════ */
SimEngineInstance.prototype.play = function () {
if (this._running || this._destroyed) return;
this._running = true;
this._syncPlayBtn();
var self = this;
var time = this.spec.time || {};
var speed = num(time.speed, 1);
var dur = num(time.duration, 0);
var loop = time.loop !== false;
this._last = (global.performance || Date).now();
function frame(now) {
if (!self._running) return;
var dt = Math.min((now - self._last) / 1000, 0.05) * speed;
self._last = now;
self._t += dt;
if (dur > 0 && self._t > dur) {
if (loop) { self._t = 0; self._trails = {}; self._preparePhysics(); }
else { self._t = dur; self._renderFrame(); self.pause(); return; }
}
// продвинуть физику фиксированными подшагами (если есть)
if (self._phys) self._stepPhysics(dt);
self._renderFrame();
self._raf = global.requestAnimationFrame(frame);
}
this._raf = global.requestAnimationFrame(frame);
};
/* продвинуть физику; удерживаемое тело временно «приколото» (fixed), чтобы
интегратор не уносил его, пока его тащит палец/мышь. */
SimEngineInstance.prototype._stepPhysics = function (dtFrame) {
if (!global.SimPhysics) return;
var held = (this._dragBody && this._dragBody.body) ? this._dragBody.body : null;
var wasFixed = held ? held.fixed : false;
if (held) { held.fixed = true; held.vx = 0; held.vy = 0; }
global.SimPhysics.step(this._phys, dtFrame);
if (held) held.fixed = wasFixed;
};
SimEngineInstance.prototype.pause = function () {
this._running = false;
if (this._raf) { global.cancelAnimationFrame(this._raf); this._raf = 0; }
this._syncPlayBtn();
};
SimEngineInstance.prototype.reset = function () {
this.pause();
this._t = 0;
this._trails = {};
this._dragBody = null;
this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params
this._renderFrame();
};
SimEngineInstance.prototype.setParam = function (name, value) {
var v = parseFloat(value);
if (!isFinite(v)) return;
this.params[name] = v;
var sl = this._sliders[name];
if (sl) { sl.value = String(v); sl.dispatchEvent(new Event('input')); }
else if (!this._running) this._renderFrame();
};
SimEngineInstance.prototype.getParam = function (name) { return this.params[name]; };
SimEngineInstance.prototype.isRunning = function () { return this._running; };
SimEngineInstance.prototype.destroy = function () {
this.pause();
this._destroyed = true;
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
// снять drag-слушатели
if (this.canvas && this._onPointerDown) {
var c = this.canvas;
c.removeEventListener('pointerdown', this._onPointerDown);
c.removeEventListener('pointermove', this._onPointerMove);
c.removeEventListener('pointerup', this._onPointerUp);
c.removeEventListener('pointercancel', this._onPointerUp);
}
this._onPointerDown = this._onPointerMove = this._onPointerUp = null;
this._dragging = null;
this._dragBody = null;
this._phys = null;
this._bodyById = {};
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null; this.canvas = null; this.ctx = null;
};
/* ── format helpers ── */
function _fmt(v) {
if (!isFinite(v)) return '—';
if (Number.isInteger(v)) return String(v);
return parseFloat(v.toFixed(3)).toString();
}
function _fmtFixed(v, prec) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(prec);
}
function _esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _readoutBadgeCss(color) {
return 'pointer-events:none;font-family:Manrope,system-ui,sans-serif;font-size:.78rem;' +
'color:' + (color || '#06D6E0') + ';background:rgba(13,13,26,0.78);' +
'border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:3px 9px;' +
'white-space:nowrap;backdrop-filter:blur(2px)';
}
function _clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function _nowMs() { return (global.performance && global.performance.now) ? global.performance.now() : Date.now(); }
/* ════════════════════ public ════════════════════ */
function mount(host, spec) {
if (!host) throw new Error('SimEngine.mount: нет host-элемента');
return new SimEngineInstance(host, spec || {});
}
global.SimEngine = {
mount: mount,
_Instance: SimEngineInstance // экспонируем для тестов/билдера (Фаза 4)
};
})(typeof window !== 'undefined' ? window : globalThis);