diff --git a/CLAUDE.md b/CLAUDE.md index d3ebfa7..80c4f4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-поля тел**: `.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 и ничего не сдвинется). diff --git a/frontend/js/labs/_sim_demo.js b/frontend/js/labs/_sim_demo.js index aa2e212..9538499 100644 --- a/frontend/js/labs/_sim_demo.js +++ b/frontend/js/labs/_sim_demo.js @@ -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 + - '
' + - 'демо' + - '
' + esc(m.title) + '
' + - '
' + esc(m.desc || '') + '
' + - '
'; - 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 + + '
' + + 'демо' + + '
' + esc(m.title) + '
' + + '
' + esc(m.desc || '') + '
' + + '
'; + grid.appendChild(card); + } } function esc(s) { diff --git a/frontend/js/labs/_sim_engine.js b/frontend/js/labs/_sim_engine.js index b1c4406..08f3a26 100644 --- a/frontend/js/labs/_sim_engine.js +++ b/frontend/js/labs/_sim_engine.js @@ -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 (мир-размер вьюпорта), а также .x / .y для объектов, у которых заданы числовые/выраж. x,y. + Для физических тел (body) в env кладутся .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) { diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index 7303fb5..bc49793 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -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-поля тел**: `.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. diff --git a/plans/sim-builder/PLAN.md b/plans/sim-builder/PLAN.md index bc7f9e3..064941a 100644 --- a/plans/sim-builder/PLAN.md +++ b/plans/sim-builder/PLAN.md @@ -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 | ⬜ | ⬜ | ⬜ | diff --git a/plans/sim-builder/phase-2-physics.md b/plans/sim-builder/phase-2-physics.md index 7d65988..8055ad5 100644 --- a/plans/sim-builder/phase-2-physics.md +++ b/plans/sim-builder/phase-2-physics.md @@ -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+ захочет +вынести в отдельный файл — добавить `