app
Adom Step2GLB - STEP to GLB converter
Public Made by Adomby adom
Color-preserving STEP (.step/.stp) to GLB converter. Thin Rust CLI shelling to a shared OCCT XCAF service. Formerly 'step2glb'.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
<!doctype html>
<html><head><meta charset="utf-8"><title>step2glb — thumb playground</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0d1117; color: #c9d1d9; font: 13px/1.5 system-ui, -apple-system, sans-serif; min-height: 100vh; }
header { padding: 14px 24px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 14px; }
h1 { margin: 0; font-size: 16px; color: #e6edf3; font-weight: 600; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; letter-spacing: 0.04em; background: rgba(0,184,176,0.15); color: #00e6dc; border: 1px solid rgba(0,184,176,0.3); font-family: ui-monospace, monospace; }
.meta { color: #7d8590; font-size: 11px; font-family: ui-monospace, monospace; margin-left: auto; }
.drop {
margin: 24px;
padding: 60px 24px;
border: 2px dashed #30363d;
border-radius: 8px;
background: #161b22;
text-align: center;
color: #8b949e;
transition: border-color 0.15s, background 0.15s;
cursor: pointer;
}
.drop.drag { border-color: #00b8b0; background: rgba(0,184,176,0.05); }
.drop strong { color: #e6edf3; font-size: 16px; display: block; margin-bottom: 8px; }
.drop input { display: none; }
.drop .hint { font-size: 11px; color: #7d8590; margin-top: 12px; }
.status { margin: 16px 24px; padding: 12px 16px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; font-family: ui-monospace, monospace; font-size: 12px; display: none; }
.status.show { display: block; }
.status.err { border-color: rgba(248,81,73,0.4); color: #f85149; }
.status.ok { color: #3fb950; }
main { padding: 0 24px 24px; }
section.chip { padding: 12px 0; }
h2 { margin: 0 0 10px; font-size: 14px; color: #e6b450; font-family: ui-monospace, monospace; }
/* Cells size to the chosen PNG dimensions so picking sm / md / lg
actually shows the tile at its real rendered size — that's the
point of the toggle. The grid wraps so lg lays out 1-2 per row,
md 3-4, sm 6-8. */
.row { display: grid; gap: 10px; }
.row.sm { grid-template-columns: repeat(auto-fit, minmax(160px, max-content)); }
.row.md { grid-template-columns: repeat(auto-fit, minmax(320px, max-content)); }
.row.lg { grid-template-columns: repeat(auto-fit, minmax(600px, max-content)); }
.cell { background: #161b22; border: 1px solid #30363d; border-radius: 4px; display: flex; flex-direction: column; cursor: pointer; transition: border-color 0.1s; }
.cell:hover { border-color: #00b8b0; }
.cell .label { padding: 4px 8px; font-size: 11px; color: #79c0ff; font-family: ui-monospace, monospace; border-bottom: 1px solid #30363d; background: #0d1117; display: flex; justify-content: space-between; align-items: center; }
.cell .label .dim { color: #7d8590; font-size: 10px; }
.cell .frame { padding: 6px; background: #000; display: flex; align-items: center; justify-content: center; }
/* Render the PNG at its natural pixel dimensions; CSS doesn't shrink
a 160×120 thumb up to 320×240 so the user actually sees the size
difference. */
.cell img { display: block; width: auto; height: auto; }
.empty { color: #7d8590; font-style: italic; font-size: 11px; padding: 60px 0; }
.sizes-toggle { margin: 8px 0 14px; display: inline-flex; gap: 4px; }
.sizes-toggle button { background: #161b22; border: 1px solid #30363d; color: #8b949e; padding: 4px 10px; font-size: 11px; font-family: ui-monospace, monospace; border-radius: 4px; cursor: pointer; }
.sizes-toggle button.active { background: rgba(0,184,176,0.15); border-color: rgba(0,184,176,0.3); color: #00e6dc; }
</style></head><body>
<header>
<h1>step2glb thumb playground</h1>
<span class="pill">/thumbnail-batch</span>
<span class="meta" id="meta">drop a .step file to render 6 orientations × 3 sizes in one shot</span>
</header>
<div class="drop" id="drop">
<strong>Drop a .step / .stp file here</strong>
<span>or click to choose</span>
<div class="hint">renders all 6 face-up orientations × 3 sizes (sm / md / lg) in a single service call</div>
<input type="file" id="file" accept=".step,.stp">
</div>
<div class="status" id="status"></div>
<main id="main"></main>
<script>
const drop = document.getElementById('drop');
const file = document.getElementById('file');
const status = document.getElementById('status');
const main = document.getElementById('main');
const meta = document.getElementById('meta');
let selectedSize = 'md';
drop.addEventListener('click', () => file.click());
file.addEventListener('change', (e) => { if (e.target.files[0]) handleFile(e.target.files[0]); });
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('drag'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', (e) => {
e.preventDefault(); drop.classList.remove('drag');
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
});
async function handleFile(f) {
setStatus(`rendering ${f.name} (${(f.size/1024).toFixed(1)} KB)…`, '');
main.innerHTML = '';
try {
const buf = await f.arrayBuffer();
const t0 = performance.now();
const r = await fetch('render', { method: 'POST', body: buf });
const json = await r.json();
const wallMs = Math.round(performance.now() - t0);
if (!json.ok) {
setStatus(`error: ${json.error || 'render failed'}`, 'err');
return;
}
render(f.name, json, wallMs);
setStatus(
`${json.thumbnailCount} thumbnails · service ${json.totalDurationMs} ms (load ${json.loadDurationMs} ms) · wall ${wallMs} ms`,
'ok'
);
} catch (e) {
setStatus(`error: ${e.message}`, 'err');
}
}
function setStatus(text, kind) {
status.textContent = text;
status.className = 'status show' + (kind ? ' ' + kind : '');
}
function render(filename, json, wallMs) {
const stem = filename.replace(/\.(step|stp)$/i, '');
const orients = ['asIs', 'zDown', 'yUp', 'yDown', 'xUp', 'xDown'];
const sizes = ['sm', 'md', 'lg'];
// Group thumbnails by size for size-toggled view
const byOrientSize = {};
for (const t of json.thumbnails) {
byOrientSize[t.upAxis + '/' + t.size] = t;
}
main.innerHTML = '';
// Size toggles
const sizesToggle = document.createElement('div');
sizesToggle.className = 'sizes-toggle';
sizesToggle.innerHTML = sizes.map(s =>
`<button data-size="${s}" class="${s === selectedSize ? 'active' : ''}">${s}</button>`
).join('');
sizesToggle.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
selectedSize = e.target.dataset.size;
render(filename, json, wallMs);
}
});
main.appendChild(sizesToggle);
// Chip section: all 6 orientations at the chosen size
const sec = document.createElement('section');
sec.className = 'chip';
const h2 = document.createElement('h2');
h2.textContent = `${stem} — ${selectedSize}`;
sec.appendChild(h2);
const row = document.createElement('div');
row.className = 'row ' + selectedSize;
for (const orient of orients) {
const t = byOrientSize[orient + '/' + selectedSize];
const cell = document.createElement('div');
cell.className = 'cell';
const label = document.createElement('div');
label.className = 'label';
label.innerHTML = `${orient}<span class="dim">${t ? t.width + '×' + t.height : '—'}</span>`;
cell.appendChild(label);
const frame = document.createElement('div');
frame.className = 'frame';
if (t) {
const img = document.createElement('img');
img.src = 'data:image/png;base64,' + t.dataB64;
img.alt = `${stem} ${orient}`;
img.title = `${stem}-3d-iso${suffix(orient)}${sizeSuffix(t.size)}.png — click to download`;
img.onclick = () => download(`${stem}-3d-iso${suffix(orient)}${sizeSuffix(t.size)}.png`, t.dataB64);
frame.appendChild(img);
} else {
frame.innerHTML = '<span class="empty">missing</span>';
}
cell.appendChild(frame);
row.appendChild(cell);
}
sec.appendChild(row);
main.appendChild(sec);
// bbox info
if (json.bboxMm) {
const bb = json.bboxMm;
const dx = (bb[0][1] - bb[0][0]).toFixed(2);
const dy = (bb[1][1] - bb[1][0]).toFixed(2);
const dz = (bb[2][1] - bb[2][0]).toFixed(2);
// 6 orientations × 3 sizes = 18 PNGs returned, 6 shown at a time
meta.textContent = `bbox ${dx} × ${dy} × ${dz} mm — pose=${json.pose} — 6 orientations × 3 sizes (showing ${selectedSize})`;
}
if (json.renderWarnings && json.renderWarnings.length > 0) {
const warn = document.createElement('div');
warn.className = 'status show err';
warn.style.margin = '12px 0';
warn.innerHTML = '<strong>render warnings:</strong><br>' + json.renderWarnings.map(w => `• ${w}`).join('<br>');
main.appendChild(warn);
}
}
function suffix(orient) {
return { asIs:'', zDown:'-zdown', yUp:'-yup', yDown:'-ydown', xUp:'-xup', xDown:'-xdown' }[orient] || '';
}
function sizeSuffix(size) {
return { md:'', sm:'-sm', lg:'-lg' }[size] || '';
}
function download(filename, b64) {
const a = document.createElement('a');
a.href = 'data:image/png;base64,' + b64;
a.download = filename;
a.click();
}
</script>
</body></html>