Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save EncodeTheCode/9a1addd45772754c891bd4e46bde01d3 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/9a1addd45772754c891bd4e46bde01d3 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blueprint Box Conveyor</title>
<style>
:root {
--bg: #0b2b57;
--bg2: #0f3570;
--line-soft: rgba(255, 255, 255, 0.08);
--white: #ecf6ff;
--panel: rgba(5, 18, 39, 0.84);
--accent: #8fd3ff;
--accent2: #d7f2ff;
--danger: #ff9aa2;
--shadow: rgba(0, 0, 0, 0.35);
}
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
linear-gradient(to right, var(--line-soft) 1px, transparent 1px) 0 0 / 28px 28px,
linear-gradient(to bottom, var(--line-soft) 1px, transparent 1px) 0 0 / 28px 28px,
linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.01)),
radial-gradient(circle at 30% 20%, rgba(140, 211, 255, 0.12), transparent 30%),
radial-gradient(circle at 70% 60%, rgba(140, 211, 255, 0.08), transparent 35%),
linear-gradient(180deg, var(--bg2), var(--bg));
color: var(--white);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
.app {
display: grid;
grid-template-columns: 380px 1fr;
height: 100vh;
width: 100vw;
}
.panel {
position: relative;
z-index: 2;
background: linear-gradient(180deg, rgba(3, 15, 34, 0.92), rgba(4, 17, 40, 0.80));
border-right: 1px solid rgba(255,255,255,0.14);
backdrop-filter: blur(8px);
box-shadow: 8px 0 40px var(--shadow);
padding: 18px;
overflow: auto;
}
.title {
font-size: 24px;
font-weight: 800;
letter-spacing: 0.02em;
margin: 0 0 8px;
}
.subtitle {
margin: 0 0 16px;
color: rgba(236, 246, 255, 0.76);
line-height: 1.35;
font-size: 13px;
}
.card {
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.04);
border-radius: 18px;
padding: 14px;
margin-bottom: 14px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.12);
}
.card h2 {
margin: 0 0 10px;
font-size: 15px;
letter-spacing: 0.03em;
color: var(--accent2);
text-transform: uppercase;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
label {
display: block;
font-size: 12px;
color: rgba(236, 246, 255, 0.8);
margin-bottom: 6px;
}
input, select, button {
width: 100%;
box-sizing: border-box;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(8, 25, 52, 0.9);
color: var(--white);
padding: 10px 12px;
font: inherit;
outline: none;
}
input:focus, select:focus {
border-color: rgba(143, 211, 255, 0.65);
box-shadow: 0 0 0 3px rgba(143, 211, 255, 0.12);
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
button {
cursor: pointer;
font-weight: 700;
letter-spacing: 0.01em;
transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease;
}
button:hover { transform: translateY(-1px); }
button.primary {
background: linear-gradient(180deg, rgba(143, 211, 255, 0.25), rgba(143, 211, 255, 0.12));
border-color: rgba(143, 211, 255, 0.55);
}
button.danger {
background: linear-gradient(180deg, rgba(255, 154, 162, 0.18), rgba(255, 154, 162, 0.08));
border-color: rgba(255, 154, 162, 0.40);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: rgba(236, 246, 255, 0.70);
line-height: 1.45;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 42vh;
overflow: auto;
padding-right: 6px;
}
.box-item {
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.04);
border-radius: 14px;
padding: 10px;
cursor: pointer;
}
.box-item.selected {
border-color: rgba(143, 211, 255, 0.68);
background: rgba(143, 211, 255, 0.08);
}
.box-item .topline {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 12px;
margin-bottom: 8px;
color: rgba(236, 246, 255, 0.90);
}
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.mini-grid input {
padding: 8px 9px;
font-size: 12px;
}
.canvas-wrap {
position: relative;
overflow: hidden;
}
canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.overlay {
position: absolute;
left: 18px;
top: 16px;
z-index: 1;
pointer-events: none;
color: rgba(236, 246, 255, 0.88);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
font-size: 12px;
line-height: 1.4;
}
.legend {
margin-top: 8px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(3, 14, 32, 0.35);
border: 1px solid rgba(255,255,255,0.12);
display: inline-block;
}
.footer-note {
margin-top: 10px;
color: rgba(236, 246, 255, 0.62);
font-size: 11px;
}
</style>
</head>
<body>
<div class="app">
<aside class="panel">
<h1 class="title">Blueprint Box Conveyor</h1>
<p class="subtitle">
Blue-and-white factory/parcel layout with live dimension editing, median-centred row placement,
8 cm spacing, and animated dashed outlines. Left-drag to pan, mouse wheel to zoom.
</p>
<div class="card">
<h2>Create box</h2>
<div class="grid-3">
<div>
<label for="newW">Width (cm)</label>
<input id="newW" type="number" min="1" step="1" value="40" />
</div>
<div>
<label for="newH">Height (cm)</label>
<input id="newH" type="number" min="1" step="1" value="30" />
</div>
<div>
<label for="newL">Length (cm)</label>
<input id="newL" type="number" min="1" step="1" value="60" />
</div>
</div>
<div class="btn-row">
<button id="addBox" class="primary">Add new box</button>
<button id="removeBox" class="danger">Remove selected</button>
</div>
<div class="hint">
Boxes are arranged left-to-right like a conveyor belt. New boxes are appended, then the full row
recentres around the median box centre.
</div>
</div>
<div class="card">
<h2>Selected box</h2>
<div class="row">
<div>
<label for="selW">Width</label>
<input id="selW" type="number" min="1" step="1" />
</div>
<div>
<label for="selH">Height</label>
<input id="selH" type="number" min="1" step="1" />
</div>
</div>
<div class="row">
<div>
<label for="selL">Length</label>
<input id="selL" type="number" min="1" step="1" />
</div>
<div>
<label for="selectedBox">Active box</label>
<select id="selectedBox"></select>
</div>
</div>
<div class="hint" id="selectedSummary">No box selected.</div>
</div>
<div class="card">
<h2>Box list</h2>
<div class="list" id="boxList"></div>
</div>
<div class="card">
<h2>Scale notes</h2>
<div class="hint">
Human reference: 184 cm tall. Spacing between boxes: 8 cm. Positions are shown as x / y / z in cm.
</div>
</div>
</aside>
<main class="canvas-wrap">
<div class="overlay">
<div><strong>Live blueprint layout</strong></div>
<div>Dashed edges animate very slowly for a calmer blueprint feel.</div>
<div class="legend">Blueprint theme · metric units · median-centered row</div>
</div>
<canvas id="scene"></canvas>
</main>
</div>
<script>
(() => {
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const pxPerCm = 4.0;
const gapCm = 8;
const humanHeightCm = 184;
const state = {
boxes: [],
selectedId: null,
dashOffset: 0,
camera: {
panX: 0,
panY: 0,
zoom: 0.72,
minZoom: 0.08,
maxZoom: 3.5,
},
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragStartPanX: 0,
dragStartPanY: 0,
};
let nextId = 1;
const ui = {
newW: document.getElementById('newW'),
newH: document.getElementById('newH'),
newL: document.getElementById('newL'),
addBox: document.getElementById('addBox'),
removeBox: document.getElementById('removeBox'),
selW: document.getElementById('selW'),
selH: document.getElementById('selH'),
selL: document.getElementById('selL'),
selectedBox: document.getElementById('selectedBox'),
selectedSummary: document.getElementById('selectedSummary'),
boxList: document.getElementById('boxList'),
};
class BoxItem {
constructor(width, height, length) {
this.id = nextId++;
this.width = Math.max(1, Number(width) || 1);
this.height = Math.max(1, Number(height) || 1);
this.length = Math.max(1, Number(length) || 1);
this.x = 0;
this.y = 0;
this.z = 0;
}
}
function addBox(width, height, length) {
const box = new BoxItem(width, height, length);
state.boxes.push(box);
state.selectedId = box.id;
layoutBoxes();
syncUI();
}
function removeSelected() {
if (!state.selectedId) return;
const idx = state.boxes.findIndex(b => b.id === state.selectedId);
if (idx === -1) return;
state.boxes.splice(idx, 1);
state.selectedId = state.boxes.length ? state.boxes[Math.max(0, idx - 1)]?.id || state.boxes[0].id : null;
layoutBoxes();
syncUI();
}
function median(values) {
if (!values.length) return 0;
const arr = [...values].sort((a, b) => a - b);
const mid = Math.floor(arr.length / 2);
return arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2;
}
function layoutBoxes() {
let cursor = 0;
const centers = [];
for (let i = 0; i < state.boxes.length; i++) {
const b = state.boxes[i];
if (i > 0) cursor += gapCm;
cursor += b.length / 2;
b.x = cursor;
b.y = 0;
b.z = 0;
centers.push(b.x);
cursor += b.length / 2;
}
const med = median(centers);
for (const b of state.boxes) b.x -= med;
}
function getSelected() {
return state.boxes.find(b => b.id === state.selectedId) || null;
}
function syncUI() {
const sel = getSelected();
ui.selectedBox.innerHTML = state.boxes.map(b => {
const isSel = b.id === state.selectedId ? 'selected' : '';
return `<option value="${b.id}" ${isSel}>Box #${b.id}</option>`;
}).join('');
ui.selectedBox.value = sel ? String(sel.id) : '';
ui.selW.value = sel ? sel.width : '';
ui.selH.value = sel ? sel.height : '';
ui.selL.value = sel ? sel.length : '';
ui.selectedSummary.textContent = sel
? `Box #${sel.id}: position x=${sel.x.toFixed(1)} cm, y=${sel.y.toFixed(1)} cm, z=${sel.z.toFixed(1)} cm`
: 'No box selected.';
ui.boxList.innerHTML = state.boxes.map(b => {
const selected = b.id === state.selectedId ? 'selected' : '';
return `
<div class="box-item ${selected}" data-id="${b.id}">
<div class="topline">
<strong>Box #${b.id}</strong>
<span>x=${b.x.toFixed(1)} · y=${b.y.toFixed(1)} · z=${b.z.toFixed(1)} cm</span>
</div>
<div class="mini-grid">
<div><label>Width</label><input type="number" min="1" step="1" data-field="width" value="${b.width}"></div>
<div><label>Height</label><input type="number" min="1" step="1" data-field="height" value="${b.height}"></div>
<div><label>Length</label><input type="number" min="1" step="1" data-field="length" value="${b.length}"></div>
</div>
</div>
`;
}).join('');
attachListHandlers();
}
function attachListHandlers() {
document.querySelectorAll('.box-item').forEach(item => {
item.addEventListener('click', () => {
const id = Number(item.dataset.id);
if (!Number.isFinite(id)) return;
state.selectedId = id;
syncUI();
});
item.querySelectorAll('input').forEach(input => {
input.addEventListener('input', (e) => {
e.stopPropagation();
const id = Number(item.dataset.id);
const box = state.boxes.find(b => b.id === id);
if (!box) return;
const field = input.dataset.field;
box[field] = Math.max(1, Number(input.value) || 1);
layoutBoxes();
syncUI();
});
});
});
}
function baseProjectPoint(x, y, z, originX, originY) {
const angle = Math.PI / 6;
const sx = originX + (x - z) * Math.cos(angle) * pxPerCm;
const sy = originY + (x + z) * Math.sin(angle) * pxPerCm - y * pxPerCm;
return { x: sx, y: sy };
}
function screenPointFromWorld(x, y, z, originX, originY) {
const p = baseProjectPoint(x, y, z, originX, originY);
return {
x: originX + (p.x - originX) * state.camera.zoom + state.camera.panX,
y: originY + (p.y - originY) * state.camera.zoom + state.camera.panY,
};
}
function worldFromScreen(mouseX, mouseY, originX, originY) {
const baseX = originX + (mouseX - originX - state.camera.panX) / state.camera.zoom;
const baseY = originY + (mouseY - originY - state.camera.panY) / state.camera.zoom;
return { x: baseX, y: baseY };
}
function drawDashedRect(points, dashOffset, strokeStyle, fillStyle, lineWidth) {
ctx.save();
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.fillStyle = fillStyle;
ctx.fill();
ctx.setLineDash([14, 10]);
ctx.lineDashOffset = -dashOffset;
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.stroke();
ctx.restore();
}
function drawBox(box, originX, originY) {
const x = box.x;
const y = box.y;
const z = box.z;
const w = box.width;
const h = box.height;
const l = box.length;
const A = screenPointFromWorld(x - l / 2, y, z - w / 2, originX, originY);
const B = screenPointFromWorld(x + l / 2, y, z - w / 2, originX, originY);
const C = screenPointFromWorld(x + l / 2, y, z + w / 2, originX, originY);
const D = screenPointFromWorld(x - l / 2, y, z + w / 2, originX, originY);
const A2 = screenPointFromWorld(x - l / 2, y + h, z - w / 2, originX, originY);
const B2 = screenPointFromWorld(x + l / 2, y + h, z - w / 2, originX, originY);
const C2 = screenPointFromWorld(x + l / 2, y + h, z + w / 2, originX, originY);
const D2 = screenPointFromWorld(x - l / 2, y + h, z + w / 2, originX, originY);
const stroke = box.id === state.selectedId ? 'rgba(215, 242, 255, 0.98)' : 'rgba(236, 246, 255, 0.85)';
const topFill = 'rgba(236, 246, 255, 0.06)';
const leftFill = 'rgba(143, 211, 255, 0.04)';
const rightFill = 'rgba(143, 211, 255, 0.06)';
const lw = box.id === state.selectedId ? 2.5 : 1.8;
ctx.save();
ctx.beginPath();
ctx.moveTo(A2.x, A2.y); ctx.lineTo(B2.x, B2.y); ctx.lineTo(C2.x, C2.y); ctx.lineTo(D2.x, D2.y); ctx.closePath();
ctx.fillStyle = topFill;
ctx.fill();
ctx.setLineDash([14, 10]);
ctx.lineDashOffset = -state.dashOffset;
ctx.strokeStyle = stroke;
ctx.lineWidth = lw;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(A.x, A.y); ctx.lineTo(D.x, D.y); ctx.lineTo(D2.x, D2.y); ctx.lineTo(A2.x, A2.y); ctx.closePath();
ctx.fillStyle = leftFill;
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(B.x, B.y); ctx.lineTo(C.x, C.y); ctx.lineTo(C2.x, C2.y); ctx.lineTo(B2.x, B2.y); ctx.closePath();
ctx.fillStyle = rightFill;
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(A.x, A.y); ctx.lineTo(B.x, B.y); ctx.lineTo(C.x, C.y); ctx.lineTo(D.x, D.y); ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(A.x, A.y); ctx.lineTo(A2.x, A2.y);
ctx.moveTo(B.x, B.y); ctx.lineTo(B2.x, B2.y);
ctx.moveTo(C.x, C.y); ctx.lineTo(C2.x, C2.y);
ctx.moveTo(D.x, D.y); ctx.lineTo(D2.x, D2.y);
ctx.stroke();
ctx.setLineDash([]);
const centerTop = screenPointFromWorld(x, y + h + 6, z, originX, originY);
ctx.fillStyle = 'rgba(236,246,255,0.96)';
ctx.font = 'bold 13px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`W ${w} · H ${h} · L ${l} cm`, centerTop.x, centerTop.y);
ctx.font = '12px Inter, system-ui, sans-serif';
ctx.fillStyle = 'rgba(236,246,255,0.78)';
ctx.fillText(`x ${x.toFixed(1)} y ${y.toFixed(1)} z ${z.toFixed(1)} cm`, centerTop.x, centerTop.y + 16);
ctx.restore();
}
function drawHuman(originX, originY) {
const leftmostEdge = state.boxes.length
? Math.min(...state.boxes.map(b => b.x - b.length / 2))
: -120;
// Place the human to the left of the first box, keeping a clear gap.
// The figure faces world +X (toward the boxes), not the camera.
const humanHeight = 184;
const humanScale = 1.0;
const humanWidth = 52;
const humanX = leftmostEdge - gapCm - humanWidth / 2 - 30;
const baseY = 0;
const baseZ = 0;
const project = (x, y, z) => screenPointFromWorld(x, y, z, originX, originY);
// Shadow / ground anchor
const footL = project(humanX - 9, baseY, baseZ + 4);
const footR = project(humanX + 9, baseY, baseZ + 4);
const groundMid = project(humanX, baseY, baseZ);
ctx.save();
ctx.fillStyle = 'rgba(236,246,255,0.08)';
ctx.beginPath();
ctx.ellipse(groundMid.x, groundMid.y + 7, 28 * state.camera.zoom, 8 * state.camera.zoom, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Head as a shaded oval, slightly offset in the facing direction.
const headBottomY = humanHeight - 22;
const headTopY = humanHeight;
const headCenter = project(humanX + 3, (headTopY + headBottomY) / 2, baseZ - 1);
const headH = 22 * state.camera.zoom;
const headW = 17 * state.camera.zoom;
function drawShadedCapsule(p1, p2, radius, fill, stroke, dash = false) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy) || 1;
const nx = -dy / len;
const ny = dx / len;
ctx.beginPath();
ctx.moveTo(p1.x + nx * radius, p1.y + ny * radius);
ctx.lineTo(p2.x + nx * radius, p2.y + ny * radius);
ctx.arc(p2.x, p2.y, radius, Math.atan2(ny, nx), Math.atan2(-ny, -nx), false);
ctx.lineTo(p1.x - nx * radius, p1.y - ny * radius);
ctx.arc(p1.x, p1.y, radius, Math.atan2(-ny, -nx), Math.atan2(ny, nx), false);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
if (dash) ctx.setLineDash([6, 5]);
ctx.strokeStyle = stroke;
ctx.stroke();
ctx.setLineDash([]);
}
ctx.save();
ctx.strokeStyle = 'rgba(236,246,255,0.92)';
ctx.fillStyle = 'rgba(236,246,255,0.14)';
ctx.lineWidth = 2;
// Feet and legs (world-oriented facing right)
const hip = project(humanX, 92, baseZ);
const kneeL = project(humanX - 4, 48, baseZ + 2);
const kneeR = project(humanX + 5, 48, baseZ - 1);
const ankleL = project(humanX - 8, 0, baseZ + 3);
const ankleR = project(humanX + 9, 0, baseZ - 1);
drawShadedCapsule(hip, kneeL, 4.3 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
drawShadedCapsule(kneeL, ankleL, 3.7 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
drawShadedCapsule(hip, kneeR, 4.3 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
drawShadedCapsule(kneeR, ankleR, 3.7 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
// Torso block
const shoulder = project(humanX + 2, 150, baseZ - 1);
const waist = project(humanX, 98, baseZ);
const torsoPts = [
project(humanX - 11, 145, baseZ + 4),
project(humanX + 11, 150, baseZ - 4),
project(humanX + 8, 96, baseZ - 3),
project(humanX - 9, 92, baseZ + 5),
];
ctx.beginPath();
ctx.moveTo(torsoPts[0].x, torsoPts[0].y);
for (let i = 1; i < torsoPts.length; i++) ctx.lineTo(torsoPts[i].x, torsoPts[i].y);
ctx.closePath();
ctx.fillStyle = 'rgba(143, 211, 255, 0.18)';
ctx.fill();
ctx.strokeStyle = 'rgba(236,246,255,0.92)';
ctx.stroke();
// Chest highlight to create a more 3D look
ctx.beginPath();
ctx.moveTo(project(humanX + 1, 142, baseZ - 2).x, project(humanX + 1, 142, baseZ - 2).y);
ctx.lineTo(project(humanX + 8, 138, baseZ - 5).x, project(humanX + 8, 138, baseZ - 5).y);
ctx.lineTo(project(humanX + 6, 104, baseZ - 4).x, project(humanX + 6, 104, baseZ - 4).y);
ctx.lineTo(project(humanX - 1, 108, baseZ - 1).x, project(humanX - 1, 108, baseZ - 1).y);
ctx.closePath();
ctx.fillStyle = 'rgba(236,246,255,0.12)';
ctx.fill();
// Arms in front of the torso, facing toward the boxes.
const elbowL = project(humanX - 15, 122, baseZ + 4);
const elbowR = project(humanX + 16, 122, baseZ - 3);
const handL = project(humanX - 20, 86, baseZ + 5);
const handR = project(humanX + 21, 86, baseZ - 4);
drawShadedCapsule(shoulder, elbowL, 3.5 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
drawShadedCapsule(elbowL, handL, 3.1 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
drawShadedCapsule(shoulder, elbowR, 3.5 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
drawShadedCapsule(elbowR, handR, 3.1 * state.camera.zoom, 'rgba(236,246,255,0.10)', 'rgba(236,246,255,0.90)');
// Head
ctx.beginPath();
ctx.ellipse(headCenter.x, headCenter.y, headW, headH, -0.08, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(236,246,255,0.16)';
ctx.fill();
ctx.strokeStyle = 'rgba(236,246,255,0.95)';
ctx.stroke();
// Face direction marker / nose edge to make the facing direction obvious.
const nose = project(humanX + 12, 172, baseZ - 2);
const brow = project(humanX + 2, 176, baseZ - 2);
ctx.beginPath();
ctx.moveTo(brow.x, brow.y);
ctx.lineTo(nose.x, nose.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(236,246,255,0.94)';
ctx.font = 'bold 13px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Human reference: 184 cm', groundMid.x, groundMid.y + 24);
ctx.font = '12px Inter, system-ui, sans-serif';
ctx.fillText('Facing boxes in world space', groundMid.x, groundMid.y + 40);
ctx.restore();
}
function drawFloorGrid(originX, originY) {
const step = 20;
ctx.save();
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
ctx.lineWidth = 1;
for (let x = -200; x < 200; x += step) {
const p1 = screenPointFromWorld(x, 0, -120, originX, originY);
const p2 = screenPointFromWorld(x, 0, 120, originX, originY);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
for (let z = -160; z < 180; z += step) {
const p1 = screenPointFromWorld(-220, 0, z, originX, originY);
const p2 = screenPointFromWorld(220, 0, z, originX, originY);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
ctx.restore();
}
function render() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
const originX = w * 0.56;
const originY = h * 0.64;
const g = ctx.createRadialGradient(w * 0.56, h * 0.55, 30, w * 0.56, h * 0.55, Math.max(w, h) * 0.8);
g.addColorStop(0, 'rgba(143, 211, 255, 0.12)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
drawFloorGrid(originX, originY);
ctx.save();
ctx.strokeStyle = 'rgba(236,246,255,0.28)';
ctx.setLineDash([8, 8]);
ctx.lineWidth = 1;
const ax1 = screenPointFromWorld(-280, 0, 0, originX, originY);
const ax2 = screenPointFromWorld(280, 0, 0, originX, originY);
ctx.beginPath();
ctx.moveTo(ax1.x, ax1.y + 1);
ctx.lineTo(ax2.x, ax2.y + 1);
ctx.stroke();
ctx.restore();
drawHuman(originX, originY);
for (const b of state.boxes) drawBox(b, originX, originY);
ctx.save();
ctx.fillStyle = 'rgba(236,246,255,0.82)';
ctx.font = '12px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
const center = screenPointFromWorld(0, 0, 0, originX, originY);
ctx.fillText('Median-centred box row', center.x, center.y + 18);
ctx.restore();
}
function updateSelectedFromInputs() {
const sel = getSelected();
if (!sel) return;
sel.width = Math.max(1, Number(ui.selW.value) || 1);
sel.height = Math.max(1, Number(ui.selH.value) || 1);
sel.length = Math.max(1, Number(ui.selL.value) || 1);
layoutBoxes();
syncUI();
}
ui.addBox.addEventListener('click', () => {
addBox(Number(ui.newW.value), Number(ui.newH.value), Number(ui.newL.value));
});
ui.removeBox.addEventListener('click', removeSelected);
ui.selectedBox.addEventListener('change', () => {
state.selectedId = Number(ui.selectedBox.value) || null;
syncUI();
});
ui.selW.addEventListener('input', updateSelectedFromInputs);
ui.selH.addEventListener('input', updateSelectedFromInputs);
ui.selL.addEventListener('input', updateSelectedFromInputs);
canvas.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
canvas.setPointerCapture(e.pointerId);
state.dragging = true;
state.dragStartX = e.clientX;
state.dragStartY = e.clientY;
state.dragStartPanX = state.camera.panX;
state.dragStartPanY = state.camera.panY;
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', (e) => {
if (!state.dragging) return;
const dx = e.clientX - state.dragStartX;
const dy = e.clientY - state.dragStartY;
state.camera.panX = state.dragStartPanX + dx;
state.camera.panY = state.dragStartPanY + dy;
});
canvas.addEventListener('pointerup', (e) => {
state.dragging = false;
canvas.style.cursor = 'grab';
try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
});
canvas.addEventListener('pointercancel', () => {
state.dragging = false;
canvas.style.cursor = 'grab';
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const originX = rect.width * 0.56;
const originY = rect.height * 0.64;
const before = worldFromScreen(mouseX, mouseY, originX, originY);
const factor = Math.exp(-e.deltaY * 0.0012);
const newZoom = Math.min(state.camera.maxZoom, Math.max(state.camera.minZoom, state.camera.zoom * factor));
state.camera.zoom = newZoom;
state.camera.panX = mouseX - originX - (before.x - originX) * state.camera.zoom;
state.camera.panY = mouseY - originY - (before.y - originY) * state.camera.zoom;
}, { passive: false });
window.addEventListener('resize', render);
// Seed boxes so the scene is immediately visible.
addBox(40, 30, 60);
addBox(55, 38, 75);
addBox(45, 28, 50);
state.selectedId = state.boxes[1].id;
layoutBoxes();
syncUI();
setInterval(() => {
state.dashOffset = (state.dashOffset + 0.03) % 1000;
render();
}, 6.25);
setInterval(() => {
const sel = getSelected();
if (!sel) return;
ui.selectedSummary.textContent = `Box #${sel.id}: position x=${sel.x.toFixed(1)} cm, y=${sel.y.toFixed(1)} cm, z=${sel.z.toFixed(1)} cm`;
}, 250);
render();
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment