feat(sim-builder): фаза 2 — физический интегратор (SimPhysics: гравитация/пружины/столкновения, drag тел)
This commit is contained in:
@@ -74,3 +74,13 @@ git push origin master
|
||||
- **Drag** (`point`/`circle` с `drag:{param,axis,min,max,paramY}`): pointer events на canvas (мышь+тач, `touchAction:none`); хит-тест в экранных px (допуск 16px, ближайшая ручка), приоритет ручек. `axis:'xy'` требует `paramY`. Курсор → мир через `_toWorld` (инверсия `_toPx`) → `_setParamClamped` (clamp по `drag.min/max` И по диапазону параметра из `_paramRange` — не полагаться на DOM-clamp слайдера). Слушатели снимаются в `destroy`. Drag только point/circle (вершины polyline/конец вектора — не реализовано).
|
||||
- Тестировать движок headless: `vm.createContext` + ручной DOM/canvas-стаб (canvas-ctx через `Proxy` с noop). `_renderFrame` рано выходит при `_cw/_ch==0` — выставить вручную. `setParam`/drag используют `new Event('input')` (браузерно безопасно, в стабе нужен `Event`).
|
||||
- ⛔ `lab.html`/`lab-glue.js` — зона параллельной сессии (Ф2 измерит. инструменты); Ф1 их НЕ трогала, работала только в `_sim_engine.js`/`_sim_demo.js`.
|
||||
|
||||
### Phase 2 — Learnings
|
||||
|
||||
- **Физический режим** (всё в `_sim_engine.js`, формат — в шапке файла): блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. gravity/friction/restitution/k/length/damping/mass/нач.позиция/vx/vy — число ИЛИ выражение от params (вычисляются на **reset**, не каждый кадр — для стабильности).
|
||||
- **`window.SimPhysics`** — экспортированный интегратор (`step(state,dtFrame)`, `integrate`, `resolveCollisions`). Полу-неявный (симплектический) Эйлер `v+=a·dt; x+=v·dt` — та же математика, что `_fx_motion.spring`, обобщённая на N связанных тел. Фикс-шаг с накопителем (кламп dt 1/2000..1/30, кап подшагов 8, кламп скорости 1e4, вязкое трение `exp(-friction·dt)`) → энергия не «взрывается». Упругие столкновения круг-круг (импульс по нормали + позиционная коррекция по обратным массам) и круг-стена. Чистая функция над state, без DOM/eval — переиспользуемо headless. Отдельного файла `_sim_physics.js` НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри `_sim_engine.js`.
|
||||
- **`_fx_motion` API не подходит** для спек-движка напрямую: `tween`/`springFactory` — rAF-замыкания, тянущие ОДНО значение к цели, не связанные тела с силами. Переиспользована только их интеграционная математика (формула спринга), а не сами функции.
|
||||
- **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в `_buildEnv` ПЕРВЫМИ (до формульных центров) — это снимает forward-ref проблему однопроходного env для тел: формульный объект, ссылающийся на тело (`segment x2:'ball.x'`), видит актуальную позицию в том же кадре. point/circle с `body` рисуются из env-полей тела, а не из выражения x/y.
|
||||
- **Drag тела**: тело (point/circle с `body`, не fixed) перетаскиваемо по умолчанию (без `drag`-конфига). Тащишь — `body.x/y = курсор`, тело временно `fixed` в `_stepPhysics`; отпускаешь — `body.vx/vy` из сглаженной оценки скорости курсора (кламп 40 м/с). Хит-тест тела — max(16px, экранный радиус). drag-ручки Ф1 и физ-тела сосуществуют.
|
||||
- **Гочи**: (1) имя param **`e`** зарезервировано — это число Эйлера в SimExpr (parser проверяет CONSTANTS до env), выражение `e` даст 2.718, не значение param. Брать `el`/`elast`. (2) Радиус тела для коллизий: circle — мировой `r`; point — экранные px → мир через `_scale`, поэтому физика собирается в `reset()` ПОСЛЕ первого `_fit()`. (3) Слайдеры во время play меняют только env (readout/формулы), но НЕ силы/тела до reset (намеренно). На паузе при `t==0` — пересборка для предпросмотра старта.
|
||||
- Headless-тест физики: виртуальные часы (`vclock`) синхронны с `performance.now()` и таймстампом rAF (иначе первый кадр получит огромный/отрицательный dt и ничего не сдвинется).
|
||||
|
||||
@@ -90,37 +90,115 @@
|
||||
]
|
||||
};
|
||||
|
||||
/* ── Фаза 2: физ-демо за флагом ── */
|
||||
|
||||
// Маятник на пружине: груз (тело) подвешен пружиной к неподвижному якорю.
|
||||
// Гравитация тянет вниз, пружина возвращает -> колебания. Груз можно тащить.
|
||||
var PENDULUM_DEMO = {
|
||||
id: 'customphys',
|
||||
cat: 'phys',
|
||||
meta: { title: 'Демо: пружинный маятник', desc: 'Спек-физика (Фаза 2): тело + пружина + гравитация, drag тела.' },
|
||||
viewport: { xmin: -6, xmax: 6, ymin: -10, ymax: 2, grid: true, axes: true, bg: '#0D0D1A' },
|
||||
time: { autoplay: true, loop: false, speed: 1 },
|
||||
params: [
|
||||
{ name: 'k', label: 'Жёсткость k', min: 5, max: 120, step: 1, value: 40, unit: 'Н/м' },
|
||||
{ name: 'm', label: 'Масса m', min: 0.5, max: 5, step: 0.1, value: 1, unit: 'кг' },
|
||||
{ name: 'L', label: 'Длина L', min: 2, max: 8, step: 0.1, value: 5, unit: 'м' }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: -9.8 },
|
||||
friction: 0.15,
|
||||
restitution: 0.7,
|
||||
walls: [{ side: 'bottom' }],
|
||||
springs: [{ a: [0, 0], b: 'bob', k: 'k', length: 'L', damping: 0.4 }]
|
||||
},
|
||||
objects: [
|
||||
// якорь подвеса
|
||||
{ type: 'circle', x: 0, y: 0, r: 0.18, color: '#FFD166', fill: '#FFD166' },
|
||||
// груз — физическое тело, стартует сбоку (выведено из равновесия), след включён
|
||||
{
|
||||
id: 'bob', type: 'circle', r: 0.6, color: '#06D6E0', fill: 'rgba(6,214,224,0.25)',
|
||||
x: '3', y: '-4', trail: true, trailColor: '#9B5DE5',
|
||||
body: { mass: 'm', vx: 0, vy: 0 }
|
||||
},
|
||||
{ type: 'label', latex: true, x: 'bob.x', y: 'bob.y - 1.1', text: 'm', color: '#06D6E0', size: 14 },
|
||||
// живые показания скорости
|
||||
{ type: 'readout', label: 'v_y', unit: 'м/с', precision: 2, color: '#FFD166', expr: 'bob.vy' },
|
||||
{ type: 'readout', label: 'y', unit: 'м', precision: 2, color: '#06D6E0', expr: 'bob.y' }
|
||||
]
|
||||
};
|
||||
|
||||
// Note: масса груза задаётся выражением 'm' (param). При reset тело пересобирается
|
||||
// с актуальной массой/нач.условиями; пружина length='L', k='k' пересчитываются тоже.
|
||||
|
||||
// Упругие шары: 3 тела в коробке из стен, разные начальные скорости, упругие
|
||||
// столкновения друг с другом и со стенами. Гравитация мягкая.
|
||||
var BALLS_DEMO = {
|
||||
id: 'customballs',
|
||||
cat: 'phys',
|
||||
meta: { title: 'Демо: упругие шары', desc: 'Спек-физика (Фаза 2): столкновения круг-круг и круг-стена.' },
|
||||
viewport: { xmin: 0, xmax: 12, ymin: 0, ymax: 9, grid: true, axes: false, bg: '#0D0D1A' },
|
||||
time: { autoplay: true, loop: false, speed: 1 },
|
||||
params: [
|
||||
{ name: 'g', label: 'Гравитация', min: 0, max: 12, step: 0.5, value: 4, unit: 'м/с²' },
|
||||
// NB: имя 'e' зарезервировано (число Эйлера в SimExpr) — используем 'el' для упругости.
|
||||
{ name: 'el', label: 'Упругость', min: 0.5, max: 1, step: 0.02, value: 0.96 }
|
||||
],
|
||||
physics: {
|
||||
enabled: true,
|
||||
gravity: { x: 0, y: '-g' }, // gravity.y — выражение от param g (вычисляется на reset)
|
||||
friction: 0,
|
||||
restitution: 'el', // упругость от param el
|
||||
walls: [{ side: 'bottom' }, { side: 'top' }, { side: 'left' }, { side: 'right' }]
|
||||
},
|
||||
objects: [
|
||||
{ id: 'b1', type: 'circle', r: 0.7, color: '#06D6E0', fill: 'rgba(6,214,224,0.3)',
|
||||
x: 2, y: 4.5, body: { mass: 1, vx: 6, vy: 2.4 }, trail: true, trailColor: '#06D6E0' },
|
||||
{ id: 'b2', type: 'circle', r: 1.0, color: '#9B5DE5', fill: 'rgba(155,93,229,0.3)',
|
||||
x: 8, y: 6, body: { mass: 2, vx: -4, vy: -3 }, trail: true, trailColor: '#9B5DE5' },
|
||||
{ id: 'b3', type: 'circle', r: 0.5, color: '#FFD166', fill: 'rgba(255,209,102,0.3)',
|
||||
x: 6, y: 2, body: { mass: 0.6, vx: 3, vy: 5 }, trail: true, trailColor: '#FFD166' },
|
||||
{ type: 'readout', label: 'b2.vx', precision: 2, color: '#9B5DE5', expr: 'b2.vx' }
|
||||
]
|
||||
};
|
||||
|
||||
var DEMOS = [PROJECTILE_DEMO, PENDULUM_DEMO, BALLS_DEMO];
|
||||
|
||||
function tryRegister() {
|
||||
if (!demoEnabled()) return;
|
||||
if (typeof global.registerSpecSim !== 'function') {
|
||||
if (global.console) console.warn('[sim-demo] registerSpecSim недоступен');
|
||||
return;
|
||||
}
|
||||
global.registerSpecSim(PROJECTILE_DEMO);
|
||||
for (var i = 0; i < DEMOS.length; i++) global.registerSpecSim(DEMOS[i]);
|
||||
|
||||
// Если каталог уже отрисован, добавить карточку демо вручную (минимально-
|
||||
// Если каталог уже отрисован, добавить карточки демо вручную (минимально-
|
||||
// инвазивно: только когда флаг включён; не трогаем SIMS/каталожный рендер).
|
||||
addDemoCardIfNeeded();
|
||||
addDemoCardsIfNeeded();
|
||||
}
|
||||
|
||||
function addDemoCardIfNeeded() {
|
||||
function addDemoCardsIfNeeded() {
|
||||
var grid = document.getElementById('sim-grid');
|
||||
if (!grid) return;
|
||||
if (document.getElementById('sim-card-customdemo')) return;
|
||||
var m = global.LabRegistry && global.LabRegistry.get('customdemo');
|
||||
if (!m) return;
|
||||
var preview = global.LabRegistry.resolvePreview(m);
|
||||
var card = document.createElement('div');
|
||||
card.id = 'sim-card-customdemo';
|
||||
card.className = 'sim-card';
|
||||
card.setAttribute('onclick', "openSim('customdemo')");
|
||||
card.innerHTML = preview +
|
||||
'<div class="sim-body">' +
|
||||
'<span class="sim-cat ' + (m.cat || 'phys') + '">демо</span>' +
|
||||
'<div class="sim-title">' + esc(m.title) + '</div>' +
|
||||
'<div class="sim-desc">' + esc(m.desc || '') + '</div>' +
|
||||
'</div>';
|
||||
grid.appendChild(card);
|
||||
for (var i = 0; i < DEMOS.length; i++) {
|
||||
var id = DEMOS[i].id;
|
||||
if (document.getElementById('sim-card-' + id)) continue;
|
||||
var m = global.LabRegistry && global.LabRegistry.get(id);
|
||||
if (!m) continue;
|
||||
var preview = global.LabRegistry.resolvePreview(m);
|
||||
var card = document.createElement('div');
|
||||
card.id = 'sim-card-' + id;
|
||||
card.className = 'sim-card';
|
||||
card.setAttribute('onclick', "openSim('" + id + "')");
|
||||
card.innerHTML = preview +
|
||||
'<div class="sim-body">' +
|
||||
'<span class="sim-cat ' + (m.cat || 'phys') + '">демо</span>' +
|
||||
'<div class="sim-title">' + esc(m.title) + '</div>' +
|
||||
'<div class="sim-desc">' + esc(m.desc || '') + '</div>' +
|
||||
'</div>';
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
|
||||
+518
-19
@@ -53,11 +53,39 @@
|
||||
color? },
|
||||
// Любой point/circle может стать перетаскиваемой ручкой:
|
||||
{ type:'point', x:'x0', y:'y0',
|
||||
drag:{ param:'x0', axis:'x'|'y'|'xy', min?, max?, paramY? } }
|
||||
]
|
||||
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?} становится ручкой:
|
||||
@@ -93,6 +121,170 @@
|
||||
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) {
|
||||
@@ -113,6 +305,10 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -187,7 +383,11 @@
|
||||
var v = parseFloat(slider.value);
|
||||
self.params[p.name] = v;
|
||||
lblVal.textContent = _fmt(v) + (p.unit ? ' ' + p.unit : '');
|
||||
if (!self._running) self._renderFrame(); // живой предпросмотр на паузе
|
||||
if (!self._running) {
|
||||
// на паузе в начале — пересобрать нач. условия физ-тел (предпросмотр старта)
|
||||
if (self._phys && self._t === 0) self._preparePhysics();
|
||||
self._renderFrame(); // живой предпросмотр на паузе
|
||||
}
|
||||
});
|
||||
|
||||
wrap.appendChild(lblRow);
|
||||
@@ -332,6 +532,21 @@
|
||||
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;
|
||||
@@ -352,6 +567,156 @@
|
||||
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 = {};
|
||||
@@ -363,11 +728,24 @@
|
||||
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) {
|
||||
if (o.hasCenter && !o.body) {
|
||||
var x = o.b.x.ev(env);
|
||||
var y = o.b.y.ev(env);
|
||||
env[o.id + '.x'] = x;
|
||||
@@ -416,11 +794,15 @@
|
||||
};
|
||||
|
||||
/* ════════════════════ Drag-интеракции (мышь + тач) ════════════════════
|
||||
Объекты с prep.drag — перетаскиваемые ручки. Слушаем pointer events на
|
||||
canvas: pointerdown -> хит-тест ближайшей ручки в пикселях; pointermove ->
|
||||
записываем мир-координату курсора в параметр(ы) (clamp по min/max). */
|
||||
Перетаскиваемы: (1) объекты с prep.drag — ручки, пишут мир-коорд. в параметр;
|
||||
(2) физ-тела (prep.body, не fixed) — тащишь напрямую: задаёт позицию тела, при
|
||||
отпускании сообщает скорость (бросок). Слушаем pointer events на canvas. */
|
||||
SimEngineInstance.prototype._hasHandles = function () {
|
||||
for (var i = 0; i < this._objs.length; i++) if (this._objs[i].drag) return true;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -435,16 +817,33 @@
|
||||
}
|
||||
|
||||
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];
|
||||
if (!o.drag || !o.b.x || !o.b.y) continue;
|
||||
var p = self._toPx(o.b.x.ev(env), o.b.y.ev(env));
|
||||
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;
|
||||
if (d <= bestD) { bestD = d; best = o; }
|
||||
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;
|
||||
}
|
||||
@@ -458,6 +857,14 @@
|
||||
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) {
|
||||
@@ -466,13 +873,19 @@
|
||||
ev.preventDefault();
|
||||
self._applyDrag(self._dragging, xy[0], xy[1]);
|
||||
} else {
|
||||
// hover-курсор над ручкой
|
||||
// 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';
|
||||
};
|
||||
@@ -485,9 +898,36 @@
|
||||
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);
|
||||
@@ -555,6 +995,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// пружины (под объектами, поверх трасс)
|
||||
if (this._phys && this._phys.springs.length) this._drawSprings(ctx);
|
||||
|
||||
// объекты
|
||||
this._labelLayer.innerHTML = '';
|
||||
for (var j = 0; j < this._objs.length; j++) {
|
||||
@@ -562,6 +1005,37 @@
|
||||
}
|
||||
};
|
||||
|
||||
/* пружины как зигзаг между концами (наглядно для маятника/осциллятора) */
|
||||
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);
|
||||
@@ -595,7 +1069,11 @@
|
||||
var B = o.b;
|
||||
switch (o.type) {
|
||||
case 'point': {
|
||||
var p = this._toPx(B.x.ev(env), B.y.ev(env));
|
||||
// физ-тело: позиция из интегратора (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();
|
||||
@@ -617,7 +1095,9 @@
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
var c0 = this._toPx(B.x.ev(env), B.y.ev(env));
|
||||
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;
|
||||
@@ -832,15 +1312,28 @@
|
||||
self._last = now;
|
||||
self._t += dt;
|
||||
if (dur > 0 && self._t > dur) {
|
||||
if (loop) { self._t = 0; self._trails = {}; }
|
||||
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; }
|
||||
@@ -851,6 +1344,8 @@
|
||||
this.pause();
|
||||
this._t = 0;
|
||||
this._trails = {};
|
||||
this._dragBody = null;
|
||||
this._preparePhysics(); // пересобрать тела/пружины с нач. условиями из params
|
||||
this._renderFrame();
|
||||
};
|
||||
|
||||
@@ -880,6 +1375,9 @@
|
||||
}
|
||||
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;
|
||||
};
|
||||
@@ -905,6 +1403,7 @@
|
||||
'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) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# Feature Context: Конструктор симуляций (SimForge)
|
||||
|
||||
## Current State
|
||||
- **Фаза 2 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||
- **Физический режим**: блок `physics:{ enabled, gravity:{x,y}, friction?, restitution?, dt?, walls?:[...], springs?:[...] }` + `body:{ mass, vx, vy, fixed }` на point/circle. Фикс-шаговый полу-неявный Эйлер (накопитель dt, кламп шага/скорости), опора на математику `_fx_motion.spring`. Упругие столкновения круг-круг и круг-стена (restitution), пружины (Гук+демпф) между телами/якорями. Drag тел (тащишь — позиция, отпускаешь — бросок со скоростью). Тела сосуществуют с формульными объектами Ф0/Ф1.
|
||||
- **env-поля тел**: `<id>.x/.y/.vx/.vy` берутся из СОСТОЯНИЯ интегратора и кладутся в env первыми — снимает forward-ref проблему однопроходного env для тел.
|
||||
- **Интегратор экспортирован** как `window.SimPhysics` (для билдера/доски/headless). Отдельного файла `_sim_physics.js` НЕТ (нельзя подключить без правки lab.html — зона параллельной сессии); код внутри `_sim_engine.js`.
|
||||
- Демо за флагом: +`customphys` (пружинный маятник), +`customballs` (упругие шары). Гочи: имя param `e` зарезервировано (число Эйлера) — в демо «шары» упругость названа `el`.
|
||||
- Верификация: `node --check` обоих файлов OK; eval/Function — только в комментарии; эмодзи нет (скан кодпойнтов); headless (vm+DOM/canvas-стаб) 28/28: падение под гравитацией (парабола, без NaN), упругие шары (скорости меняются, тела в коробке, ограничены), пружинный маятник (колебания, без взрыва), drag тела (позиция+бросок), смешанная сцена (формульный point + segment на ball.x/y + readout ball.y живут вместе), `SimPhysics.step` raw.
|
||||
- **Фаза 1 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только `_sim_engine.js` + `_sim_demo.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии).
|
||||
- Новые типы объектов спеки: **plot** (график `f(var)` на canvas движка, `trace` — след по `t`), **readout** (живой бейдж, мягкая ошибка через `evalSafe`), **vector** с формой `origin+dx/dy`. **drag** на point/circle (`drag:{param,axis,min,max,paramY}`) — pointer events (мышь+тач), хит-тест в px (16px), двойной clamp (drag.min/max + диапазон параметра). Точные поля — в шапке `_sim_engine.js` и handoff phase-1.
|
||||
- Демо `customdemo` расширено: +слайдеры x0/y0, draggable-старт (axis xy), plot траектории, 2 readout (R, H). По-прежнему за флагом.
|
||||
@@ -47,8 +53,9 @@
|
||||
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
|
||||
|
||||
## RESUME STATE
|
||||
- Последний коммит фичи: — (Ф0 + Ф1 реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 1 — Plots & interactions (✅ Implemented, pending commit) → дальше Phase 2 — Physics
|
||||
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 2 — Physics (✅ Implemented, pending commit) → дальше Phase 3 — Persistence + API
|
||||
- Режим: Automated / Orchestrator / Incremental
|
||||
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector — в шапке `_sim_engine.js` и в handoff phase-0/phase-1.
|
||||
- Файлы Ф1 (несведённые с параллельной сессией): `frontend/js/labs/_sim_engine.js`, `frontend/js/labs/_sim_demo.js`.
|
||||
- Новые публичные API для следующих фаз: `window.SimExpr`, `window.SimEngine.mount`, `window.SimPhysics` (step/integrate/resolveCollisions), `window.registerSpecSim` / `window.SimAdapter`. Формат спеки v1 + типы plot/readout/drag/vector + блок `physics`/`body`/`springs`/`walls` — в шапке `_sim_engine.js` и в handoff phase-0/1/2.
|
||||
- Файлы Ф2 (несведённые с параллельной сессией): `frontend/js/labs/_sim_engine.js`, `frontend/js/labs/_sim_demo.js`.
|
||||
- Для Ф3 сериализовать/валидировать: блок `physics` (gravity x/y, friction, restitution, dt, walls[], springs[{a,b,k,length,damping}]) и `body{mass,vx,vy,fixed}` на объектах; строки-выражения санитизировать как x/y/expr.
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
- [x] Phase 0: Спека v1 + рантайм (формульные сцены) [domain: frontend] → [subplan](./phase-0-runtime-core.md)
|
||||
- [x] Phase 1: Графики + интеракции [domain: frontend] → [subplan](./phase-1-plots-interactions.md)
|
||||
- [ ] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
|
||||
- [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md)
|
||||
- [ ] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
|
||||
- [ ] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
|
||||
- [ ] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
|
||||
@@ -54,7 +54,7 @@
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 0: Runtime core | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 1: Plots & interactions | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 2: Physics | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 3: Persistence + API | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Builder UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 5: Catalog | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 2: Физический интегратор
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Implemented (не закоммичено — коммит за оркестратором)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
После фазы маятник/столкновения/брошенное тело идут динамически из спеки.
|
||||
|
||||
## Tasks
|
||||
- [ ] Блок `physics` в спеке: `{ enabled, gravity:{x,y}, friction, walls:[...], restitution }`.
|
||||
- [ ] Тело-объект: `body:{ mass, vx, vy, fixed }` — интегрируется (опора на `_fx_motion.js`, посмотреть API; не дублировать интегратор).
|
||||
- [ ] Пружины: `springs:[{ a, b, k, length }]` (между телами или телом и точкой-якорем).
|
||||
- [ ] Столкновения: упругие шары/стены (restitution), базовый бродфейз достаточно (N небольшое).
|
||||
- [ ] Drag тела: перетаскивание задаёт позицию/скорость (отпустил — летит). Кинематические (формульные) объекты Ф0 сосуществуют с физическими.
|
||||
- [ ] Траектория: накопление следа центра тела (toggle в спеке).
|
||||
- [ ] Демо-спеки: «маятник» (груз+нить как пружина/констрейнт), «упругие шары».
|
||||
- [x] Блок `physics` в спеке: `{ enabled, gravity:{x,y}, friction, walls:[...], restitution, dt?, springs? }`. gravity/friction/restitution — числа ИЛИ выражения от params (вычисляются на reset).
|
||||
- [x] Тело-объект: `body:{ mass, vx, vy, fixed }` на point/circle — интегрируется фикс-шагом (накопитель dt). Нач. позиция/vx/vy/масса — числа или выражения от params (вычисляются при reset/init, далее интегрируются). Опора на математику `_fx_motion` (полу-неявный Эйлер); см. ниже.
|
||||
- [x] Пружины: `springs:[{ a, b, k, length, damping? }]` — концы: id тела ИЛИ якорь-точка `[x,y]`. Сила Гука + демпфирование вдоль оси. Рисуются зигзагом.
|
||||
- [x] Столкновения: упругие круг-круг (по нормали, импульс + позиционная коррекция по обратным массам) и круг-стена (restitution). Broadphase O(n^2) (N мало).
|
||||
- [x] Drag тела: тащишь — задаёт позицию (тело «приколото», не интегрируется); отпустил — сообщает скорость (бросок, кламп 40 м/с). Формульные объекты Ф0/Ф1 сосуществуют (drag-ручки и физ-тела в одной сцене).
|
||||
- [x] Траектория: тело с `trail:true` пишет след центра (переиспользован существующий механизм trail; позиция берётся из env-полей тела).
|
||||
- [x] Демо-спеки за флагом: «пружинный маятник» (`customphys`: тело+пружина+гравитация+drag) и «упругие шары» (`customballs`: 3 тела + 4 стены, g/упругость от слайдеров).
|
||||
|
||||
## Files to Modify/Create
|
||||
- `frontend/js/labs/_sim_engine.js` — физический режим, интеграция с _fx_motion (modify)
|
||||
@@ -33,10 +33,72 @@
|
||||
- Не переусложнять коллизии — школьный уровень (круги/стены).
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Все задачи выполнены
|
||||
- [ ] Использует _fx_motion, без своего дубля интегратора без причины
|
||||
- [ ] Стабильность (нет взрыва энергии на разумных параметрах)
|
||||
- [ ] Нет регрессий Ф0/Ф1
|
||||
- [x] Все задачи выполнены
|
||||
- [x] Опирается на математику `_fx_motion.spring` (полу-неявный/симплектический Эйлер `v+=a·dt; x+=v·dt`) — обобщена на связанные тела в `SimPhysics`; API tween/spring-фабрики `_fx_motion` (rAF-замыкания на одно значение) не подходит для N связанных тел, поэтому тонкий модуль поверх той же математики, без дубля иного интегратора.
|
||||
- [x] Стабильность (нет взрыва энергии): фикс-шаг dt (кламп 1/2000..1/30), накопитель + кап подшагов (8), кламп скорости (1e4), вязкое трение через `exp(-friction·dt)`. Headless-прогон: падение/маятник/шары — конечные, без NaN/∞.
|
||||
- [x] Нет регрессий Ф0/Ф1: формульные point/segment/circle/rect/polyline/path/vector/label/plot/readout/drag работают; тела и формульные объекты в одной сцене (тест mixed).
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Заполняет реализатор -->
|
||||
|
||||
### Что реализовано (Phase 2) — только `_sim_engine.js` + `_sim_demo.js`
|
||||
Файл `_sim_physics.js` НЕ создавался: его нельзя подключить без правки `frontend/lab.html`
|
||||
(зона параллельной сессии). Интегратор живёт внутри `_sim_engine.js` и экспортируется
|
||||
как `window.SimPhysics` (переиспользуемо headless/билдером/доской). Если Ф4+ захочет
|
||||
вынести в отдельный файл — добавить `<script>` в lab.html и `module.exports`-ветку.
|
||||
|
||||
- **`window.SimPhysics`** — `{ step(state, dtFrame), integrate(...), resolveCollisions(...) }`.
|
||||
Полу-неявный Эйлер (математика `_fx_motion.spring`), фикс-шаг, пружины (Гук+демпф),
|
||||
упругие столкновения круг-круг и круг-стена. Без DOM/eval — чистая функция над state.
|
||||
- **Engine**: `_preparePhysics()` (сборка тел/пружин/стен из спеки на reset),
|
||||
`_stepPhysics(dt)` (вызывается в rAF до `_renderFrame`; удерживаемое тело временно
|
||||
fixed), drag тел (`_dragBody`), `_drawSprings()`, рендер point/circle-тел из состояния
|
||||
интегратора, body-поля в `_buildEnv`.
|
||||
|
||||
### Формат (точные поля)
|
||||
```jsonc
|
||||
// глобальный блок физики
|
||||
physics: {
|
||||
enabled: true, // false/отсутствие -> чистая кинематика (Ф0/Ф1)
|
||||
gravity: { x:0, y:-9.8 }, // число ИЛИ выражение от params (вычисляется на reset)
|
||||
friction?: 0, // вязкое затухание (1/с), exp(-friction·dt); число/выражение
|
||||
restitution?: 1, // упругость 0..1 (кламп); число/выражение
|
||||
dt?: 1/240, // фикс-шаг (кламп 1/2000..1/30)
|
||||
walls?: [
|
||||
{ side:'bottom'|'top'|'left'|'right' }, // из границ viewport
|
||||
{ x1,y1, x2,y2 } // произвольный отрезок (нормаль к центру)
|
||||
],
|
||||
springs?: [
|
||||
{ a:'bodyId'|[x,y], b:'bodyId'|[x,y], // конец: id тела ИЛИ якорь-точка
|
||||
k:40, length:5, damping?:0.4 } // k/length/damping — число/выражение
|
||||
]
|
||||
}
|
||||
// тело на point/circle (интегрируется, НЕ формула)
|
||||
{ id:'bob', type:'circle', r:0.6, x:'3', y:'-4', // нач. позиция — число/выражение
|
||||
trail?:true, trailColor?:'#...', // след тела (механизм Ф0)
|
||||
body:{ mass?:1, vx?:0, vy?:0, fixed?:false } } // масса/vx/vy — число/выражение
|
||||
```
|
||||
|
||||
### env-поля тел (доступны readout/plot/label/привязкам)
|
||||
Для каждого тела в env кладутся `<id>.x`, `<id>.y`, `<id>.vx`, `<id>.vy` ИЗ СОСТОЯНИЯ
|
||||
ИНТЕГРАТОРА (не из выражения). Кладутся ПЕРВЫМИ в `_buildEnv` — снимает forward-ref
|
||||
проблему однопроходного env: формульные объекты, ссылающиеся на тело, видят его
|
||||
актуальную позицию/скорость в этом же кадре.
|
||||
|
||||
### Гочи / решения / риски
|
||||
- **Имя param `e` зарезервировано** (число Эйлера в SimExpr). Не использовать `e` как
|
||||
имя параметра в выражениях физики — взять `el`/`elast` и т.п. (демо «шары» учтено).
|
||||
- **Радиус тела для коллизий**: circle — мировой `r`; point — экранные px → переводятся
|
||||
в мир через текущий масштаб (фолбэк 0.3) при `_preparePhysics`. Зависит от `_scale`,
|
||||
поэтому физика собирается ПОСЛЕ `_fit()` (в reset, который зовётся после первого fit).
|
||||
- **Изменение params на лету**: gravity/k/length/restitution/масса/нач.условия пересчит. на
|
||||
**reset** (и на паузе в `t==0` — для предпросмотра старта). Во время проигрывания слайдеры
|
||||
меняют только сами params в env (для readout/формул), но не телепортируют тела/не меняют силы
|
||||
до следующего reset. Это намеренно (стабильность). Если Ф4 захочет live-силы — пересобирать
|
||||
`opts`/springs каждый кадр (тела не трогать).
|
||||
- **Drag во время play**: удерживаемое тело временно `fixed`; на отпускании — скорость из
|
||||
сглаженной оценки движения курсора (кламп 40 м/с).
|
||||
- **Сериализация для Ф3 (БД/API)**: в спеке надо хранить/валидировать блок `physics`
|
||||
(gravity x/y, friction, restitution, dt, walls[], springs[]) и `body{}` на объектах
|
||||
(mass, vx, vy, fixed — числа или строки-выражения). Глубину/число springs/walls и
|
||||
диапазоны (k, mass>0, restitution 0..1, dt) — проверять на сервере как и остальные
|
||||
выражения. Строки-выражения санитизировать так же, как x/y/expr Ф0/Ф1.
|
||||
|
||||
Reference in New Issue
Block a user