Thibault-SF's picture
Sand3D Explorer
d5a7700 verified
Raw History Blame Contribute Delete
20.2 kB
// Sand3D Explorer: the gallery, the search and the object page, on the Space's JSON API.
"use strict";
const $ = (s, el = document) => el.querySelector(s);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
const fmt = (n) => Number(n).toLocaleString("en-US").replace(/,/g, " ");
const PAGE = 60;
const S = {
facets: null, labels: {},
q: "", mode: "meaning", dataset: "", family: "", articulated: false, glb: false, parts: false,
offset: 0, total: 0, loading: false, seq: 0, done: false,
objects: new Map(), homeScroll: 0,
};
async function api(path) {
const r = await fetch(path, { credentials: "same-origin" });
if (!r.ok) { const e = new Error(`${r.status}`); e.status = r.status; e.body = await r.json().catch(() => ({})); throw e; }
return r.json();
}
// ------------------------------------------------------------------ boot
async function waitReady() {
const box = $("#loading");
for (;;) {
let st;
try { st = await api("/api/status"); } catch { st = { stage: "Connecting" }; }
if (st.ready) { box.hidden = true; return; }
box.hidden = false;
const n = st.loaded ? ` · ${fmt(st.loaded)} objects` : "";
$("#loading-stage").textContent = st.stage === "failed" ? `Failed: ${st.error}` : `${cap(st.stage)}${n}`;
await new Promise((r) => setTimeout(r, 1200));
}
}
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
async function boot() {
await waitReady();
S.facets = await api("/api/facets");
for (const d of S.facets.datasets) S.labels[d.key] = d.label;
readURL();
if (!S.facets.semantic) S.mode = "words";
drawFacets();
bind();
route();
}
function readURL() {
const p = new URLSearchParams(location.search);
S.q = p.get("q") || "";
S.mode = p.get("by") === "words" ? "words" : "meaning";
S.dataset = p.get("dataset") || "";
S.family = p.get("family") || "";
S.articulated = p.get("articulated") === "1";
S.glb = p.get("glb") === "1";
S.parts = p.get("parts") === "1";
}
function writeURL() {
const p = new URLSearchParams();
if (S.q) p.set("q", S.q);
if (S.q && S.mode === "words") p.set("by", "words");
if (S.dataset) p.set("dataset", S.dataset);
if (S.family) p.set("family", S.family);
if (S.articulated) p.set("articulated", "1");
if (S.glb) p.set("glb", "1");
if (S.parts) p.set("parts", "1");
const qs = p.toString();
history.replaceState(null, "", "/" + (qs ? "?" + qs : ""));
}
// ------------------------------------------------------------------ facets and controls
function drawFacets() {
const F = S.facets;
const art = F.datasets.reduce((a, d) => a + d.articulated, 0);
$("#stats").innerHTML = `${fmt(F.total)} objects · ${fmt(art)} articulated · ${fmt(F.glb)} viewable in 3D` +
(F.parts ? ` · ${fmt(F.parts)} parts on their own` : "");
const chips = [`<button class="chip" data-ds="" aria-pressed="${!S.dataset}">All <span class="n">${fmt(F.total)}</span></button>`]
.concat(F.datasets.map((d) => `<button class="chip" data-ds="${esc(d.key)}" aria-pressed="${S.dataset === d.key}">${esc(d.label)} <span class="n">${fmt(d.count)}</span></button>`));
$("#datasets").innerHTML = chips.join("");
drawFamilies();
$("#ds-list").innerHTML = F.datasets.map((d) => `
<div class="ds"><h3>${esc(d.label)} <span>${fmt(d.count)}</span></h3>
<p>${esc(d.blurb)}</p>
<p>${fmt(d.articulated)} articulated · ${fmt(d.families.length)} families · ${fmt(d.glb)} in 3D${d.parts ? ` · ${fmt(d.parts)} parts` : ""}</p>
<a href="https://huggingface.co/datasets/${esc(d.repo)}" target="_blank" rel="noopener">${esc(d.repo)}</a></div>`).join("");
$("#q").value = S.q;
$("#articulated").checked = S.articulated;
$("#glb").checked = S.glb;
drawParts();
drawMode();
}
function drawFamilies() {
const sel = $("#family");
const d = S.facets.datasets.find((x) => x.key === S.dataset);
sel.disabled = !d;
sel.innerHTML = `<option value="">${d ? "All families" : "Pick a dataset first"}</option>` +
(d ? d.families.map(([raw, label, n]) => `<option value="${esc(raw)}"${raw === S.family ? " selected" : ""}>${esc(label)} (${fmt(n)})</option>`).join("") : "");
}
function drawParts() {
const d = S.facets.datasets.find((x) => x.key === S.dataset);
const t = $("#parts-toggle");
t.hidden = !(d && d.parts);
if (t.hidden) S.parts = false;
else $("#parts-label").textContent = `Its parts (${fmt(d.parts)})`;
$("#parts").checked = S.parts;
}
function drawMode() {
for (const b of $("#mode").querySelectorAll("button")) {
b.setAttribute("aria-checked", String(b.dataset.mode === S.mode));
if (b.dataset.mode === "meaning" && !S.facets.semantic) { b.disabled = true; b.title = "Semantic search is not available"; }
}
}
function bind() {
let t;
$("#q").addEventListener("input", () => { clearTimeout(t); t = setTimeout(() => setQ($("#q").value), 280); });
$("#search-form").addEventListener("submit", (e) => { e.preventDefault(); clearTimeout(t); setQ($("#q").value, true); });
$("#mode").addEventListener("click", (e) => {
const b = e.target.closest("button"); if (!b || b.disabled) return;
S.mode = b.dataset.mode; drawMode(); refresh();
});
$("#datasets").addEventListener("click", (e) => {
const b = e.target.closest(".chip"); if (!b) return;
S.dataset = b.dataset.ds; S.family = "";
for (const c of $("#datasets").querySelectorAll(".chip")) c.setAttribute("aria-pressed", String(c === b));
drawFamilies(); drawParts(); refresh();
});
$("#parts").addEventListener("change", (e) => { S.parts = e.target.checked; S.family = ""; drawFamilies(); refresh(); });
$("#family").addEventListener("change", (e) => { S.family = e.target.value; refresh(); });
$("#articulated").addEventListener("change", (e) => { S.articulated = e.target.checked; refresh(); });
$("#glb").addEventListener("change", (e) => { S.glb = e.target.checked; refresh(); });
document.addEventListener("click", (e) => {
const a = e.target.closest("a[data-nav], a.card");
if (a && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); go(a.getAttribute("href")); }
const s = e.target.closest("[data-scroll]");
if (s) { e.preventDefault(); if (location.pathname !== "/") go("/"); setTimeout(() => $("#about-datasets").scrollIntoView(), 50); }
});
window.addEventListener("popstate", route);
new IntersectionObserver((es) => { if (es.some((x) => x.isIntersecting)) more(); }, { rootMargin: "800px" }).observe($("#more"));
$("#grid").addEventListener("mouseover", peek);
}
function setQ(v, force) {
v = v.trim();
if (v === S.q && !force) return;
S.q = v; refresh();
if (v) scrollTo({ top: $("#toolbar").offsetTop - $("#top").offsetHeight });
}
// ------------------------------------------------------------------ routing
function go(href) {
S.fromHome = location.pathname === "/";
if (S.fromHome) { S.homeScroll = scrollY; S.homeURL = "/" + location.search; }
history.pushState(null, "", href);
route();
}
function route() {
const m = location.pathname.match(/^\/o\/(.+)$/);
if (!m) { S.current = null; dropViewer(); }
if (m) { $("#home").hidden = true; $("#object").hidden = false; showObject(decodeURIComponent(m[1])); return; }
$("#object").hidden = true; $("#object").innerHTML = "";
$("#home").hidden = false;
document.title = "Sand3D Explorer · Sandflow";
const before = JSON.stringify([S.q, S.mode, S.dataset, S.family, S.articulated, S.glb, S.parts]);
readURL(); if (!S.facets.semantic) S.mode = "words";
const after = JSON.stringify([S.q, S.mode, S.dataset, S.family, S.articulated, S.glb, S.parts]);
if (!$("#grid").children.length || before !== after) { drawFacets(); refresh(); }
else requestAnimationFrame(() => scrollTo(0, S.homeScroll));
}
// ------------------------------------------------------------------ gallery
function refresh() {
writeURL();
S.offset = 0; S.total = 0; S.done = false; S.seq++;
$("#grid").innerHTML = Array.from({ length: 12 }, () => `<div class="skeleton"></div>`).join("");
$("#count").innerHTML = "&nbsp;";
more(true);
}
async function more(first) {
if ((S.loading && !first) || S.done) return;
S.loading = true;
const seq = S.seq;
const p = new URLSearchParams({ q: S.q, mode: S.mode, dataset: S.dataset, family: S.family,
articulated: S.articulated, glb: S.glb, parts: S.parts, offset: S.offset, limit: PAGE });
try {
const r = await api("/api/search?" + p);
if (seq !== S.seq) return;
if (first) $("#grid").innerHTML = "";
S.total = r.total; S.offset += r.items.length; S.done = S.offset >= r.total;
$("#grid").insertAdjacentHTML("beforeend", r.items.map((c) => card(c)).join(""));
if (!r.total) $("#grid").innerHTML = `<p class="empty"><b>No object matches.</b> Try fewer words, or search by look.</p>`;
const how = "containing";
$("#count").innerHTML = r.mode === "all" ? `<b>${fmt(r.total)}</b> objects`
: r.mode === "meaning" ? `Closest renders to “${esc(S.q)}”, out of <b>${fmt(r.total)}</b> objects`
: `<b>${fmt(r.total)}</b> objects ${how} “${esc(S.q)}”`;
$("#more").textContent = S.done ? (r.total ? "End of results" : "") : "Loading more…";
} catch (e) {
if (e.status === 503) { await waitReady(); S.loading = false; return more(first); }
$("#more").textContent = "Could not load: " + e.message;
} finally {
if (seq === S.seq) S.loading = false;
}
}
function card(c, small) {
const tags = [c.parts ? `<span class="tag">${c.parts} part${c.parts > 1 ? "s" : ""}</span>` : `<span class="tag">${c.loc} lines</span>`];
if (c.joints) tags.push(`<span class="tag">${c.joints} joint${c.joints > 1 ? "s" : ""}</span>`);
if (c.glb) tags.push(`<span class="tag violet">3D</span>`);
return `<a class="card" href="/o/${encodeURIComponent(c.id)}" data-id="${esc(c.id)}">
<div class="thumb"><img loading="lazy" decoding="async" src="/r/${encodeURIComponent(c.id)}.png" alt="${esc(c.name)}">${small ? "" : `<pre class="code-peek"></pre>`}</div>
<div class="body"><div class="name" title="${esc(c.name)}">${esc(c.name)}</div>
<div class="meta">${esc(S.labels[c.dataset] || c.dataset)}${c.part ? " part" : ""}${c.family ? ` · ${esc(c.family)}` : ""}</div>
${small ? "" : `<div class="tags">${tags.join("")}</div>`}</div></a>`;
}
let peekTimer;
function peek(e) {
const a = e.target.closest(".card"); if (!a) return;
const pre = $(".code-peek", a); if (!pre || pre.classList.contains("ready")) return;
clearTimeout(peekTimer);
peekTimer = setTimeout(async () => {
const o = await getObject(a.dataset.id).catch(() => null);
if (!o) return;
pre.textContent = o.program.split("\n").filter((l) => l.trim()).slice(0, 22).join("\n");
pre.classList.add("ready");
}, 220);
}
async function getObject(id) {
if (!S.objects.has(id)) S.objects.set(id, api("/api/object/" + encodeURIComponent(id)));
try { return await S.objects.get(id); } catch (e) { S.objects.delete(id); throw e; }
}
// ------------------------------------------------------------------ object page
const ICON = {
copy: `<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></svg>`,
ext: `<svg viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>`,
back: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M15 18l-6-6 6-6"/></svg>`,
};
async function showObject(id) {
S.current = id;
dropViewer();
const el = $("#object");
scrollTo(0, 0);
el.innerHTML = `<button class="back" data-back>${ICON.back} Back to the gallery</button><div class="skeleton" style="aspect-ratio:3"></div>`;
bindBack(el);
let o;
try { o = await getObject(id); }
catch (e) {
if (e.status === 503) { await waitReady(); return showObject(id); }
el.innerHTML = `<button class="back" data-back>${ICON.back} Back to the gallery</button><p class="empty"><b>No object ${esc(id)}.</b></p>`;
return bindBack(el);
}
document.title = `${o.name} · Sand3D Explorer`;
const lic = o.license_status && o.license_status !== "ok" ? ` <span class="pending">(source licence ${esc(o.license_status)})</span>` : "";
const rows = [
["Dataset", `<a href="https://huggingface.co/datasets/${esc(o.repo)}" target="_blank" rel="noopener">${esc(o.repo)}</a>`],
["Source", esc(o.source_label)],
...(o.assembly ? [["Assembly", `<a href="/o/${encodeURIComponent(o.assembly.id)}" data-nav>${esc(o.assembly.name)}</a>`]] : []),
["Family", esc(o.family || "Unclassified")],
["Parts", o.parts ? fmt(o.parts) : "—"],
["Joints", o.joints ? `${o.joints}, declared in the program` : "None"],
["Entry", o.entry ? `<span class="mono">${esc(o.entry)}()</span>` : "The program builds the object as it runs"],
["Split", esc(o.split === "val" ? "validation" : o.split)],
["Fidelity", o.f_score != null ? `F-score ${Number(o.f_score).toFixed(3)} against the source object` : "—"],
["Licence", `${esc(o.license)}${o.source_license && o.source_license !== o.license ? `, source under ${esc(o.source_license)}` : ""}${lic}`],
["Attribution", esc(o.attribution)],
["Library", `sand3d ${esc(o.sand3d_version)}`],
["Id", `<span class="mono">${esc(o.id)}</span>`],
];
el.innerHTML = `
<button class="back" data-back>${ICON.back} Back to the gallery</button>
<div class="obj-head">
<div class="stage" id="stage">
${o.glb ? `<div class="stage-tabs"><button data-view="3d" aria-pressed="true">3D</button><button data-view="render" aria-pressed="false">Render</button></div>` : ""}
<div id="stage-body"></div>
</div>
<div class="obj-info">
<p class="repere">${esc(o.dataset_label)}${o.part ? " · part" : ""}</p>
<h1>${esc(o.name)}</h1>
<p class="sub">${o.loc} lines of Python${o.joints ? ` · ${o.joints} joint${o.joints > 1 ? "s" : ""}` : ""}</p>
<table class="facts">${rows.map(([k, v]) => `<tr><th>${k}</th><td>${v}</td></tr>`).join("")}</table>
<div class="actions">
<a class="btn" href="${esc(o.row_url)}" target="_blank" rel="noopener">${ICON.ext} Row on the Hub</a>
<a class="btn" href="${esc(o.usda_url)}" target="_blank" rel="noopener">${ICON.ext} USD file</a>
<a class="btn" href="${esc(o.library_url)}" target="_blank" rel="noopener">${ICON.ext} sand3d on GitHub</a>
</div>
</div>
</div>
<section class="program">
<div class="program-head"><h2>The program</h2><span class="lines">Python · sand3d ${esc(o.sand3d_version)} · ${o.loc} lines</span></div>
<div class="code"><button class="btn copy" id="copy">${ICON.copy} Copy</button><pre><code id="code"></code></pre></div>
</section>
<section class="similar" id="similar" hidden>
<h2>Similar objects</h2><p>The nearest renders, across every dataset.</p>
<div class="grid" id="similar-grid"></div>
</section>`;
bindBack(el);
drawStage(o, o.glb ? "3d" : "render");
for (const b of el.querySelectorAll(".stage-tabs button")) b.addEventListener("click", () => {
for (const x of el.querySelectorAll(".stage-tabs button")) x.setAttribute("aria-pressed", String(x === b));
drawStage(o, b.dataset.view);
});
$("#code").innerHTML = highlight(o.program);
$("#copy").addEventListener("click", () => copy(o.program, $("#copy")));
if (o.has_similar) {
const r = await api(`/api/similar/${encodeURIComponent(o.id)}?limit=12`).catch(() => ({ items: [] }));
if (S.current === id) {
$("#similar-grid").innerHTML = r.items.map((c) => card(c, true)).join("");
$("#similar").hidden = !r.items.length;
}
}
}
function bindBack(el) {
const b = $("[data-back]", el);
if (b) b.addEventListener("click", () => { if (S.fromHome) history.back(); else go(S.homeURL || "/"); });
}
let viewer = null;
function dropViewer() { if (viewer) { viewer.dispose(); viewer = null; } }
function drawStage(o, view) {
const body = $("#stage-body");
const img = `/r/${encodeURIComponent(o.id)}.png`;
dropViewer();
if (view === "3d" && o.glb) {
body.innerHTML = `<div class="three" id="three"><img class="still poster" src="${img}" alt="${esc(o.name)}"><div class="three-loading"><div></div></div></div>
<div class="three-tip" id="three-tip" hidden></div>
<div class="three-bar" id="three-bar" hidden>
<div class="seg" id="three-mode" role="radiogroup" aria-label="View">
<button data-m="parts" aria-checked="true">Parts</button><button data-m="materials" aria-checked="false">Materials</button><button data-m="mesh" aria-checked="false">Mesh</button>
</div>
<label class="explode"><span>Explode</span><input type="range" id="explode" min="0" max="1" step="0.01" value="0"></label>
<span class="three-info" id="three-info"></span>
</div>`;
const host = $("#three");
import("/static/viewer.js?v=4").then(({ Viewer }) => {
if (S.current !== o.id) return;
viewer = new Viewer(host, {
onReady: ({ parts }) => {
host.classList.add("ready");
$("#three-bar").hidden = false;
$("#three-info").textContent = `${parts} part${parts > 1 ? "s" : ""}${o.joints ? ` · ${o.joints} joint${o.joints > 1 ? "s" : ""}` : ""} · drag to turn`;
},
onHover: (h) => {
const tip = $("#three-tip"); if (!tip) return;
if (!h) { tip.hidden = true; return; }
tip.hidden = false; tip.textContent = h.name;
tip.style.transform = `translate(${h.x + 14}px, ${h.y + 12}px)`;
},
});
return viewer.load(o.glb_url);
}).catch((e) => {
host.classList.add("failed");
$("#three-info") && ($("#three-info").textContent = "");
body.insertAdjacentHTML("beforeend", `<span class="stage-hint">The 3D view could not load (${esc(e.message || e)}): the render is shown instead</span>`);
});
$("#three-mode").addEventListener("click", (e) => {
const b = e.target.closest("button"); if (!b || !viewer) return;
for (const x of $("#three-mode").querySelectorAll("button")) x.setAttribute("aria-checked", String(x === b));
viewer.setMode(b.dataset.m);
});
$("#explode").addEventListener("input", (e) => viewer && viewer.explode(Number(e.target.value)));
} else {
body.innerHTML = `<img class="still" src="${img}" alt="${esc(o.name)}">${o.glb ? "" : `<span class="stage-hint">Render only: no 3D view for this object yet</span>`}`;
}
}
function copy(text, btn) {
const ok = () => { btn.classList.add("done"); btn.innerHTML = `${ICON.copy} Copied`; setTimeout(() => { btn.classList.remove("done"); btn.innerHTML = `${ICON.copy} Copy`; }, 1600); };
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(ok);
else { const t = document.createElement("textarea"); t.value = text; document.body.appendChild(t); t.select(); document.execCommand("copy"); t.remove(); ok(); }
}
// Highlighted HTML split into one element per line, spans reopened across line breaks.
function highlight(src) {
let html = esc(src);
if (window.hljs) html = hljs.highlight(src, { language: "python", ignoreIllegals: true }).value;
html = html.replace(/\bs3d\b/g, '<span class="s3d">s3d</span>');
const out = []; const open = []; let line = "";
const re = /(<span[^>]*>)|(<\/span>)|(\n)|([^<\n]+|<)/g; let m;
while ((m = re.exec(html))) {
if (m[1]) { open.push(m[1]); line += m[1]; }
else if (m[2]) { open.pop(); line += m[2]; }
else if (m[3]) { out.push(line + "</span>".repeat(open.length)); line = open.join(""); }
else line += m[4];
}
out.push(line + "</span>".repeat(open.length));
return out.map((l) => `<span class="ln">${l || " "}</span>`).join("");
}
// the toolbar sticks right under the header, whatever height the header wraps to
function headHeight() { document.documentElement.style.setProperty("--head-h", $("#top").offsetHeight + "px"); }
window.addEventListener("resize", headHeight);
document.addEventListener("DOMContentLoaded", () => { headHeight(); boot(); });