Created
May 30, 2026 06:31
-
-
Save EncodeTheCode/9a1addd45772754c891bd4e46bde01d3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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