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
+211 -23
View File
@@ -83,6 +83,22 @@ class ForceSandboxSim {
this._floorY = 0;
this.onUpdate = null;
/* FBD toggle — when true all forces drawn via LSPhysFX colours */
this._fbdOn = false;
/* ── Universal energy bars ── */
this._energyOn = false;
this._energyScaleFSB = 0;
/* -- GraphPanel widget -- */
this._graphsOn = false;
this._graphUI = null;
this._graphBodyIdx = 0;
/* ── TimeControl ── */
this._tc = window.LSTimeControl ? new window.LSTimeControl(this) : null;
this.fit();
this._bindEvents();
}
@@ -176,6 +192,7 @@ class ForceSandboxSim {
pinned: false,
trail: [],
forces: [],
_trail2: window.LSMotionTrail ? new window.LSMotionTrail({ color, width: 2.5, maxLen: 100 }) : null,
};
this.bodies.push(body);
/* LabFX: spawn sound */
@@ -608,11 +625,30 @@ class ForceSandboxSim {
this._last = now;
if (window.LabFX) LabFX.particles.update(rawDt);
if (this._paused) { this.draw(); return; }
const dt = rawDt * this.timeScale;
/* TimeControl: scale + pause (wraps existing timeScale) */
let dt;
if (this._tc) {
dt = this._tc.advance(rawDt * this.timeScale);
if (dt === 0) { this.draw(); if (this.onUpdate) this.onUpdate(this.info()); return; }
} else {
dt = rawDt * this.timeScale;
}
this._simTime += dt;
this._step(dt);
this.draw();
if (this.onUpdate) this.onUpdate(this.info());
if (window.LSGraphPanel && this._graphsOn && this._graphUI && this.bodies.length > 0) {
const b = this.bodies[this._graphBodyIdx] || this.bodies[0];
const S = ForceSandboxSim.SCALE;
if (b && !b.pinned) {
const spd = Math.hypot(b.vx, b.vy) / S;
const dspd = this._gpPrevSpd != null ? (spd - this._gpPrevSpd) / Math.max(dt, 1e-6) : 0;
this._gpPrevSpd = spd;
this._graphUI.push(this._simTime, [b.x / S, spd, dspd]);
}
}
}
/* ════════════════════════════════════════════════════════════
@@ -718,11 +754,16 @@ class ForceSandboxSim {
if (this._strobeTimer >= 0.12) {
this._strobeTimer = 0;
for (const b of this.bodies) {
if (!this.showTrail || b.pinned) continue;
if (Math.hypot(b.vx, b.vy) > 8) {
if (b.pinned) continue;
if (this.showTrail && Math.hypot(b.vx, b.vy) > 8) {
b.trail.push({ x: b.x, y: b.y, a: b.angle });
if (b.trail.length > 40) b.trail.shift();
}
/* LSMotionTrail: continuous update regardless of speed threshold */
if (b._trail2) {
b._trail2.tick();
if (Math.hypot(b.vx, b.vy) > 2) b._trail2.push(b.x, b.y);
}
}
}
}
@@ -1404,6 +1445,8 @@ class ForceSandboxSim {
if (this.hasWalls) this._drawWalls(ctx, W, H, fY);
if (this.ramp) this._drawRamp(ctx);
if (this.showTrail) this._drawTrails(ctx);
/* LSMotionTrail overlay for each body */
for (const b of this.bodies) { if (b._trail2) b._trail2.draw(ctx); }
this._drawRopes(ctx);
if (window.LabFX && this.springs.length > 0) {
LabFX.glow.drawGlow(ctx, () => this._drawSprings(ctx), { color: '#9B5DE5', intensity: 4 });
@@ -1415,13 +1458,54 @@ class ForceSandboxSim {
if (this.showVelocity) this._drawVelocities(ctx);
if (this._drag) this._drawDragArrow(ctx);
if (this.showFBD && this._selected !== null) this._drawFBD(ctx);
if (this._fbdOn) this._pv_drawAllForces(ctx);
if (this.showEnergy) this._drawEnergyBar(ctx);
/* universal energy bars if enabled via toggle */
if (this._energyOn && window.LSPhysFX) this._drawEnergyBarsFSB(ctx);
if (this._ghostPos && !this._drag && !this._hovered && this.tool !== 'erase') this._drawGhost(ctx);
if (this.bodies.length === 0) this._drawHint(ctx);
/* LabFX: particles overlay */
if (window.LabFX) LabFX.particles.draw(this.ctx);
}
/* ── Universal energy bars: forcesandbox ── */
_drawEnergyBarsFSB(ctx) {
if (!this.bodies.length) return;
const S = ForceSandboxSim.SCALE, fY = this._floorY;
let KE = 0, PE = 0, EL = 0;
for (const b of this.bodies) {
const v = Math.hypot(b.vx, b.vy) / S;
KE += 0.5 * b.mass * v * v;
KE += 0.5 * (b.I / (S * S)) * b.omega * b.omega;
if (this.gravity && this.hasFloor) {
const bot = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r;
PE += b.mass * this.gVal * Math.max(0, fY - bot) / S;
}
/* elastic PE from springs */
for (const sp of this.springs) {
if (sp.a === b.id || sp.b === b.id) {
const ba = this.bodies.find(x => x.id === sp.a);
const bb = this.bodies.find(x => x.id === sp.b);
if (ba && bb) {
const dx = (bb.x - ba.x) / S, dy = (bb.y - ba.y) / S;
const dist = Math.sqrt(dx*dx + dy*dy);
const dl = dist - (sp.restLen || 0.1);
EL += 0.25 * sp.k * dl * dl; /* shared 50/50 per endpoint */
}
}
}
}
var fr = this._energyLoss;
var tot = KE + PE + EL + fr;
if (tot > this._energyScaleFSB) this._energyScaleFSB = tot;
const PW = 188, MARGIN = 12;
/* place in top-right but offset down if existing bar visible */
const oy = this.showEnergy ? 72 : MARGIN;
LSPhysFX.drawEnergyBars(ctx, this.W - PW - MARGIN, oy, PW, 0,
{ ke: KE, pe: PE, elastic: EL, friction: fr, total: this._energyScaleFSB }, {});
}
_drawBg(ctx, W, H) {
const bg = ctx.createRadialGradient(W / 2, H * 0.3, 0, W / 2, H / 2, W * 0.82);
bg.addColorStop(0, '#0d1320'); bg.addColorStop(1, '#050810');
@@ -1605,29 +1689,36 @@ class ForceSandboxSim {
} else {
cr = 6; cg = Math.round(214 + Math.abs(strain) / 1.5 * 41); cb = 224;
}
ctx.strokeStyle = `rgba(${cr},${cg},${cb},0.9)`;
ctx.lineWidth = 2;
ctx.shadowColor = `rgb(${cr},${cg},${cb})`;
ctx.shadowBlur = 6;
// Zigzag coil rendering
const COILS = 8;
const headLen = Math.min(dist * 0.08, 16);
const zigDist = dist - 2 * headLen;
const spColor = `rgba(${cr},${cg},${cb},0.9)`;
const amp = Math.max(3, Math.min(14, 10 / (1 + Math.abs(strain) * 3)));
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 + ux * headLen, y1 + uy * headLen);
for (let i = 0; i < COILS * 2; i++) {
const frac = (i + 0.5) / (COILS * 2);
const along = headLen + frac * zigDist;
const side = (i % 2 === 0) ? amp : -amp;
ctx.lineTo(x1 + ux * along + px * side, y1 + uy * along + py * side);
/* use LSPhysFX.drawSpring if available, else fallback */
if (window.LSPhysFX) {
ctx.save();
ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6;
LSPhysFX.drawSpring(ctx, x1, y1, x2, y2, {
coils: 8, amp: amp, color: spColor, lineWidth: 2,
});
ctx.restore();
} else {
const COILS = 8;
const headLen = Math.min(dist * 0.08, 16);
const zigDist = dist - 2 * headLen;
ctx.strokeStyle = spColor; ctx.lineWidth = 2;
ctx.shadowColor = `rgb(${cr},${cg},${cb})`; ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 + ux * headLen, y1 + uy * headLen);
for (let i = 0; i < COILS * 2; i++) {
const frac = (i + 0.5) / (COILS * 2);
const along = headLen + frac * zigDist;
const side = (i % 2 === 0) ? amp : -amp;
ctx.lineTo(x1 + ux * along + px * side, y1 + uy * along + py * side);
}
ctx.lineTo(x2 - ux * headLen, y2 - uy * headLen);
ctx.lineTo(x2, y2);
ctx.stroke();
}
ctx.lineTo(x2 - ux * headLen, y2 - uy * headLen);
ctx.lineTo(x2, y2);
ctx.stroke();
// Label
ctx.shadowBlur = 0;
@@ -1877,6 +1968,60 @@ class ForceSandboxSim {
ctx.textAlign = 'left';
}
/* ── _pv_drawAllForces: unified FBD via LSPhysFX ───────────── */
_pv_drawAllForces(ctx) {
if (!window.LSPhysFX) return;
const S = ForceSandboxSim.SCALE;
for (const b of this.bodies) {
const cx = b.x, cy = b.type === 'box' ? b.y : b.y;
/* gravity */
if (this.gravity) {
const mg = b.mass * this.gVal;
LSPhysFX.drawForceArrow(ctx, cx, cy, 0, Math.min(60, mg * 2.5), 'gravity',
'mg=' + mg.toFixed(0) + 'Н');
/* normal from floor */
const bottom = b.type === 'box' ? b.y + b.h / 2 : b.y + b.r;
if (this.hasFloor && Math.abs(bottom - this._floorY) < 6 && Math.abs(b.vy) < 8) {
LSPhysFX.drawForceArrow(ctx, cx, cy, 0, -Math.min(60, mg * 2.5), 'normal',
'N=' + mg.toFixed(0) + 'Н');
/* kinetic friction */
if (Math.abs(b.vx) > 10) {
const fFr = Math.max(b.mu, this.floorMu) * mg;
LSPhysFX.drawForceArrow(ctx, cx, cy,
-Math.sign(b.vx) * Math.min(50, fFr * 2), 0,
'friction', 'Fтр=' + fFr.toFixed(0) + 'Н');
}
}
}
/* spring forces */
for (const sp of this.springs) {
const other = (sp.b1id === b.id) ? this.bodies.find(bb => bb.id === sp.b2id)
: (sp.b2id === b.id ? this.bodies.find(bb => bb.id === sp.b1id) : null);
if (!other) continue;
const dx = other.x - b.x, dy = other.y - b.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const ext = dist - sp.L0;
if (Math.abs(ext) < 0.5) continue;
const fMag = sp.k * ext;
const fLen = Math.min(50, Math.abs(fMag) / S * 3 + 10);
LSPhysFX.drawForceArrow(ctx, cx, cy,
(dx / dist) * fLen * Math.sign(ext),
(dy / dist) * fLen * Math.sign(ext),
'elastic', 'Fупр=' + Math.abs(fMag / S).toFixed(0) + 'Н');
}
/* applied forces */
for (const f of b.forces) {
const fMag = Math.hypot(f.fx, f.fy) / S;
const fLen = Math.min(60, fMag * 2.5);
if (fLen < 3) continue;
const dir = Math.atan2(f.fy, f.fx);
LSPhysFX.drawForceArrow(ctx, cx, cy,
Math.cos(dir) * fLen, Math.sin(dir) * fLen,
'applied', (f.label || 'F') + '=' + fMag.toFixed(0) + 'Н');
}
}
}
_drawEnergyBar(ctx) {
if (!this.bodies.length) return;
const S = ForceSandboxSim.SCALE, fY = this._floorY;
@@ -2096,6 +2241,20 @@ class ForceSandboxSim {
}
}
/* ── Energy toggle: forcesandbox ── */
function fsbToggleEnergy() {
if (!sbSim) return;
sbSim._energyOn = !sbSim._energyOn;
const on = sbSim._energyOn;
const btn = document.getElementById('fsb-energy-btn');
if (btn) {
btn.classList.toggle('active', on);
btn.querySelector('span').textContent = on ? 'Энергия: Вкл' : 'Энергия: Выкл';
}
if (!on) sbSim._energyScaleFSB = 0;
sbSim.draw();
}
/* ── Utilities ───────────────────────────────────────────────── */
function _fsb_rrect(ctx, x, y, w, h, r) {
@@ -2115,3 +2274,32 @@ function _fsb_lighten(hex, d) {
const c = v => Math.max(0, Math.min(255, v));
return `rgb(${c((n >> 16) + d)},${c(((n >> 8) & 255) + d)},${c((n & 255) + d)})`;
}
function sandboxToggleGraphs() {
if (typeof sandboxSim === 'undefined' || !sandboxSim) return;
if (!window.LSGraphPanelUI) return;
sandboxSim._graphsOn = !sandboxSim._graphsOn;
sandboxSim._gpPrevSpd = null;
if (sandboxSim._graphsOn) {
const canvasOuter = document.querySelector('#sim-dynamics .proj-canvas-outer');
if (!canvasOuter) return;
const labels = sandboxSim.bodies.map((b, i) => (b.type === 'ball' ? 'Шар' : 'Блок') + (i + 1));
sandboxSim._graphUI = new GraphPanelUI(canvasOuter, {
maxPoints: 400,
traces: ['x', 'v', 'a'],
labels: ['x', 'v', '|a|'],
units: ['м', 'м/с', 'м/с²'],
colors: ['#06D6E0', '#FFD166', '#EF476F'],
toggleBtnId: 'btn-sandbox-graphs',
title: 'Тело',
bodySelector: labels.length ? labels : ['Тело 1'],
});
sandboxSim._graphUI.isOn = true;
sandboxSim._graphUI._build();
} else {
if (sandboxSim._graphUI) { sandboxSim._graphUI._destroy(); sandboxSim._graphUI = null; }
}
const btn = document.getElementById('btn-sandbox-graphs');
if (btn) btn.classList.toggle('active', sandboxSim._graphsOn);
}