213 lines
4.8 KiB
JavaScript
213 lines
4.8 KiB
JavaScript
import { mechanismBounds } from './geometry.js';
|
|
import { radToDeg } from './math.js';
|
|
|
|
const COLORS = {
|
|
bg: '#0f1116',
|
|
grid: '#1a2230',
|
|
floor: '#3a465d',
|
|
body: '#ea0000',
|
|
link: '#000000',
|
|
crank: '#00ff00',
|
|
pivot: '#ffff00',
|
|
traceNear: '#6fd2ff',
|
|
traceFar: '#b483ff',
|
|
};
|
|
|
|
function drawLine(ctx, a, b, style = '#fff', width = 2) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(a.x, a.y);
|
|
ctx.lineTo(b.x, b.y);
|
|
ctx.strokeStyle = style;
|
|
ctx.lineWidth = width;
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawCircle(ctx, p, r, fill, stroke = null, lineWidth = 1) {
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
|
|
if (fill) {
|
|
ctx.fillStyle = fill;
|
|
ctx.fill();
|
|
}
|
|
if (stroke) {
|
|
ctx.strokeStyle = stroke;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawPolyline(ctx, pts, style, width = 1.5, alpha = 1) {
|
|
if (!pts || pts.length < 2) return;
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0].x, pts[0].y);
|
|
for (let i = 1; i < pts.length; i += 1) {
|
|
ctx.lineTo(pts[i].x, pts[i].y);
|
|
}
|
|
ctx.strokeStyle = style;
|
|
ctx.lineWidth = width;
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function worldToScreenFactory(canvas) {
|
|
const b = mechanismBounds();
|
|
const worldW = b.maxX - b.minX;
|
|
const worldH = b.maxY - b.minY;
|
|
|
|
const pad = 30;
|
|
const scale = Math.min(
|
|
(canvas.width - pad * 2) / worldW,
|
|
(canvas.height - pad * 2) / worldH,
|
|
);
|
|
|
|
// Rotate full scene by 180° in screen space (flip X and Y)
|
|
// so the platform orientation matches the expected floor-facing legs.
|
|
const ox = (canvas.width - worldW * scale) * 0.5 + b.maxX * scale;
|
|
const oy = (canvas.height - worldH * scale) * 0.5 - b.minY * scale;
|
|
|
|
return {
|
|
scale,
|
|
toScreen(p) {
|
|
return {
|
|
x: ox - p.x * scale,
|
|
y: oy + p.y * scale,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
function drawGrid(ctx, canvas) {
|
|
const step = 40;
|
|
ctx.save();
|
|
ctx.strokeStyle = COLORS.grid;
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x <= canvas.width; x += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= canvas.height; y += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawFloor(ctx, canvas, tf, floorY) {
|
|
if (!Number.isFinite(floorY)) return;
|
|
const y = tf.toScreen({ x: 0, y: floorY }).y;
|
|
ctx.save();
|
|
ctx.strokeStyle = COLORS.floor;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawSide(ctx, tf, side, opacity, thickness = 2.5) {
|
|
if (!side) return;
|
|
ctx.save();
|
|
ctx.globalAlpha = opacity;
|
|
|
|
const A = tf.toScreen(side.A);
|
|
const B = tf.toScreen(side.B);
|
|
const O = tf.toScreen(side.O);
|
|
const C = tf.toScreen(side.C);
|
|
const D = tf.toScreen(side.D);
|
|
const E = tf.toScreen(side.E);
|
|
const F = tf.toScreen(side.F);
|
|
const G = tf.toScreen(side.G);
|
|
|
|
// Body
|
|
drawLine(ctx, A, B, COLORS.body, thickness);
|
|
|
|
// Crank
|
|
drawCircle(ctx, O, 7.5 * tf.scale / 8, COLORS.crank, '#000', 1);
|
|
drawLine(ctx, O, C, COLORS.crank, thickness);
|
|
|
|
// Links
|
|
drawLine(ctx, A, D, COLORS.link, thickness);
|
|
drawLine(ctx, C, D, COLORS.link, thickness);
|
|
drawLine(ctx, C, F, COLORS.link, thickness);
|
|
|
|
drawLine(ctx, B, E, COLORS.link, thickness);
|
|
drawLine(ctx, C, E, COLORS.link, thickness);
|
|
drawLine(ctx, C, G, COLORS.link, thickness);
|
|
|
|
// Pivots
|
|
for (const p of [A, B, C, D, E]) {
|
|
drawCircle(ctx, p, 4, COLORS.pivot);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawHUD(ctx, data) {
|
|
ctx.save();
|
|
ctx.fillStyle = '#dfe7f6';
|
|
ctx.font = '13px ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
const tDeg = radToDeg(data.theta).toFixed(1);
|
|
const p = (data.theta / (Math.PI * 2)).toFixed(3);
|
|
ctx.fillText(`theta: ${tDeg} deg`, 16, 24);
|
|
ctx.fillText(`phase: ${p}`, 16, 42);
|
|
ctx.restore();
|
|
}
|
|
|
|
export function renderScene(canvas, state) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = COLORS.bg;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
drawGrid(ctx, canvas);
|
|
|
|
const tf = worldToScreenFactory(canvas);
|
|
drawFloor(ctx, canvas, tf, state.ground?.floorY ?? NaN);
|
|
|
|
if (state.showTrace) {
|
|
drawPolyline(
|
|
ctx,
|
|
state.trace.nearF.points.map(tf.toScreen),
|
|
COLORS.traceNear,
|
|
1.8,
|
|
0.9,
|
|
);
|
|
drawPolyline(
|
|
ctx,
|
|
state.trace.nearG.points.map(tf.toScreen),
|
|
COLORS.traceNear,
|
|
1.8,
|
|
0.5,
|
|
);
|
|
drawPolyline(
|
|
ctx,
|
|
state.trace.farF.points.map(tf.toScreen),
|
|
COLORS.traceFar,
|
|
1.4,
|
|
0.7,
|
|
);
|
|
drawPolyline(
|
|
ctx,
|
|
state.trace.farG.points.map(tf.toScreen),
|
|
COLORS.traceFar,
|
|
1.4,
|
|
0.4,
|
|
);
|
|
}
|
|
|
|
if (state.showFar) {
|
|
drawSide(ctx, tf, state.pose.far, 0.5, 2);
|
|
}
|
|
if (state.showNear) {
|
|
drawSide(ctx, tf, state.pose.near, 1, 2.6);
|
|
}
|
|
|
|
drawHUD(ctx, state.pose);
|
|
}
|