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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user