feat(labs): универсальные инструменты для физических симуляций (Раунд 2)

ФУНДАМЕНТ — frontend/js/labs/_phys_visuals.js (новый файл, 871 строк):
- LSPhysFX.FORCE_COLORS: стандарт 10 цветов (mg красный, N циан, F_тр оранжевый,
  T фиолетовый, F_упр зелёный, F_сопр серый, F_прил жёлтый, импульс, v, a)
- drawVector / drawForceArrow / drawSpring / drawRope / drawSurface
- drawCoordSystem / drawScale / drawClock / drawAngleArc / drawPivot
- drawEnergyBars (KE/PE/W_тр/E_упр стеком с авто-масштабированием)
- LSTimeControl class (pause/play/0.1×-5×/scrubber для одного DOF)
- LSMotionTrail class (gradient line с alpha fade)
- LSBuildTimeControlUI helper для DOM-UI бара

ГРАФИКИ — frontend/js/labs/_graph_panel.js (новый файл, 415 строк):
- LSGraphPanel: 3 stacked time-series плота, авто-scale, freeze/export PNG
- LSGraphPanelUI: overlay-виджет 320×248 в углу canvas, body-selector,
  кнопки Сброс/Стоп/PNG download

FBD (свободные силовые диаграммы) интегрированы в:
- projectile.js: mg + drag + wind + elastic (bounce)
- pendulum.js: mg + T (math), mg + F_упр (spring vert/horiz)
- collision.js: стрелки скорости каждого шара + flash импульса
- newton.js: все сцены законов I-III + новые Атвуд/наклон/скатывание
- forcesandbox.js: gravity/N/friction/spring/applied на каждом теле

ENERGY BARS интегрированы в 5 сим с расчётами:
- projectile: ΔE_drag = F_d·v·dt (cumulative)
- pendulum: для math/spring/double/physical с учётом γ-затухания
- collision: KE loss при каждом столкновении
- newton: все 8 сцен включая Атвуд + наклон + скатывание (с моментом инерции)
- forcesandbox: + E_упр от пружин

GRAPHS PANEL — в 5 сим:
- pendulum: θ/ω/E (режим-aware)
- collision: |v₁|, |v₂|, v_цм
- newton: x/v/a (зависит от закона)
- forcesandbox: x/|v|/|a| выбранного тела
- hydrostatics: depth/vy/submergedFrac (только Архимед)

TIME CONTROL + MOTION TRAILS в 5 сим:
- pendulum (полный scrubber для math), collision, newton, forcesandbox (speed+pause)
- projectile (layered speed+pause, свой trail сохранён)
- LSMotionTrail на bob/балах/блоках с alpha gradient

Заменено рисование пружин на LSPhysFX.drawSpring везде.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-26 14:37:48 +03:00
parent e46548d06b
commit 7a323f8fe0
10 changed files with 2536 additions and 39 deletions
+263 -2
View File
@@ -59,6 +59,24 @@ class NewtonSim {
this.onUpdate = null;
this.onModeChange = null;
/* FBD toggle */
this._fbdOn = false;
/* ── Energy bars widget ── */
this._energyOn = false;
this._frictionWork = 0; // cumulative friction heat (J)
this._appliedWork = 0; // work done by applied force (J)
this._energyScale = 0;
/* -- GraphPanel widget -- */
this._graphsOn = false;
this._graphUI = null;
this._tSim = 0;
/* ── TimeControl ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
this._tcScale = 1; // separate from tc.scale so we can multiply with existing dt
this.fit();
this._bindEvents();
}
@@ -248,9 +266,17 @@ class NewtonSim {
/* ── Тик ──────────────────────────────────────────────────── */
_tick(now) {
const dt = Math.min((now - this._last) / 1000, 0.05);
const rawDt = Math.min((now - this._last) / 1000, 0.05);
this._last = now;
if (window.LabFX) LabFX.particles.update(dt);
if (window.LabFX) LabFX.particles.update(rawDt);
/* TimeControl: pause / speed scale */
let dt = rawDt;
if (this._tc) {
dt = this._tc.advance(rawDt);
if (dt === 0) { this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; }
}
if (!this._paused) {
if (this.law === 1 && this.scene === 'A') this._step1A(dt);
else if (this.law === 1) this._step1B(dt);
@@ -262,8 +288,12 @@ class NewtonSim {
else if (this.scene === 'B') this._step3B(dt);
else this._step3C(dt);
}
this._tSim += dt;
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
if (window.LSGraphPanel && this._graphsOn && this._graphUI && !this._paused) {
this._graphUI.push(this._tSim, this._newtonGraphValues());
}
}
/* ── Физика I-A : блок с трением ────────────────────────── */
@@ -553,10 +583,79 @@ class NewtonSim {
else if (this.scene === 'B') this._drawL3B(ctx);
else this._drawL3C(ctx);
/* ── Energy bars overlay ── */
if (this._energyOn && window.LSPhysFX) {
this._drawEnergyBarsNwt(ctx);
}
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Energy bars: newton ── */
_drawEnergyBarsNwt(ctx) {
var en = this._calcEnergiesNwt();
if (!en) return;
var tot = en.ke + en.pe + en.friction;
if (tot > this._energyScale) this._energyScale = tot;
var PW = 188, MARGIN = 12;
LSPhysFX.drawEnergyBars(ctx, this.W - PW - MARGIN, MARGIN, PW, 0,
{ ke: en.ke, pe: en.pe, friction: en.friction, total: this._energyScale }, {});
}
_calcEnergiesNwt() {
var ke = 0, pe = 0, fr = this._frictionWork;
var S = NewtonSim.SCALE, G = NewtonSim.G;
/* law 1A — sliding block */
if (this.law === 1 && this.scene === 'A') {
var b = this._1A;
var spd = Math.hypot(b.bvx || 0, b.bvy || 0) / S;
ke = 0.5 * this.mass1 * spd * spd;
} else if (this.law === 1) {
/* law 1B — projectile */
var b2 = this._1B;
if (b2) {
var spd2 = Math.hypot(b2.vx || 0, b2.vy || 0) / S;
var h2 = Math.max(0, (this.H * 0.6 - (b2.y || 0))) / S;
ke = 0.5 * this.mass1 * spd2 * spd2;
pe = this.mass1 * G * h2;
}
} else if (this.law === 4 && this.scene === 'atwood') {
var atw = this._atw;
var v = Math.abs(atw.vy || 0) / S;
ke = 0.5 * (this.atwM1 + this.atwM2) * v * v;
/* PE from starting positions */
var dy1 = ((atw.y1 || 0) - (this.H * 0.3)) / S;
var dy2 = ((atw.y2 || 0) - (this.H * 0.3)) / S;
pe = Math.max(0, this.atwM1 * G * (-dy1) + this.atwM2 * G * (-dy2));
} else if (this.law === 4 && this.scene === 'ramp') {
var rmp = this._ramp;
ke = 0.5 * this.mass1 * (rmp.bv || 0) * (rmp.bv || 0);
var h = (rmp.bx || 0) * Math.sin(rmp.alpha || 0);
pe = Math.max(0, this.mass1 * G * h);
} else if (this.law === 4 && this.scene === 'roll') {
/* rolling: use ball as representative */
var roll = this._roll;
var vb = roll.vBall || 0;
/* KE includes rotational: for solid ball k=2/5, cyl=1/2, hoop=1 */
ke = 0.5 * this.mass1 * vb * vb * (1 + 0.4) / 1; /* solid ball */
var hRoll = Math.max(0, (roll.L || 3) * Math.sin(roll.alpha || 0) - (roll.sBall || 0) * Math.sin(roll.alpha || 0));
pe = this.mass1 * G * hRoll;
} else if (this.law === 2) {
var b2s = this._2;
var spd3 = Math.abs(b2s.v || 0);
ke = 0.5 * this.mass1 * spd3 * spd3;
} else if (this.law === 3) {
var b3 = this.scene === 'A' ? this._3A : (this.scene === 'B' ? this._3B : this._3C);
if (b3) {
var v3a = Math.abs(b3.v1 || 0), v3b = Math.abs(b3.v2 || 0);
ke = 0.5 * this.mass1 * v3a * v3a + 0.5 * this.mass2 * v3b * v3b;
}
}
return { ke: Math.max(0, ke), pe: Math.max(0, pe), friction: Math.max(0, fr) };
}
/* ── Закон I — Сцена A ───────────────────────────────────── */
_drawL1A(ctx) {
@@ -624,6 +723,16 @@ class NewtonSim {
ctx.font = '12px monospace'; ctx.fillStyle = 'rgba(185,210,255,0.75)';
ctx.fillText(`μ = ${this.mu.toFixed(2)} F тр = μ · m · g`, 18, 26);
/* FBD: mg, N for Law I scene A */
if (this._fbdOn && window.LSPhysFX) {
const by2 = b.by - b.BH / 2;
const mg2 = this.mass1 * NewtonSim.G;
LSPhysFX.drawForceArrow(ctx, b.bx, by2, 0, 50, 'gravity', 'mg=' + mg2.toFixed(0) + 'Н');
if (!b.inAir) {
LSPhysFX.drawForceArrow(ctx, b.bx, by2, 0, -50, 'normal', 'N=' + mg2.toFixed(0) + 'Н');
}
}
this._caption(ctx, 'Тело продолжает движение\nпока не подействует сила', W, H);
}
@@ -809,6 +918,21 @@ class NewtonSim {
}
}
/* FBD: F_applied, mg, N, F_friction for Law II scene A */
if (this._fbdOn && window.LSPhysFX && this.scene === 'A') {
const { b1x: bx2 } = this._2;
const BH2 = 48;
const by2 = g.gY - BH2 / 2;
const mg2 = this.mass1 * NewtonSim.G;
const fFr2 = this.mu ? this.mu * mg2 : 0;
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, 44, 'gravity', 'mg=' + mg2.toFixed(0) + 'Н');
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -44, 'normal', 'N=' + mg2.toFixed(0) + 'Н');
LSPhysFX.drawForceArrow(ctx, bx2, by2, Math.min(55, this.force * 1.5), 0, 'applied', 'F=' + this.force + 'Н');
if (fFr2 > 0.5) {
LSPhysFX.drawForceArrow(ctx, bx2, by2, -Math.min(40, fFr2 * 1.5), 0, 'friction', 'Fтр=' + fFr2.toFixed(0) + 'Н');
}
}
this._caption(ctx, 'F = m · a', W, H);
}
@@ -920,6 +1044,19 @@ class NewtonSim {
ctx.textAlign = 'left';
}
/* FBD: action/reaction via LSPhysFX for Law III scene A */
if (this._fbdOn && window.LSPhysFX && s.fired && s.ball) {
const ny3 = g.gY - CW - 50;
const S3 = NewtonSim.SCALE;
const fMag = Math.min(70, this.mass1 * 6);
/* force on ball → right */
LSPhysFX.drawForceArrow(ctx, s.cx + CW / 2 + 12, ny3,
fMag, 0, 'applied', 'F→ядро');
/* reaction on cannon → left */
LSPhysFX.drawForceArrow(ctx, s.cx - CW / 2 - 12, ny3,
-fMag, 0, 'impulse', 'F→пушка');
}
this._caption(ctx, 'Действие = Противодействие\nF₁ = −F₂', W, H);
}
@@ -1165,6 +1302,33 @@ class NewtonSim {
ctx.restore();
}
_newtonGraphValues() {
const S = NewtonSim.SCALE;
if (this.law === 1 && this.scene === 'A') {
const b = this._1A;
const x = b.bx ? b.bx / S : 0;
const v = Math.hypot(b.bvx || 0, b.bvy || 0) / S;
const a = this._paused ? 0 : (this.mu * NewtonSim.G);
return [x, v, -a];
}
if (this.law === 2) {
const b = this._2;
const x = (b.b1x || 0) / S;
const v = (b.b1vx || 0) / S;
const a = b.running ? (this.force / this.mass1) : 0;
return [x, v, a];
}
if (this.law === 4 && this.scene === 'atwood' && this._atw) {
const atw = this._atw;
const x = atw.y1 ? atw.y1 / S : 0;
const v = (atw.vy || 0) / S;
const a = atw.aPhys || 0;
return [x, v, a];
}
// default: use 1A block or zero
return [0, 0, 0];
}
_fma(ctx, F, m, a, cx, y) {
ctx.save();
ctx.font = 'bold 15px monospace';
@@ -1946,6 +2110,35 @@ function _nwt_lighten(hex, d) {
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
}
/* ─── GraphPanel helpers ─────────────────────────── */
function newtonToggleGraphs() {
const sim = typeof newtonSim !== 'undefined' ? newtonSim : null;
if (!sim || !window.LSGraphPanelUI) return;
sim._graphsOn = !sim._graphsOn;
if (sim._graphsOn) {
sim._tSim = 0;
const canvasOuter = document.querySelector('#sim-dynamics .proj-canvas-outer');
if (!canvasOuter) return;
const S = NewtonSim.SCALE;
sim._graphUI = new GraphPanelUI(canvasOuter, {
maxPoints: 400,
traces: ['x', 'v', 'a'],
labels: ['x', 'v', 'a'],
units: ['м', 'м/с', 'м/с²'],
colors: ['#06D6E0', '#FFD166', '#EF476F'],
toggleBtnId: 'btn-newton-graphs',
title: 'Графики x/v/a'
});
sim._graphUI.isOn = true;
sim._graphUI._build();
} else {
if (sim._graphUI) { sim._graphUI._destroy(); sim._graphUI = null; }
}
const btn = document.getElementById('btn-newton-graphs');
if (btn) btn.classList.toggle('active', sim._graphsOn);
}
/* ─── lab UI init ─────────────────────────────────── */
var newtonSim = null;
var sandboxSim = null;
@@ -1967,6 +2160,7 @@ function _nwt_lighten(hex, d) {
if (!newtonSim) {
newtonSim = new NewtonSim(nwCanvas);
newtonSim.onUpdate = _newtonUpdateUI;
_dynInjectTCBar();
}
// activate current mode
dynMode(_dynMode);
@@ -1974,6 +2168,58 @@ function _nwt_lighten(hex, d) {
}));
}
function _dynInjectTCBar() {
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
var wrap = document.getElementById('sim-dynamics');
if (!wrap || wrap.querySelector('.tc-bar')) return;
/* proxy TC that routes to whichever sim is active */
var proxyTC = {
paused: false,
scale: 1,
advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
setScale: function(s) {
this.scale = +s;
if (newtonSim && newtonSim._tc) newtonSim._tc.setScale(+s);
if (sandboxSim && sandboxSim._tc) sandboxSim._tc.setScale(+s);
},
togglePause: function() {
this.paused = !this.paused;
if (newtonSim && newtonSim._tc) newtonSim._tc.paused = this.paused;
if (sandboxSim && sandboxSim._tc) sandboxSim._tc.paused = this.paused;
return this.paused;
},
};
var simProxy = { draw: function() {
if (_dynMode === 'sandbox' && sandboxSim) sandboxSim.draw();
else if (newtonSim) newtonSim.draw();
}};
var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
/* Trails toggle (sandbox only — newton doesn't have generic bodies) */
var sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
var trailBtn = document.createElement('button');
trailBtn.className = 'zoom-btn';
trailBtn.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:3px 8px;border:1px solid rgba(255,255,255,0.15);border-radius:7px;background:rgba(28,18,48,0.8);color:#ccc;font-size:.68rem;font-weight:700;cursor:pointer;white-space:nowrap;flex-shrink:0;font-family:Manrope,sans-serif';
trailBtn.innerHTML = '<svg class="ic" viewBox="0 0 24 24" width="13" height="13"><path d="M3 12 Q6 3 12 12 Q18 21 21 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> Следы';
trailBtn.title = 'Следы движения (Sandbox)';
trailBtn.addEventListener('click', function() {
var on = sandboxSim ? !sandboxSim.showTrail : false;
if (sandboxSim) sandboxSim.showTrail = on;
trailBtn.style.background = on ? 'rgba(6,214,224,0.2)' : 'rgba(28,18,48,0.8)';
trailBtn.style.color = on ? '#06D6E0' : '#ccc';
trailBtn.style.borderColor = on ? 'rgba(6,214,224,0.5)' : 'rgba(255,255,255,0.15)';
});
tcBar.appendChild(sep);
tcBar.appendChild(trailBtn);
var statsBar = wrap.querySelector('.proj-stats-bar');
if (statsBar) wrap.insertBefore(tcBar, statsBar);
else wrap.appendChild(tcBar);
}
function dynMode(mode, btn) {
_dynMode = mode;
const isSandbox = mode === 'sandbox';
@@ -2579,5 +2825,20 @@ function _nwt_lighten(hex, d) {
document.getElementById('dbar-v5').textContent = info.time + ' с';
}
/* ── Energy toggle: newton ── */
function dynToggleEnergy() {
if (nSim) {
nSim._energyOn = !nSim._energyOn;
const on = nSim._energyOn;
const btn = document.getElementById('nwt-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) { nSim._frictionWork = 0; nSim._energyScale = 0; }
nSim.draw();
}
}
/* ── chem sandbox ── */