// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 = [``] .concat(F.datasets.map((d) => ``)); $("#datasets").innerHTML = chips.join(""); drawFamilies(); $("#ds-list").innerHTML = F.datasets.map((d) => `

${esc(d.label)} ${fmt(d.count)}

${esc(d.blurb)}

${fmt(d.articulated)} articulated · ${fmt(d.families.length)} families · ${fmt(d.glb)} in 3D${d.parts ? ` · ${fmt(d.parts)} parts` : ""}

${esc(d.repo)}
`).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 = `` + (d ? d.families.map(([raw, label, n]) => ``).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 }, () => `
`).join(""); $("#count").innerHTML = " "; 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 = `

No object matches. Try fewer words, or search by look.

`; const how = "containing"; $("#count").innerHTML = r.mode === "all" ? `${fmt(r.total)} objects` : r.mode === "meaning" ? `Closest renders to “${esc(S.q)}”, out of ${fmt(r.total)} objects` : `${fmt(r.total)} 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 ? `${c.parts} part${c.parts > 1 ? "s" : ""}` : `${c.loc} lines`]; if (c.joints) tags.push(`${c.joints} joint${c.joints > 1 ? "s" : ""}`); if (c.glb) tags.push(`3D`); return `
${esc(c.name)}${small ? "" : `
`}
${esc(c.name)}
${esc(S.labels[c.dataset] || c.dataset)}${c.part ? " part" : ""}${c.family ? ` · ${esc(c.family)}` : ""}
${small ? "" : `
${tags.join("")}
`}
`; } 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: ``, ext: ``, back: ``, }; async function showObject(id) { S.current = id; dropViewer(); const el = $("#object"); scrollTo(0, 0); el.innerHTML = `
`; bindBack(el); let o; try { o = await getObject(id); } catch (e) { if (e.status === 503) { await waitReady(); return showObject(id); } el.innerHTML = `

No object ${esc(id)}.

`; return bindBack(el); } document.title = `${o.name} · Sand3D Explorer`; const lic = o.license_status && o.license_status !== "ok" ? ` (source licence ${esc(o.license_status)})` : ""; const rows = [ ["Dataset", `${esc(o.repo)}`], ["Source", esc(o.source_label)], ...(o.assembly ? [["Assembly", `${esc(o.assembly.name)}`]] : []), ["Family", esc(o.family || "Unclassified")], ["Parts", o.parts ? fmt(o.parts) : "—"], ["Joints", o.joints ? `${o.joints}, declared in the program` : "None"], ["Entry", o.entry ? `${esc(o.entry)}()` : "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", `${esc(o.id)}`], ]; el.innerHTML = `
${o.glb ? `
` : ""}

${esc(o.dataset_label)}${o.part ? " · part" : ""}

${esc(o.name)}

${o.loc} lines of Python${o.joints ? ` · ${o.joints} joint${o.joints > 1 ? "s" : ""}` : ""}

${rows.map(([k, v]) => ``).join("")}
${k}${v}

The program

Python · sand3d ${esc(o.sand3d_version)} · ${o.loc} lines
`; 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 = `
${esc(o.name)}
`; 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", `The 3D view could not load (${esc(e.message || e)}): the render is shown instead`); }); $("#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 = `${esc(o.name)}${o.glb ? "" : `Render only: no 3D view for this object yet`}`; } } 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, 's3d'); const out = []; const open = []; let line = ""; const re = /(]*>)|(<\/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 + "".repeat(open.length)); line = open.join(""); } else line += m[4]; } out.push(line + "".repeat(open.length)); return out.map((l) => `${l || " "}`).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(); });