Add 3D/Schematic/Footprint visualization tabs + connector board-edge marker

This commit is contained in:
gator 2026-08-15 00:17:53 +00:00
parent cf903e29d7
commit 706e50cdf5
3 changed files with 84 additions and 31 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,52 @@
// part.js — part detail: interactive PBR viewer + full curated record (specs, pins, spice, downloads). // part.js — part detail. Left: a tabbed visualization (real 3D model / schematic symbol / footprint),
// all built from the curated data. Right: the full record (specs, pins, SPICE, downloads).
import { mountViewer } from '/assets/viewer.js'; import { mountViewer } from '/assets/viewer.js';
const esc = (s) => (s == null ? '' : String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))); const esc = (s) => (s == null ? '' : String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])));
const id = new URLSearchParams(location.search).get('id'); const id = new URLSearchParams(location.search).get('id');
function dl(name, text, mime = 'text/plain') { function dl(name, text, mime = 'text/plain') { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type: mime })); a.download = name; a.click(); }
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type: mime })); function spendCredit() { let c = parseInt(localStorage.getItem('ppcb_credits') || '5', 10); if (c <= 0) { alert('Out of download credits — contribute a curated part to earn 5 more.'); return false; } localStorage.setItem('ppcb_credits', --c); document.getElementById('cred-n').textContent = c; return true; }
a.download = name; a.click();
// ---- footprint SVG from real pad geometry (top-down, copper pads, pad numbers, outline) ----
function footprintSVG(p) {
const pads = p.footprint_pads || []; if (!pads.length) return '<div class="hint">no footprint data</div>';
let minx = 1e9, miny = 1e9, maxx = -1e9, maxy = -1e9;
for (const d of pads) { minx = Math.min(minx, d.x - d.w / 2); maxx = Math.max(maxx, d.x + d.w / 2); miny = Math.min(miny, d.y - d.h / 2); maxy = Math.max(maxy, d.y + d.h / 2); }
const m = Math.max(maxx - minx, maxy - miny) * 0.14 + 0.4;
minx -= m; miny -= m; maxx += m; maxy += m;
const vb = `${minx} ${miny} ${maxx - minx} ${maxy - miny}`;
const fs = Math.max((maxx - minx), (maxy - miny)) * 0.035;
let s = `<svg viewBox="${vb}" preserveAspectRatio="xMidYMid meet" font-family="monospace">`;
s += `<rect x="${minx + m * 0.4}" y="${miny + m * 0.4}" width="${maxx - minx - m * 0.8}" height="${maxy - miny - m * 0.8}" fill="none" stroke="#bcbcb6" stroke-width="${fs * 0.12}" stroke-dasharray="${fs * 0.4} ${fs * 0.3}"/>`;
for (const d of pads) {
const round = (d.shape || '').toUpperCase().includes('OVAL') || (d.shape || '').toUpperCase().includes('ROUND');
s += `<rect x="${d.x - d.w / 2}" y="${d.y - d.h / 2}" width="${d.w}" height="${d.h}" rx="${round ? Math.min(d.w, d.h) / 2 : fs * 0.06}" fill="#c47a3d" stroke="#8a531f" stroke-width="${fs * 0.05}"/>`;
if (d.n != null) s += `<text x="${d.x}" y="${d.y + fs * 0.35}" font-size="${fs}" fill="#2a1a0c" text-anchor="middle" font-weight="600">${esc(d.n)}</text>`;
} }
function spendCredit() { if (p.class === 'connector') { // board-edge marker on the mating side
let c = parseInt(localStorage.getItem('ppcb_credits') || '5', 10); s += `<line x1="${minx + m * 0.4}" y1="${maxy - m * 0.5}" x2="${maxx - m * 0.4}" y2="${maxy - m * 0.5}" stroke="#2f8f2f" stroke-width="${fs * 0.18}"/>`;
if (c <= 0) { alert('Out of download credits — contribute a curated part to earn 5 more.'); return false; } s += `<text x="${(minx + maxx) / 2}" y="${maxy - m * 0.5 - fs * 0.4}" font-size="${fs * 0.9}" fill="#2f8f2f" text-anchor="middle">BOARD EDGE</text>`;
localStorage.setItem('ppcb_credits', --c); document.getElementById('cred-n').textContent = c; return true; }
return s + '</svg>';
}
// ---- schematic symbol from pins (labelled box; pins split L/R) ----
function schematicSVG(p) {
const pins = p.pins || []; if (!pins.length) return '<div class="hint">no pin data</div>';
const L = pins.filter((_, i) => i % 2 === 0), R = pins.filter((_, i) => i % 2 === 1);
const rows = Math.max(L.length, R.length), rh = 26, bw = 150, bx = 120, by = 40, bh = rows * rh + 20;
const W = bx + bw + 120, H = by + bh + 40;
let s = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" font-family="monospace">`;
s += `<rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="#fdfdfb" stroke="#2a2a2a" stroke-width="2"/>`;
s += `<text x="${bx + bw / 2}" y="${by - 14}" font-size="15" text-anchor="middle" fill="#111" font-weight="700">${esc(p.mpn || p.id)}</text>`;
const draw = (arr, side) => arr.forEach((pin, i) => {
const y = by + 20 + i * rh, x0 = side === 'L' ? bx : bx + bw, x1 = side === 'L' ? bx - 34 : bx + bw + 34;
s += `<line x1="${x0}" y1="${y}" x2="${x1}" y2="${y}" stroke="#2a2a2a" stroke-width="2"/>`;
s += `<text x="${side === 'L' ? x1 - 5 : x1 + 5}" y="${y - 5}" font-size="11" fill="#666" text-anchor="${side === 'L' ? 'end' : 'start'}">${esc(pin.pin ?? '')}</text>`;
s += `<text x="${side === 'L' ? x0 + 8 : x0 - 8}" y="${y + 4}" font-size="12" fill="#111" text-anchor="${side === 'L' ? 'start' : 'end'}" font-weight="600">${esc(pin.name || '')}</text>`;
});
draw(L, 'L'); draw(R, 'R');
return s + '</svg>';
} }
(async () => { (async () => {
@ -18,55 +54,59 @@ function spendCredit() {
const p = parts.find((x) => x.id === id); const p = parts.find((x) => x.id === id);
const root = document.getElementById('root'); const root = document.getElementById('root');
document.getElementById('cred-n').textContent = localStorage.getItem('ppcb_credits') || '5'; document.getElementById('cred-n').textContent = localStorage.getItem('ppcb_credits') || '5';
if (!p) { root.innerHTML = '<div class="empty">Part not found. <a href="/">Back to catalog</a></div>'; return; } if (!p) { root.innerHTML = '<div class="empty">Part not found. <a href="/">Back to library</a></div>'; return; }
document.title = `${p.mpn || p.id} — PromptPCB Parts`; document.title = `${p.mpn || p.id} — PromptPCB Parts`;
const params = (p.params || []).map((x) => `<tr><td>${esc(x.name)}</td><td class="v">${esc(x.value)} ${esc(x.unit || '')}${x.note ? `<span class="cond">${esc(x.note)}</span>` : ''}</td></tr>`).join(''); const params = (p.params || []).map((x) => `<tr><td>${esc(x.name)}</td><td class="v">${esc(x.value)} ${esc(x.unit || '')}${x.note ? `<span class="cond">${esc(x.note)}</span>` : ''}</td></tr>`).join('');
const pins = (p.pins || []).map((x) => `<div class="pin"><span class="n">${esc(x.pin ?? '')}</span>${esc(x.name || '')}<span class="t">${esc(x.type || '')}</span></div>`).join(''); const pinsList = (p.pins || []).map((x) => `<div class="pin"><span class="n">${esc(x.pin ?? '')}</span>${esc(x.name || '')}<span class="t">${esc(x.type || '')}</span></div>`).join('');
const ucs = (p.use_cases || []).map((u) => `<li>${esc(u)}</li>`).join(''); const ucs = (p.use_cases || []).map((u) => `<li>${esc(u)}</li>`).join('');
const tagList = Object.entries(p.tags || {}).filter(([, v]) => v != null && v !== '' && !Array.isArray(v)).map(([k, v]) => `<span class="tag"><b>${esc(k)}</b> ${esc(v)}</span>`).join(''); const tagList = Object.entries(p.tags || {}).filter(([, v]) => v != null && v !== '' && !Array.isArray(v) && typeof v !== 'boolean').map(([k, v]) => `<span class="tag"><b>${esc(k)}</b> ${esc(v)}</span>`).join('');
const kr = ((p.tags || {}).key_ratings || []).map((r) => `<span class="tag">${esc(r)}</span>`).join(''); const kr = ((p.tags || {}).key_ratings || []).map((r) => `<span class="tag">${esc(r)}</span>`).join('');
const ser = p.series ? `<div class="tags" style="margin-top:8px">${Object.entries(p.series).filter(([k]) => k !== 'mesh').map(([k, v]) => `<span class="tag"><b>${esc(k)}</b> ${esc(v)}</span>`).join('')}</div>` : ''; const ser = p.series ? `<div class="tags" style="margin-top:8px">${Object.entries(p.series).filter(([k]) => k !== 'mesh').map(([k, v]) => `<span class="tag"><b>${esc(k)}</b> ${esc(v)}</span>`).join('')}</div>` : '';
const edge = p.board_edge ? `<div class="viznote"><b>Board edge:</b> ${esc(p.board_edge)}.</div>` : '';
root.innerHTML = `<div class="detail"> root.innerHTML = `<div class="detail">
<div><div class="viz">
<div class="viztabs"><button data-tab="d3" class="on">3D model</button><button data-tab="sch">Schematic</button><button data-tab="fp">Footprint</button></div>
<div class="vizpanel" data-v="d3"><canvas id="cv"></canvas><span class="hint">real manufacturer model · drag to orbit</span></div>
<div class="vizpanel" data-v="sch" hidden>${schematicSVG(p)}</div>
<div class="vizpanel" data-v="fp" hidden>${footprintSVG(p)}<span class="hint">${esc(p.package || '')}</span></div>
${edge}
</div></div>
<div> <div>
<div class="viewer"><canvas id="cv"></canvas> <div class="dclass">${esc(p.class)}${p.type === 'series' ? ' · series' : ''}</div>
<div class="vbar"><span class="vtag">PBR · drag to orbit</span><span class="vtag">${esc(p.package || (p.series && p.series.package) || '')}</span><span class="vtag">${(p.pins || []).length} pins</span></div>
</div>
</div>
<div>
<div style="font-family:var(--mono);font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:1px">${esc(p.class)}${p.type === 'series' ? ' · series' : ''}</div>
<h1 class="dh1">${esc((p.role || p.summary || '').split('—')[0])}</h1> <h1 class="dh1">${esc((p.role || p.summary || '').split('—')[0])}</h1>
<div class="dmpn">${esc(p.mpn || p.id)} · ${esc(p.manufacturer || '')} · ${esc(p.lcsc || '')}</div> <div class="dmpn">${esc(p.mpn || p.id)} · ${esc(p.manufacturer || '')} · ${esc(p.lcsc || '')}</div>
<p style="color:var(--dim);line-height:1.6;margin:0 0 6px">${esc(p.summary)}</p> <p style="color:var(--dim);line-height:1.6;margin:0 0 8px">${esc(p.summary)}</p>
<div class="dactions"> <div class="dactions">
<button class="btn" id="dl-all">↓ Download files</button> <button class="btn" id="dl-all">↓ Download files</button>
<a class="btn ghost" href="${esc(p.datasheet_url)}" target="_blank" rel="noopener">Datasheet ↗</a> <a class="btn ghost" href="${esc(p.datasheet_url)}" target="_blank" rel="noopener">Datasheet ↗</a>
<button class="btn ghost" id="lib">+ My library</button> <button class="btn ghost" id="lib">+ My library</button>
</div> </div>
<div class="assets"> <div class="assets"><span><span class="ok">✓</span> 3D model</span><span><span class="ok">✓</span> Schematic</span><span><span class="ok">✓</span> Footprint</span><span><span class="ok">✓</span> SPICE</span><span><span class="ok">✓</span> ${(p.pins || []).length} pins</span></div>
<span class="asset"><span class="ok">✓</span> 3D model</span>
<span class="asset"><span class="ok">✓</span> SPICE</span>
<span class="asset"><span class="ok">✓</span> Datasheet</span>
<span class="asset"><span class="ok">✓</span> Pin map (${(p.pins || []).length})</span>
<span class="asset"><span class="ok">✓</span> Verified</span>
</div>
${p.when_to_use ? `<div class="section"><h3>When to use</h3><p style="color:var(--text);line-height:1.6;margin:0">${esc(p.when_to_use)}</p></div>` : ''} ${p.when_to_use ? `<div class="section"><h3>When to use</h3><p style="color:var(--text);line-height:1.6;margin:0">${esc(p.when_to_use)}</p></div>` : ''}
${ucs ? `<div class="section"><h3>What it's used for</h3><ul class="uc">${ucs}</ul></div>` : ''} ${ucs ? `<div class="section"><h3>What it's used for</h3><ul class="uc">${ucs}</ul></div>` : ''}
${params ? `<div class="section"><h3>Specifications</h3><table class="ptable">${params}</table></div>` : ''} ${params ? `<div class="section"><h3>Specifications</h3><table class="ptable">${params}</table></div>` : ''}
${ser} ${ser}
${pins ? `<div class="section"><h3>Pins &amp; pads · aligned to footprint</h3><div class="pins">${pins}</div></div>` : ''} ${pinsList ? `<div class="section"><h3>Pins &amp; pads</h3><div class="pins">${pinsList}</div></div>` : ''}
${(tagList || kr) ? `<div class="section"><h3>Requirement tags</h3><div class="tags">${kr}${tagList}</div></div>` : ''} ${(tagList || kr) ? `<div class="section"><h3>Requirement tags</h3><div class="tags">${kr}${tagList}</div></div>` : ''}
${p.spice ? `<div class="section"><h3>SPICE model</h3><pre class="spice">${esc(p.spice)}</pre></div>` : ''} ${p.spice ? `<div class="section"><h3>SPICE model</h3><pre class="spice">${esc(p.spice)}</pre></div>` : ''}
</div> </div>
</div>`; </div>`;
mountViewer(document.getElementById('cv'), p.model_3d); // tab switching (lazy-mount the 3D viewer on first show)
let mounted = false;
const show = (tab) => {
root.querySelectorAll('.viztabs button').forEach((b) => b.classList.toggle('on', b.dataset.tab === tab));
root.querySelectorAll('.vizpanel').forEach((v) => (v.hidden = v.dataset.v !== tab));
if (tab === 'd3' && !mounted) { mounted = true; mountViewer(document.getElementById('cv'), p.model_3d); }
};
root.querySelectorAll('.viztabs button').forEach((b) => (b.onclick = () => show(b.dataset.tab)));
show('d3');
document.getElementById('dl-all').onclick = async () => { document.getElementById('dl-all').onclick = async () => {
if (!spendCredit()) return; if (!spendCredit()) return;
const obj = await (await fetch(p.model_3d)).text(); try { const wrl = await (await fetch(p.model_3d)).text(); dl(`${p.id}.wrl`, wrl); } catch {}
dl(`${p.id}.obj`, obj);
dl(`${p.id}.cir`, `* ${p.mpn} SPICE model — PromptPCB Parts\n${p.spice || ''}\n`); dl(`${p.id}.cir`, `* ${p.mpn} SPICE model — PromptPCB Parts\n${p.spice || ''}\n`);
dl(`${p.id}.json`, JSON.stringify(p, null, 2), 'application/json'); dl(`${p.id}.json`, JSON.stringify(p, null, 2), 'application/json');
}; };

View file

@ -139,3 +139,16 @@ pre.spice{background:#08080a;border:1px solid var(--line);border-radius:3px;padd
body{animation:pagein .5s ease both} body{animation:pagein .5s ease both}
@keyframes pagein{from{opacity:0}to{opacity:1}} @keyframes pagein{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.reveal{opacity:1;transform:none;transition:none}body{animation:none}} @media(prefers-reduced-motion:reduce){.reveal{opacity:1;transform:none;transition:none}body{animation:none}}
/* visualization tabs (3D / schematic / footprint) */
.viz{position:sticky;top:92px}
.viztabs{display:flex;margin-bottom:12px;border:1px solid var(--line2);border-radius:2px;width:fit-content;overflow:hidden}
.viztabs button{background:transparent;border:none;border-right:1px solid var(--line2);color:var(--dim);font-family:var(--mono);font-size:13px;padding:9px 20px;cursor:pointer;transition:.12s}
.viztabs button:last-child{border-right:none}
.viztabs button.on{background:var(--acc);color:var(--acc-ink);font-weight:600}
.vizpanel{height:min(60vh,520px);background:#f3f3f0;border:1px solid var(--line2);border-radius:3px;overflow:hidden;position:relative;display:flex;align-items:center;justify-content:center}
.vizpanel canvas{width:100%;height:100%;display:block}
.vizpanel svg{width:100%;height:100%}
.vizpanel .hint{position:absolute;bottom:12px;left:12px;font-family:var(--mono);font-size:11px;color:#555}
.viznote{font-family:var(--mono);font-size:12px;color:var(--dim);margin-top:12px;line-height:1.5}
.viznote b{color:var(--acc)}