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