'use strict'; /* ═══════════════════════════════════════════════════════════════════ LSPhysFX — shared physics visualization helpers Provides: drawVector, drawForceArrow, drawSpring, drawRope, drawSurface, drawCoordSystem, drawScale, drawClock, drawAngleArc, drawPivot ═══════════════════════════════════════════════════════════════════ */ (function(global) { if (!global.LSPhysFX) global.LSPhysFX = {}; /* ── Standard force colours ─────────────────────────────────── */ LSPhysFX.FORCE_COLORS = { gravity: '#EF476F', // mg — red normal: '#06D6E0', // N — cyan friction: '#FF8C42', // F_тр — orange tension: '#9B5DE5', // T — violet elastic: '#7BF5A4', // F_упр — green drag: '#888', // F_сопр — gray applied: '#FFD166', // F_прил — gold impulse: '#FF6B6B', // удар — bright red velocity: '#4CC9F0', // v vector — light blue accel: '#F15BB5', // a vector — pink }; /* ── drawVector ─────────────────────────────────────────────── */ /* Draws an arrow from (x0, y0) with components (vx, vy). opts: { color, label, scale=1, lineWidth=2, dashed=false, arrowSize=8 } */ LSPhysFX.drawVector = function(ctx, x0, y0, vx, vy, opts) { const o = opts || {}; const color = o.color || '#fff'; const scale = (o.scale !== undefined) ? o.scale : 1; const lw = (o.lineWidth !== undefined) ? o.lineWidth : 2; const arrowSize = (o.arrowSize !== undefined) ? o.arrowSize : 8; const label = o.label || ''; const dashed = o.dashed || false; const ex = x0 + vx * scale; const ey = y0 + vy * scale; const dx = ex - x0, dy = ey - y0; const len = Math.sqrt(dx * dx + dy * dy); if (len < 2) return; const ux = dx / len, uy = dy / len; const hw = arrowSize * 0.55, hl = arrowSize * 1.4; ctx.save(); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.shadowColor = color; ctx.shadowBlur = 5; if (dashed) ctx.setLineDash([5, 4]); /* shaft — stop before arrowhead */ ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(ex - ux * hl, ey - uy * hl); ctx.stroke(); ctx.setLineDash([]); /* arrowhead */ ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - ux * hl - uy * hw, ey - uy * hl + ux * hw); ctx.lineTo(ex - ux * hl + uy * hw, ey - uy * hl - ux * hw); ctx.closePath(); ctx.fill(); /* label at tip offset perpendicular */ if (label) { ctx.shadowBlur = 0; ctx.font = 'bold 10px Manrope, monospace'; ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const lx = (x0 + ex) / 2 - uy * 14; const ly = (y0 + ey) / 2 + ux * 14; ctx.fillText(label, lx, ly); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; } ctx.restore(); }; /* ── drawForceArrow ─────────────────────────────────────────── */ /* Convenience wrapper: type = key from FORCE_COLORS. (fx, fy) are canvas-space pixel components of the arrow. */ LSPhysFX.drawForceArrow = function(ctx, x0, y0, fx, fy, type, label) { const col = LSPhysFX.FORCE_COLORS[type] || '#fff'; LSPhysFX.drawVector(ctx, x0, y0, fx, fy, { color: col, label: label || '', scale: 1, lineWidth: 2.5, arrowSize: 8, }); }; /* ── drawSpring ─────────────────────────────────────────────── */ /* Draws a zigzag spring between two endpoints. opts: { coils=10, amp=8, color='#FFD166', lineWidth=2 } */ LSPhysFX.drawSpring = function(ctx, x1, y1, x2, y2, opts) { const o = opts || {}; const coils = (o.coils !== undefined) ? o.coils : 10; const amp = (o.amp !== undefined) ? o.amp : 8; const color = o.color || '#FFD166'; const lw = (o.lineWidth !== undefined) ? o.lineWidth : 2; const dx = x2 - x1, dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); if (len < 2) return; /* unit vectors along and perpendicular to spring axis */ const ux = dx / len, uy = dy / len; const nx = -uy, ny = ux; /* end pads: 10% each side */ const pad = Math.min(len * 0.08, 8); ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const seg = coils * 2 + 2; const bodyL = len - 2 * pad; ctx.beginPath(); ctx.moveTo(x1, y1); /* short pad line */ ctx.lineTo(x1 + ux * pad, y1 + uy * pad); for (let i = 1; i <= seg; i++) { const t = i / seg; const side = (i % 2 === 0) ? amp : -amp; const bx = x1 + ux * pad + t * bodyL * ux + nx * side; const bya = y1 + uy * pad + t * bodyL * uy + ny * side; ctx.lineTo(bx, bya); } /* short pad line at end */ ctx.lineTo(x2, y2); ctx.stroke(); ctx.restore(); }; /* ── drawRope ───────────────────────────────────────────────── */ /* Draws a rope (straight or catenary-like if sag > 0). opts: { color='rgba(210,225,255,0.6)', sag=0, lineWidth=2, dashed=false } */ LSPhysFX.drawRope = function(ctx, x1, y1, x2, y2, opts) { const o = opts || {}; const color = o.color || 'rgba(210,225,255,0.6)'; const sag = (o.sag !== undefined) ? o.sag : 0; const lw = (o.lineWidth !== undefined) ? o.lineWidth : 2; const dashed = o.dashed || false; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'round'; if (dashed) ctx.setLineDash([5, 4]); ctx.beginPath(); if (sag > 0) { const mx = (x1 + x2) / 2; const my = (y1 + y2) / 2 + sag; ctx.moveTo(x1, y1); ctx.quadraticCurveTo(mx, my, x2, y2); } else { ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); } ctx.stroke(); ctx.setLineDash([]); ctx.restore(); }; /* ── drawSurface ────────────────────────────────────────────── */ /* Draws a floor/wall/ramp with diagonal hatching. (x, y) = left endpoint of the surface line; w = length. opts: { angle=0 (radians), hatch=true, color='rgba(255,255,255,0.25)', thick=4 } */ LSPhysFX.drawSurface = function(ctx, x, y, w, opts) { const o = opts || {}; const angle = (o.angle !== undefined) ? o.angle : 0; const hatch = (o.hatch !== undefined) ? o.hatch : true; const color = o.color || 'rgba(255,255,255,0.25)'; const thick = (o.thick !== undefined) ? o.thick : 4; ctx.save(); ctx.translate(x, y); ctx.rotate(angle); /* main surface line */ ctx.strokeStyle = color; ctx.lineWidth = thick; ctx.lineCap = 'butt'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(w, 0); ctx.stroke(); /* hatching below */ if (hatch) { ctx.strokeStyle = 'rgba(255,255,255,0.10)'; ctx.lineWidth = 1; const step = 10; const depth = 12; for (let xi = 0; xi < w + depth; xi += step) { const xs = Math.min(xi, w); const xe = Math.max(0, xi - depth); if (xs === xe) continue; ctx.beginPath(); ctx.moveTo(xs, 0); ctx.lineTo(xe, depth); ctx.stroke(); } } ctx.restore(); }; /* ── drawCoordSystem ────────────────────────────────────────── */ /* Draws XY axes with optional grid inside a rect (ox,oy)–(ox+w,oy+h). opts: { labelX='x', labelY='y', gridSize=20, gridColor, axisColor, labelColor } */ LSPhysFX.drawCoordSystem = function(ctx, ox, oy, w, h, opts) { const o = opts || {}; const labelX = o.labelX || 'x'; const labelY = o.labelY || 'y'; const gridSize = (o.gridSize !== undefined) ? o.gridSize : 20; const gridColor = o.gridColor || 'rgba(255,255,255,0.05)'; const axisColor = o.axisColor || 'rgba(255,255,255,0.4)'; const labelColor = o.labelColor || 'rgba(255,255,255,0.6)'; ctx.save(); /* grid */ ctx.strokeStyle = gridColor; ctx.lineWidth = 1; for (let gx = ox; gx <= ox + w; gx += gridSize) { ctx.beginPath(); ctx.moveTo(gx, oy); ctx.lineTo(gx, oy + h); ctx.stroke(); } for (let gy = oy; gy <= oy + h; gy += gridSize) { ctx.beginPath(); ctx.moveTo(ox, gy); ctx.lineTo(ox + w, gy); ctx.stroke(); } /* axes */ ctx.strokeStyle = axisColor; ctx.lineWidth = 1.5; /* X axis */ ctx.beginPath(); ctx.moveTo(ox, oy + h); ctx.lineTo(ox + w + 10, oy + h); ctx.stroke(); /* Y axis */ ctx.beginPath(); ctx.moveTo(ox, oy + h); ctx.lineTo(ox, oy - 10); ctx.stroke(); /* axis labels */ ctx.fillStyle = labelColor; ctx.font = 'bold 11px Manrope, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(labelX, ox + w + 12, oy + h); ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText(labelY, ox, oy - 12); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; ctx.restore(); }; /* ── drawScale ──────────────────────────────────────────────── */ /* Ruler with tick marks. opts: { horizontal=true, label='', color='rgba(255,255,255,0.5)' } */ LSPhysFX.drawScale = function(ctx, x, y, length, divisions, opts) { const o = opts || {}; const horiz = (o.horizontal !== undefined) ? o.horizontal : true; const label = o.label || ''; const color = o.color || 'rgba(255,255,255,0.5)'; ctx.save(); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 1.5; ctx.lineCap = 'round'; ctx.font = '9px Manrope, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const step = length / divisions; if (horiz) { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + length, y); ctx.stroke(); for (let i = 0; i <= divisions; i++) { const tx = x + i * step; const tall = (i % 5 === 0) ? 8 : 4; ctx.beginPath(); ctx.moveTo(tx, y); ctx.lineTo(tx, y + tall); ctx.stroke(); if (i % 5 === 0) ctx.fillText(String(i), tx, y + 10); } } else { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + length); ctx.stroke(); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; for (let i = 0; i <= divisions; i++) { const ty = y + i * step; const tall = (i % 5 === 0) ? 8 : 4; ctx.beginPath(); ctx.moveTo(x, ty); ctx.lineTo(x - tall, ty); ctx.stroke(); if (i % 5 === 0) ctx.fillText(String(i), x - 10, ty); } } if (label) { ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(label, horiz ? x + length / 2 : x - 22, horiz ? y + 22 : y + length + 4); } ctx.restore(); }; /* ── drawClock ──────────────────────────────────────────────── */ /* Stopwatch face showing time t (seconds). */ LSPhysFX.drawClock = function(ctx, x, y, r, t) { ctx.save(); /* face */ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1.5; ctx.stroke(); /* ticks */ ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1; for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI * 2 - Math.PI / 2; const r1 = r * 0.80, r2 = r * 0.92; ctx.beginPath(); ctx.moveTo(x + Math.cos(a) * r1, y + Math.sin(a) * r1); ctx.lineTo(x + Math.cos(a) * r2, y + Math.sin(a) * r2); ctx.stroke(); } /* second hand — completes one revolution per 60 s */ const secAngle = (t % 60) / 60 * Math.PI * 2 - Math.PI / 2; ctx.strokeStyle = '#EF476F'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(secAngle) * r * 0.75, y + Math.sin(secAngle) * r * 0.75); ctx.stroke(); /* minute hand */ const minAngle = (t % 3600) / 3600 * Math.PI * 2 - Math.PI / 2; ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(minAngle) * r * 0.55, y + Math.sin(minAngle) * r * 0.55); ctx.stroke(); /* time label */ ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.font = `bold ${Math.max(8, r * 0.28)}px Manrope, monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const mins = Math.floor(t / 60); const secs = (t % 60).toFixed(1); ctx.fillText((mins > 0 ? mins + ':' : '') + (mins > 0 && t % 60 < 10 ? '0' : '') + secs + 'с', x, y + r * 0.38); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; ctx.restore(); }; /* ── drawAngleArc ───────────────────────────────────────────── */ /* Arc from angle a1 to a2 (radians) with optional label. opts: { color='rgba(255,200,60,0.5)', label='', lineWidth=1.5 } */ LSPhysFX.drawAngleArc = function(ctx, cx, cy, r, a1, a2, opts) { const o = opts || {}; const color = o.color || 'rgba(255,200,60,0.5)'; const label = o.label || ''; const lw = (o.lineWidth !== undefined) ? o.lineWidth : 1.5; ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.beginPath(); ctx.arc(cx, cy, r, a1, a2); ctx.stroke(); if (label) { const mid = (a1 + a2) / 2; const lx = cx + Math.cos(mid) * (r + 12); const ly = cy + Math.sin(mid) * (r + 12); ctx.fillStyle = color; ctx.font = 'bold 10px Manrope, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, lx, ly); ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; } ctx.restore(); }; /* ── drawEnergyBars ─────────────────────────────────────────── */ /* Universal stacked energy-bar widget for mech sims. Renders inside a semi-transparent panel at canvas position (x, y). energies : { ke — kinetic energy, J (always shown) pe — gravitational PE, J elastic — spring/elastic PE, J friction — cumulative heat loss (W_тр), J total — optional: external scale anchor (if provided and > sum, used for normalization) } opts : { w — panel width in px (default 185) bgColor — background fill borderColor — border stroke colors — { ke, pe, elastic, friction } } */ LSPhysFX.drawEnergyBars = function(ctx, x, y, w, _h, energies, opts) { opts = opts || {}; var ke = Math.max(0, energies.ke || 0); var pe = Math.max(0, energies.pe || 0); var friction = Math.max(0, energies.friction || 0); var elastic = Math.max(0, energies.elastic || 0); var mechSum = ke + pe + elastic; var total = mechSum + friction; /* external scale anchor for stable bars */ if (energies.total && energies.total > total) total = energies.total; if (total < 0.001) return; var cols = opts.colors || {}; var CKE = cols.ke || '#06D6E0'; var CPE = cols.pe || '#FFD166'; var CEL = cols.elastic || '#7BF5A4'; var CFR = cols.friction || '#888888'; /* layout */ var PAD_L = 40; /* left label column */ var PAD_R = 52; /* right value column */ var ROW_H = 14; var GAP = 5; var panelW = (w && w > 0 ? w : 185); /* rows — always show ke and W_тр, conditionally PE and elastic */ var rows = []; rows.push({ label: 'Eк', val: ke, color: CKE }); if (pe > 0.001) rows.push({ label: 'Eп', val: pe, color: CPE }); if (elastic > 0.001) rows.push({ label: 'Eупр', val: elastic, color: CEL }); rows.push({ label: 'Wтр', val: friction, color: CFR }); var panelH = GAP + (ROW_H + GAP) * (rows.length + 1); /* +1 for Sigma */ var barX = x + PAD_L; var barW = panelW - PAD_L - PAD_R; /* background panel */ ctx.save(); ctx.fillStyle = opts.bgColor || 'rgba(10,12,26,0.82)'; ctx.strokeStyle = opts.borderColor || 'rgba(255,255,255,0.13)'; ctx.lineWidth = 1; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, panelW, panelH, 8); else ctx.rect(x, y, panelW, panelH); ctx.fill(); ctx.stroke(); ctx.font = 'bold 9px Manrope, monospace'; ctx.textBaseline = 'middle'; /* ── Sigma row ── */ var ry = y + GAP; var sigmaW = (total / total) * barW; /* always full = reference */ ctx.fillStyle = 'rgba(255,255,255,0.18)'; _lsfx_rrect2(ctx, barX, ry + 1, barW, ROW_H - 2, 3); ctx.fill(); /* mech portion highlighted */ var mechW = (mechSum / total) * barW; ctx.fillStyle = 'rgba(255,255,255,0.38)'; if (mechW > 0.5) { _lsfx_rrect2(ctx, barX, ry + 1, mechW, ROW_H - 2, 3); ctx.fill(); } ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.textAlign = 'right'; ctx.fillText('ΣE', x + PAD_L - 4, ry + ROW_H / 2); ctx.textAlign = 'left'; ctx.fillText(_lsfx_fmt2(mechSum + friction), barX + barW + 4, ry + ROW_H / 2); ry += ROW_H + GAP; /* ── individual rows ── */ for (var i = 0; i < rows.length; i++) { var row = rows[i]; var bw = barW * (row.val / total); ctx.fillStyle = 'rgba(255,255,255,0.07)'; _lsfx_rrect2(ctx, barX, ry + 1, barW, ROW_H - 2, 3); ctx.fill(); if (bw > 0.5) { ctx.fillStyle = row.color; _lsfx_rrect2(ctx, barX, ry + 1, Math.max(bw, 1.5), ROW_H - 2, 3); ctx.fill(); } ctx.fillStyle = row.color; ctx.textAlign = 'right'; ctx.fillText(row.label, x + PAD_L - 4, ry + ROW_H / 2); ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.textAlign = 'left'; ctx.fillText(_lsfx_fmt2(row.val), barX + barW + 4, ry + ROW_H / 2); ry += ROW_H + GAP; } ctx.restore(); }; function _lsfx_rrect2(ctx, x, y, w, h, r) { ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(x, y, w, h, r); else ctx.rect(x, y, w, h); } function _lsfx_fmt2(v) { if (v >= 1000) return (v / 1000).toFixed(1) + 'кДж'; if (v >= 10) return v.toFixed(0) + ' Дж'; if (v >= 0.1) return v.toFixed(1) + ' Дж'; return v.toFixed(2) + ' Дж'; } /* ── drawPivot ──────────────────────────────────────────────── */ /* Pinned joint circle (pivot) for pendulum/lever. */ LSPhysFX.drawPivot = function(ctx, x, y, r) { const rad = r || 5; ctx.save(); ctx.beginPath(); ctx.arc(x, y, rad, 0, Math.PI * 2); ctx.fillStyle = '#444'; ctx.strokeStyle = 'rgba(255,255,255,0.4)'; ctx.lineWidth = 1.5; ctx.fill(); ctx.stroke(); /* crosshair */ ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x - rad, y); ctx.lineTo(x + rad, y); ctx.moveTo(x, y - rad); ctx.lineTo(x, y + rad); ctx.stroke(); ctx.restore(); }; })(window); /* ══════════════════════════════════════════════════════════════ TimeControl — playback speed + history scrubber Manages slow-motion, fast-forward and history replay. Integration in a sim's RAF tick: const scaledDt = this._tc.advance(rawDt); if (scaledDt === 0) { this.draw(); return; } this._step(scaledDt); this._tc.record(this.getState()); ══════════════════════════════════════════════════════════════ */ (function(global) { if (global.LSTimeControl) return; // guard: another agent may have loaded function _pvClone(obj) { try { if (typeof structuredClone === 'function') return structuredClone(obj); } catch (e) { /* ignore */ } return JSON.parse(JSON.stringify(obj)); } class TimeControl { constructor(sim, opts) { opts = opts || {}; this.sim = sim; this.scale = 1; this.paused = false; this.history = []; this.historyMax = opts.historyMax || 600; // 10 s @ 60 fps this.scrubbing = false; this.scrubIndex = null; } /* Returns scaled dt (0 when paused or scrubbing) */ advance(dtRaw) { if (this.paused) return 0; if (this.scrubbing) return 0; return dtRaw * this.scale; } record(state) { if (this.scrubbing) return; this.history.push({ t: performance.now(), state: _pvClone(state) }); while (this.history.length > this.historyMax) this.history.shift(); } setScrub(idx) { this.scrubbing = true; this.scrubIndex = idx; if (this.sim.applyState && this.history[idx]) { this.sim.applyState(this.history[idx].state); } } exitScrub() { this.scrubbing = false; } setScale(s) { this.scale = +s; } togglePause() { this.paused = !this.paused; return this.paused; } stepBack() { if (!this.history.length) return; const idx = (this.scrubIndex !== null) ? Math.max(0, this.scrubIndex - 1) : this.history.length - 1; this.setScrub(idx); } stepForward() { if (!this.history.length) return; if (this.scrubIndex === null) return; const idx = Math.min(this.history.length - 1, this.scrubIndex + 1); this.setScrub(idx); if (idx >= this.history.length - 1) this.exitScrub(); } reset() { this.history = []; this.scrubbing = false; this.scrubIndex = null; this.paused = false; } } global.LSTimeControl = TimeControl; /* ════════════════════════════════════════════════════════════ MotionTrail — fading gradient trail for moving bodies Usage each frame: trail.push(x, y); trail.tick(); trail.draw(ctx); ════════════════════════════════════════════════════════════ */ class MotionTrail { constructor(opts) { opts = opts || {}; this.points = []; this.maxAge = opts.maxAge || 60; this.maxLen = opts.maxLen || 120; this.color = opts.color || '#06D6E0'; this.width = opts.width || 3; } push(x, y) { this.points.push({ x, y, age: 0 }); while (this.points.length > this.maxLen) this.points.shift(); } tick() { for (let i = 0; i < this.points.length; i++) this.points[i].age++; while (this.points.length && this.points[0].age > this.maxAge) { this.points.shift(); } } clear() { this.points = []; } draw(ctx) { const pts = this.points; if (pts.length < 2) return; ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (let i = 1; i < pts.length; i++) { const p0 = pts[i - 1]; const p1 = pts[i]; const ratio = p1.age / this.maxAge; // 0=fresh, 1=gone const alpha = Math.max(0, (1 - ratio) * 0.72); if (alpha < 0.01) continue; ctx.globalAlpha = alpha; ctx.strokeStyle = this.color; ctx.lineWidth = Math.max(0.5, this.width * (1 - ratio * 0.6)); ctx.shadowColor = this.color; ctx.shadowBlur = 6 * (1 - ratio); ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.stroke(); } ctx.restore(); } } global.LSMotionTrail = MotionTrail; /* ════════════════════════════════════════════════════════════ buildTimeControlUI(sim, tc, opts) Builds and returns a