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
+199 -7
View File
@@ -58,6 +58,23 @@ class CollisionSim {
/* hover inspector */
this._hoverBall = null;
/* FBD toggle */
this._fbdOn = false;
/* ── Energy bars widget ── */
this._energyOn = false;
this._frictionWork = 0; // cumulative KE lost in inelastic collisions
this._energyScale = 0;
/* -- GraphPanel widget -- */
this._graphsOn = false;
this._graphUI = null;
/* ── TimeControl + MotionTrails ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
this._tcTrailColors = ['#06D6E0', '#F15BB5'];
this.showTCTrails = false;
canvas.addEventListener('click', () => { if (this.onPlayPause) this.onPlayPause(); });
canvas.addEventListener('mousemove', e => this._onMouseMove(e));
canvas.addEventListener('mouseleave', () => { this._hoverBall = null; this.draw(); });
@@ -148,16 +165,20 @@ class CollisionSim {
this._b = [
{ id:1, m:this.m1, r:r1, x:cx - gap/2, y:cy,
vx:this.v1, vy:0, angle: 0, angVel: 0,
color:'#9B5DE5', rgb:'155,93,229', trail:[] },
color:'#9B5DE5', rgb:'155,93,229', trail:[],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#9B5DE5', width:2.5, maxLen:120 }) : null },
{ id:2, m:this.m2, r:r2, x:cx + gap/2, y:cy + dy2,
vx:-this.v2 * Math.cos(rad), vy:-this.v2 * Math.sin(rad), angle: 0, angVel: 0,
color:'#06D6E0', rgb:'6,214,224', trail:[] },
color:'#06D6E0', rgb:'6,214,224', trail:[],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color:'#06D6E0', width:2.5, maxLen:120 }) : null },
];
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
this._cooldown = 0;
this._colCount = 0;
this._snapBefore = null;
this._snapAfter = null;
this._frictionWork = 0;
this._energyScale = 0;
}
/* ═══ launch visual burst ═══ */
@@ -190,11 +211,29 @@ class CollisionSim {
if (this._lastTs === null) this._lastTs = ts;
const rawDt = Math.min((ts - this._lastTs) / 1000, 0.05);
this._lastTs = ts;
const dt = rawDt * this.speed;
if (window.LabFX) LabFX.particles.update(rawDt);
let dt;
if (this._tc) {
dt = this._tc.advance(rawDt * this.speed);
if (dt === 0) { this.draw(); this._emit(); if (this.playing) this._tick(); return; }
} else {
dt = rawDt * this.speed;
}
this._tSim = (this._tSim || 0) + dt;
/* tick ball motion trails */
if (this.showTCTrails) {
for (const b of this._b) { if (b._trail2) b._trail2.tick(); }
}
this._step(dt);
this.draw();
this._emit();
if (window.LSGraphPanel && this._graphsOn && this._graphUI) {
const [b1, b2] = this._b;
this._graphUI.push(this._tSim || 0, [b1 ? Math.hypot(b1.vx, b1.vy) : 0, b2 ? Math.hypot(b2.vx, b2.vy) : 0, 0]);
}
if (this.playing) this._tick();
});
}
@@ -208,6 +247,7 @@ class CollisionSim {
for (const b of this._b) {
b.trail.push({ x: b.x, y: b.y, spd: Math.hypot(b.vx, b.vy) });
if (b.trail.length > 90) b.trail.shift();
if (this.showTCTrails && b._trail2) b._trail2.push(b.x, b.y);
}
/* centre-of-mass trail */
@@ -299,6 +339,12 @@ class CollisionSim {
}
this._snapAfter = this._snapshot();
/* track inelastic energy loss for energy bars */
if (this._energyOn && this._snapBefore && this._snapAfter) {
const sumKE = arr => arr.reduce((s, b) => s + b.ke, 0);
const dKE = sumKE(this._snapBefore) - sumKE(this._snapAfter);
if (dKE > 0) this._frictionWork += dKE;
}
this._colCount++;
this._cooldown = 8;
@@ -427,6 +473,28 @@ class CollisionSim {
_emit() { if (this.onUpdate) this.onUpdate(this.stats()); }
toggleGraphs(canvasOuter) {
if (!window.LSGraphPanelUI) return false;
this._graphsOn = !this._graphsOn;
if (this._graphsOn) {
this._tSim = 0;
this._graphUI = new GraphPanelUI(canvasOuter, {
maxPoints: 400,
traces: ['v1', 'v2', 'cm'],
labels: ['|v1|', '|v2|', 'v_цм'],
units: ['м/с', 'м/с', 'м/с'],
colors: ['#9B5DE5', '#06D6E0', '#FFD166'],
toggleBtnId: 'btn-coll-graphs',
title: 'Скорости'
});
this._graphUI.isOn = true;
this._graphUI._build();
} else {
if (this._graphUI) { this._graphUI._destroy(); this._graphUI = null; }
}
return this._graphsOn;
}
/* ═══ RENDER ═══ */
draw() {
@@ -564,6 +632,11 @@ class CollisionSim {
}
}
/* ── 7a. MotionTrail overlay ── */
if (this.showTCTrails) {
for (const b of this._b) { if (b._trail2) b._trail2.draw(ctx); }
}
/* ── 7b. Centre-of-mass trail ── */
for (let i = 1; i < this._cmTrail.length; i++) {
const frac = i / this._cmTrail.length;
@@ -928,10 +1001,52 @@ class CollisionSim {
this._drawBallTooltip(ctx, this._hoverBall, W, H);
}
/* ── 18. FBD velocity / impulse overlays ── */
if (this._fbdOn && window.LSPhysFX) {
for (const b of this._b) {
const spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
if (spd > 0.5) {
const vLen = Math.min(60, spd * 3);
LSPhysFX.drawForceArrow(ctx, b.x, b.y - b.r - 8,
(b.vx / spd) * vLen, (b.vy / spd) * vLen,
'velocity', 'v=' + spd.toFixed(1));
}
}
/* impulse flash at impact */
if (this._impactPt) {
const iEl = (performance.now() - this._impactPt.ts) / 300;
if (iEl < 1) {
const ip = this._impactPt;
LSPhysFX.drawForceArrow(ctx, ip.x, ip.y, 0, -40 * (1 - iEl), 'impulse', 'J');
}
}
}
/* ── Energy bars overlay ── */
if (this._energyOn && window.LSPhysFX) {
this._drawEnergyBarsColl(ctx, W, H);
}
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Energy bars: collision (1D) ── */
_drawEnergyBarsColl(ctx, W, H) {
var ke = 0;
for (var i = 0; i < this._b.length; i++) {
var b = this._b[i];
var spd = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
ke += 0.5 * b.m * spd * spd;
}
var tot = ke + this._frictionWork;
if (tot > this._energyScale) this._energyScale = tot;
var PW = 188, MARGIN = 12;
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
{ ke: ke, friction: this._frictionWork, total: this._energyScale }, {});
}
/* ── hover inspector ── */
_onMouseMove(e) {
@@ -2154,6 +2269,57 @@ function _roundRect(ctx, x, y, w, h, r) {
/* first fit + draw */
cSim.fit(); cSim.setSpeed(+document.getElementById('sl-speed').value);
collParam(); cSim.draw(); _collUpdateUI(cSim.stats());
/* ── Inject TimeControl bar ── */
_collInjectTCBar();
}
function _collInjectTCBar() {
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
var wrap = document.getElementById('sim-coll');
if (!wrap || wrap.querySelector('.tc-bar')) return;
/* Build a shared TC that controls whichever sim is active */
var proxyTC = {
scale: 1, paused: false,
advance: function(dt) { return this.paused ? 0 : dt * this.scale; },
setScale: function(s) {
this.scale = +s;
/* propagate to all sims */
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.setScale(proxyTC.scale); });
},
togglePause: function() {
this.paused = !this.paused;
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s && s._tc) s._tc.paused = proxyTC.paused; });
return this.paused;
},
};
/* Trail toggle */
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 = 'Следы движения';
trailBtn.addEventListener('click', function() {
var on = !((cSim && cSim.showTCTrails));
[cSim, cSim2D, cSimMB, cSimBL].forEach(function(s) { if (s) s.showTCTrails = 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)';
});
/* dummy sim adapter for LSBuildTimeControlUI */
var simProxy = { draw: function() { var a = cSim || cSim2D || cSimMB || cSimBL; if (a) a.draw(); } };
var tcBar = window.LSBuildTimeControlUI(simProxy, proxyTC, { scrubSupported: false });
var sep = document.createElement('div');
sep.style.cssText = 'width:1px;height:18px;background:rgba(255,255,255,0.12);flex-shrink:0';
tcBar.appendChild(sep);
tcBar.appendChild(trailBtn);
var statsBar = wrap.querySelector('.proj-stats-bar');
if (statsBar) wrap.insertBefore(tcBar, statsBar);
else wrap.appendChild(tcBar);
}
/* Switch active mode */
@@ -2412,5 +2578,31 @@ function _roundRect(ctx, x, y, w, h, r) {
_collSyncBtn();
}
/* ── Energy toggle: collision ── */
function collToggleEnergy() {
const as = _activeSim && _activeSim();
if (!as) return;
as._energyOn = !as._energyOn;
const on = as._energyOn;
const btn = document.getElementById('coll-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) { as._frictionWork = 0; as._energyScale = 0; }
as.draw();
}
/* ── magnetic ── */
function collToggleGraphs() {
const as = _activeSim && _activeSim();
if (!as || typeof as.toggleGraphs !== 'function') return;
const canvasOuter = document.querySelector('#sim-coll .proj-canvas-outer');
if (!canvasOuter) return;
const on = as.toggleGraphs(canvasOuter);
const btn = document.getElementById('btn-coll-graphs');
if (btn) btn.classList.toggle('active', on);
}