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
+137 -1
View File
@@ -39,6 +39,11 @@ class ProjectileSim {
this._path = null; // [{x, y, vx, vy, t}]
this._pathTf = 0;
/* ── Energy bars widget ── */
this._energyOn = false; // toggle state
this._frictionWork = 0; // cumulative J lost to drag
this._energyScale = 0; // max observed total (for stable scale)
/* animation state */
this.t = 0;
this.playing = false;
@@ -124,6 +129,12 @@ class ProjectileSim {
this.planetCompare = false; // show 3 planet trajectories simultaneously
this.comparePlanets = ['earth', 'moon', 'mars']; // which 3
/* FBD toggle */
this._fbdOn = false;
/* ── TimeControl (speed-only; projectile manages its own time) ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
canvas.addEventListener('click', () => {
if (this.onPlayPause) this.onPlayPause();
});
@@ -925,6 +936,13 @@ class ProjectileSim {
if (window.LabFX) LabFX.particles.update(rawDt);
/* TimeControl: if paused via TC, just redraw */
if (this._tc && this._tc.paused) {
this.draw(); this._emit();
if (this.playing) this._tick();
return;
}
this._launchFlash = Math.max(0, this._launchFlash - rawDt * 2.5);
const prevT = this.t;
@@ -932,7 +950,20 @@ class ProjectileSim {
this._trail.push({ mx: cur.x, my: cur.y });
if (this._trail.length > 80) this._trail.shift();
this.t += rawDt * this.speed;
/* energy: accumulate drag work ΔW = F_drag · v · dt (approx) */
if (this._energyOn && (this.drag || this.parachute)) {
const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy);
const rho = this.rho;
const mass = Math.max(0.1, this.mass);
const A = this._chuteOpen ? this.chuteArea : 0.00785;
const Cd = this._chuteOpen ? this.chuteCd : this.Cd;
const Fd = 0.5 * Cd * rho * A * spd * spd;
this._frictionWork += Fd * spd * rawDt * this.speed;
}
/* advance time; respect TC scale on top of existing speed multiplier */
const tcScale = (this._tc && !this._tc.paused) ? this._tc.scale : 1;
this.t += rawDt * this.speed * tcScale;
const tf = this._curTFlight();
if (this.t >= tf) {
this.t = tf;
@@ -1044,6 +1075,8 @@ class ProjectileSim {
this._chuteOpen = this.parachute && this.chuteOpenHeight < 0;
this._chuteOpenedTs = -999;
this._chimeEmitted = false;
this._frictionWork = 0;
this._energyScale = 0;
this._computePath();
if (this.dualMode) {
this._p2.t = 0;
@@ -1718,10 +1751,80 @@ class ProjectileSim {
this._drawInspector(ctx, tpx, tpy, PL, gy, W, H, PB, PT);
}
/* ── 20. FBD overlay ── */
if (this._fbdOn && window.LSPhysFX && this.t > 0) {
const tf2 = this._curTFlight();
const cur2 = this._curState(Math.min(this.t, tf2));
const bx2 = tpx(cur2.x), by2 = tpy(Math.max(0, cur2.y));
const spd2 = Math.sqrt(cur2.vx * cur2.vx + cur2.vy * cur2.vy);
const FLEN = 48;
/* gravity — straight down */
const mg = this.mass * this.g;
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, FLEN, 'gravity',
'mg=' + mg.toFixed(0) + 'Н');
/* drag — opposite velocity */
if (this.drag && spd2 > 0.2) {
const rho2 = this.rho || 1.225;
const Fd = 0.5 * rho2 * this.Cd * 0.1 * spd2 * spd2;
const dragLen = Math.min(FLEN, Fd * 6);
if (dragLen > 3) {
const dnx = -cur2.vx / spd2, dny = -cur2.vy / spd2;
LSPhysFX.drawForceArrow(ctx, bx2, by2,
dnx * dragLen, dny * dragLen,
'drag', 'F_c=' + Fd.toFixed(1) + 'Н');
}
}
/* wind — horizontal */
if (this.wind !== 0) {
const windLen = Math.min(FLEN, Math.abs(this.wind) * 4);
LSPhysFX.drawForceArrow(ctx, bx2, by2,
Math.sign(this.wind) * windLen, 0,
'applied', 'F_w=' + Math.abs(this.wind).toFixed(0) + 'м/с');
}
/* elastic spring force — only when bounce just occurred */
if (this.bounce) {
const bounceElapsed = (performance.now() - this._impactTs) / 1000;
if (bounceElapsed >= 0 && bounceElapsed < 0.25 && cur2.y <= 0.5) {
LSPhysFX.drawForceArrow(ctx, bx2, by2, 0, -FLEN, 'elastic', 'F_упр');
}
}
}
/* ── Energy bars overlay ── */
if (this._energyOn && window.LSPhysFX) {
this._drawEnergyBarsProj(ctx, W, H);
}
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Energy bars: projectile ── */
_drawEnergyBarsProj(ctx, W, H) {
const tf = this._curTFlight();
const cur = this._curState(Math.min(this.t, tf));
const h = Math.max(0, cur.y); // height above launch (m)
const spd = Math.sqrt(cur.vx * cur.vx + cur.vy * cur.vy);
const m = Math.max(0.1, this.mass);
const g = this.g;
const ke = 0.5 * m * spd * spd;
const pe = m * g * h;
const fr = this._frictionWork;
const tot = ke + pe + fr;
/* stable scale */
if (tot > this._energyScale) this._energyScale = tot;
const scaleTotal = this._energyScale;
const PW = 188, MARGIN = 12;
LSPhysFX.drawEnergyBars(ctx, W - PW - MARGIN, MARGIN, PW, 0,
{ ke, pe, friction: fr, total: scaleTotal }, {});
}
/* ── hover inspector ── */
_onMouseMove(e) {
@@ -2011,6 +2114,7 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
pSim.onTargetUpdate = _projUpdateTargetHUD;
const gc = document.getElementById('proj-graphs-canvas');
if (gc) pSim.attachGraphsCanvas(gc);
_projInjectTCBar(pSim);
}
pSim.fit();
projParam(); // sync sliders → sim
@@ -2019,6 +2123,24 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
}));
}
function _projInjectTCBar(sim) {
if (!window.LSTimeControl || !window.LSBuildTimeControlUI) return;
var wrap = document.getElementById('sim-proj');
if (!wrap || wrap.querySelector('.tc-bar')) return;
/* Only speed control — projectile uses analytical/pre-computed paths */
/* TC.paused is checked in _tick. TC.scale multiplies rawDt * speed */
var tc = sim._tc;
var tcBar = window.LSBuildTimeControlUI(sim, tc, { scrubSupported: false });
/* Note: "Следы" already exist in the projectile panel — don't duplicate */
var statsBar = wrap.querySelector('.proj-stats-bar');
if (statsBar) wrap.insertBefore(tcBar, statsBar);
else wrap.appendChild(tcBar);
}
function projPlayPause() {
if (!pSim) return;
if (pSim.playing) {
@@ -2396,5 +2518,19 @@ function _projArrow(ctx, x1, y1, x2, y2, color, lw) {
pSim.draw();
}
/* ── Energy toggle: projectile ── */
function projToggleEnergy() {
if (!pSim) return;
pSim._energyOn = !pSim._energyOn;
const on = pSim._energyOn;
const btn = document.getElementById('proj-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) { pSim._frictionWork = 0; pSim._energyScale = 0; }
pSim.draw();
}
/* ── collision ── */