- Home
- Skills
- Frontend & Web Apps
- Colorfigures Pro: 3D Interactive HTML Generator
More screenshots
Works with the AI Agents you already use
Colorfigures Pro: 3D Interactive HTML Generator
Create interactive, hand-inked isometric 3D illustrations and machine cutaways as self-contained HTML pages.
$9.99
Colorfigures Pro: 3D Interactive HTML Generator
Example session with this skill installed
Make a Colorfigures interactive HTML page of a retro espresso machine. It should have a working portafilter, a steam wand, and a brew button. In the see-through view, label the boiler, pump, heating element, and water tank, showing the water flow when the brew button is pressed.
- Read your context and instructions
- Compiled the colorfigures pro
- Generated the image
I’ll build the retro espresso machine figure. First I’m checking for the Colorfigures engine and the reference examples.
The machine now renders and the engine runs without errors. I’m tuning the framing and zoom, then I’ll check the see-through view and the brew sequence.
I’ve built the retro espresso machine as a single HTML page, retro-espresso-machine.html. The Colorfigures engine files (core.js and build.py) weren’t in the workspace, so I wrote a standalone version in the same style, with a mini isometric engine of my own. I checked it in headless Chrome from the front, back and phone width, closed and see-through, plus a brew, a steam and a refill run. I didn’t test the top-down angle, and I didn’t check the page for errors during a steam or refill run.
Buttons
Portafilter: locks it into the group head or releases it. Tapping the portafilter itself does the same.
Add coffee: if the portafilter is locked it releases it first, then fills the basket with grounds and tamps them. A spent puck is replaced.
Brew: locks the portafilter if needed, then pulls a shot. The red button on the machine and the space bar do the same. If there’s no coffee, or the tank is empty, it tells you.
Steam: opens the steam valve, steams the milk pitcher for a few seconds, then closes. The red knob on the side does the same.
Refill tank: the lid slides aside and a jug pours water in.
See-through: fades the case so you can see inside.
Labels: names the parts. In see-through it labels water tank, pump, boiler, heating element, steam valve, group head, brew switch and portafilter. On a phone the labels become numbered badges with a list underneath.
Turntable / Front view: auto-rotate and reset the camera. Dragging turns the camera, and +/- or the mouse wheel zooms.
Water flow in see-through: when you press Brew, moving dashed lines trace the water from the tank through the pump, boiler, group head and coffee puck. The pump vibrates, the gauge climbs to 9 bar, and the coffee streams into the cup. The heating element glows when the boiler temperature drops, after a brew or after steaming.
Known flaws
The tank water box is semi-transparent so it doesn’t hide the parts behind it.
The water-flow lines are drawn flat over the see-through view, so a line can show on top of a part that should hide it.
A few small parts can overlap oddly from some angles. That includes the back-wall cord, which I only checked in the back view.
Retro espresso machine
Code
·HTML
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Retro Espresso Machine</title>
<style>
:root{--ink:#1d1a2b;--paper:#f4ead3;--accent:#e2583c;--pill:#fffaf0}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);font-family:ui-rounded,"Trebuchet MS",system-ui,sans-serif;overflow:hidden}
#paper{position:fixed;inset:0;background:
radial-gradient(ellipse at 50% 40%,#f9f1dc 0%,#f1e5c8 70%,#e9dbb8 100%)}
#paper:after{content:"";position:absolute;inset:0;opacity:.35;mix-blend-mode:multiply;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' seed='4'/><feColorMatrix values='0 0 0 0 .55 0 0 0 0 .45 0 0 0 0 .3 0 0 0 .5 0'/></filter><rect width='220' height='220' filter='url(%23n)'/></svg>")}
#stage{position:fixed;left:0;right:0;top:0;bottom:0;display:flex;flex-direction:column}
#cvwrap{position:relative;flex:1;min-height:0}
canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:grab}
canvas.drag{cursor:grabbing}
#top{position:absolute;left:10px;top:10px;display:flex;gap:6px;flex-wrap:wrap;z-index:3;max-width:70%}
#zoom{position:absolute;right:10px;bottom:10px;display:flex;flex-direction:column;gap:6px;z-index:3}
#stats{position:absolute;right:12px;top:10px;z-index:3;text-align:right;font-size:12px;line-height:1.5;letter-spacing:.02em;font-weight:700}
#stats div span{display:inline-block;min-width:3.2em;padding:1px 7px;margin-left:6px;background:var(--pill);border:2px solid var(--ink);border-radius:9px;box-shadow:2px 2px 0 var(--ink)}
#flash{position:absolute;left:50%;top:56px;transform:translate(-50%,-8px);z-index:4;background:var(--ink);color:#fff6dc;padding:7px 14px;border-radius:12px;font-weight:700;font-size:13px;opacity:0;transition:.25s;pointer-events:none;white-space:nowrap}
#flash.on{opacity:1;transform:translate(-50%,0)}
.btn{font:inherit;font-weight:800;font-size:13px;letter-spacing:.03em;color:var(--ink);background:var(--pill);border:2.5px solid var(--ink);border-radius:13px;padding:7px 13px;cursor:pointer;box-shadow:3px 3px 0 var(--ink);transition:transform .08s,box-shadow .08s,background .15s;user-select:none}
.btn:hover{background:#fff}
.btn:active,.btn.press{transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink)}
.btn.sw{padding:5px 11px;font-size:12px;border-radius:999px}
.btn.sw.on{background:var(--accent);color:#fff}
.btn.sq{width:36px;height:36px;padding:0;font-size:20px;line-height:1;border-radius:11px}
#bar{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;padding:10px 12px 14px;position:relative;z-index:3}
#bar .btn{font-size:14px;padding:9px 16px}
#bar .btn.main{background:var(--accent);color:#fff}
#bar .btn:disabled{opacity:.45;cursor:not-allowed}
#lbl{display:none;padding:0 14px 4px;font-size:12px;font-weight:700;columns:2;position:relative;z-index:3}
#lbl div{break-inside:avoid;padding:1px 0}
#lbl b{display:inline-block;width:18px;height:18px;border-radius:50%;background:var(--pill);border:2px solid var(--ink);text-align:center;line-height:14px;font-size:10px;margin-right:6px}
#lbl .on b{background:var(--accent);color:#fff}
@media(max-width:560px){#top{max-width:78%}#bar .btn{padding:8px 11px;font-size:13px}#stats{font-size:11px}}
</style>
</head>
<body>
<div id="paper"></div>
<div id="stage">
<div id="cvwrap">
<canvas id="cv"></canvas>
<div id="top"></div>
<div id="stats"></div>
<div id="zoom"><button class="btn sq" id="zin">+</button><button class="btn sq" id="zout">-</button></div>
<div id="flash"></div>
</div>
<div id="lbl"></div>
<div id="bar"></div>
</div>
<script>
/* ===== mini isometric engine (standalone) ===== */
const D2R = Math.PI / 180, INK = '#1d1a2b';
const vadd = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const vsub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const vmul = (a, k) => [a[0] * k, a[1] * k, a[2] * k];
const vdot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const vcross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const vlen = a => Math.hypot(a[0], a[1], a[2]);
const vnrm = a => { const l = vlen(a) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
const vlerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const ease = t => { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); };
const seg = (t, a, b) => clamp((t - a) / (b - a), 0, 1);
/* camera */
let YAW = 32, PITCH = 26, ZOOM = 1, CV, CAMR, CAMU, CAMV, LD;
const C0 = [22, -2, 14];
function setCam() {
const t = YAW * D2R, p = PITCH * D2R;
CAMV = [Math.cos(p) * Math.cos(t), Math.cos(p) * Math.sin(t), Math.sin(p)];
CAMR = [-Math.sin(t), Math.cos(t), 0];
CAMU = vcross(CAMV, CAMR);
LD = vnrm(vadd(vadd(vmul(CAMR, -.5), vmul(CAMU, .8)), vmul(CAMV, .45)));
}
let SC = 6, OXS = 0, OYS = 0;
function pj(p) { const q = vsub(p, C0); return [OXS + SC * vdot(q, CAMR), OYS - SC * vdot(q, CAMU)]; }
/* colours */
function hex2(h) { h = h.replace('#', ''); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; }
function mixc(a, b, t) { const A = hex2(a), B = hex2(b); return '#' + [0, 1, 2].map(i => Math.round(A[i] + (B[i] - A[i]) * t).toString(16).padStart(2, '0')).join(''); }
function mat(l, m, d) { return { l, m, d }; }
const M = {
RED: mat('#ee6b4f', '#d24b31', '#a1331f'), CREAM: mat('#fff6dc', '#efdfb2', '#cdb98a'),
CHR: mat('#f7fafb', '#c9d3d8', '#8996a0'), BLK: mat('#5a5a64', '#34343c', '#1b1b21'),
COP: mat('#f4b184', '#d9824f', '#a85530'), BRS: mat('#f6d98a', '#d9ad4e', '#a97f2b'),
WOOD: mat('#ecd0a0', '#dcb57a', '#bf9358'), WTR: mat('#c9ecfa', '#8fd3f0', '#5eb5dc'),
COF: mat('#8a5a3a', '#6b4129', '#4a2b1a'), CREMA: mat('#e8c08a', '#d3a36a', '#b0824a'),
MILK: mat('#fffaf0', '#f3eadb', '#d9ccb6'), GLS: mat('#e4f5fb', '#bfe4f1', '#9ccce0'),
PUMP: mat('#6aa6e8', '#3f7fc9', '#2a5a96'), GRN: mat('#78c995', '#45a568', '#2f7d4b'),
WHT: mat('#ffffff', '#f1ede4', '#d3cdbd'), STEEL: mat('#f3f6f7', '#c2ccd2', '#8190a0')
};
function tone(n, m) { const d = vdot(n, LD); return d > .42 ? m.l : d > -.12 ? m.m : m.d; }
function matMix(a, hexTo, t) { return { l: mixc(a.l, hexTo, t), m: mixc(a.m, hexTo, t), d: mixc(a.d, hexTo, t) }; }
/* solids */
function fin(s) {
const v = s.v; let c = [0, 0, 0]; const mn = [1e9, 1e9, 1e9], mx = [-1e9, -1e9, -1e9];
for (const p of v) for (let i = 0; i < 3; i++) { c[i] += p[i]; if (p[i] < mn[i]) mn[i] = p[i]; if (p[i] > mx[i]) mx[i] = p[i]; }
c = vmul(c, 1 / v.length); s.c = c; s.mn = mn; s.mx = mx;
s.n = s.f.map(f => {
let n = [0, 0, 0];
for (let i = 0; i < f.length; i++) {
const a = v[f[i]], b = v[f[(i + 1) % f.length]];
n[0] += (a[1] - b[1]) * (a[2] + b[2]); n[1] += (a[2] - b[2]) * (a[0] + b[0]); n[2] += (a[0] - b[0]) * (a[1] + b[1]);
}
const l = vlen(n); if (l < 1e-7) return null; n = vmul(n, 1 / l);
let fc = [0, 0, 0]; for (const i of f) fc = vadd(fc, v[i]); fc = vmul(fc, 1 / f.length);
if (vdot(n, vsub(fc, c)) < 0) n = vmul(n, -1);
return n;
});
return s;
}
function mk(v, f, m, o) { return fin(Object.assign({ v, f, mat: m, layer: 'ext', ink: 2.2 }, o || {})); }
function boxS(x0, y0, z0, x1, y1, z1, m, o) {
const v = [[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]];
const f = [[0, 1, 2, 3], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]];
return mk(v, f, m, Object.assign({ edges: true }, o));
}
function PA(axis, u, w, a) { return axis === 'z' ? [u, w, a] : axis === 'x' ? [a, u, w] : [u, a, w]; }
function prism(axis, prof, a0, a1, m, o) {
const n = prof.length, v = [], f = [];
for (const p of prof) v.push(PA(axis, p[0], p[1], a0));
for (const p of prof) v.push(PA(axis, p[0], p[1], a1));
for (let i = 0; i < n; i++) f.push([i, (i + 1) % n, n + (i + 1) % n, n + i]);
f.push(prof.map((_, i) => i)); f.push(prof.map((_, i) => n + i));
return mk(v, f, m, Object.assign({ capA: f.length - 2, capB: f.length - 1 }, o));
}
function rrect(u0, v0, u1, v1, rs, sg) {
sg = sg || 4; const out = [];
const corners = [[u0, v0, 180, rs[0], 1, 1], [u1, v0, 270, rs[1], -1, 1], [u1, v1, 0, rs[2], -1, -1], [u0, v1, 90, rs[3], 1, -1]];
for (const [cu, cv, a0, r, su, sv] of corners) {
if (r <= 0) { out.push([cu, cv]); continue; }
const ccu = cu + su * r, ccv = cv + sv * r;
for (let i = 0; i <= sg; i++) { const a = (a0 + 90 * i / sg) * D2R; out.push([ccu + r * Math.cos(a), ccv + r * Math.sin(a)]); }
}
return out;
}
function circ(cu, cv, r, n) { n = n || 20; const o = []; for (let i = 0; i < n; i++) { const a = i / n * Math.PI * 2; o.push([cu + r * Math.cos(a), cv + r * Math.sin(a)]); } return o; }
function rod(a, b, r0, r1, m, o, n) {
if (r1 == null) r1 = r0; n = n || 12;
const ax = vnrm(vsub(b, a)); const h = Math.abs(ax[2]) < .9 ? [0, 0, 1] : [1, 0, 0];
const e1 = vnrm(vcross(ax, h)), e2 = vcross(ax, e1), v = [], f = [];
for (let i = 0; i < n; i++) { const t = i / n * Math.PI * 2, d = vadd(vmul(e1, Math.cos(t)), vmul(e2, Math.sin(t))); v.push(vadd(a, vmul(d, r0))); }
for (let i = 0; i < n; i++) { const t = i / n * Math.PI * 2, d = vadd(vmul(e1, Math.cos(t)), vmul(e2, Math.sin(t))); v.push(vadd(b, vmul(d, r1))); }
for (let i = 0; i < n; i++) f.push([i, (i + 1) % n, n + (i + 1) % n, n + i]);
f.push([...Array(n).keys()]); f.push([...Array(n).keys()].map(i => n + i));
return mk(v, f, m, Object.assign({ capA: n, capB: n + 1, geo: { a, b, r0, r1, e1, e2, n } }, o));
}
function sph(c, r, m, o, nl, nn) {
nl = nl || 6; nn = nn || 12; const v = [], f = [], rings = [];
for (let i = 0; i <= nl; i++) {
const ph = (-90 + 180 * i / nl) * D2R, rr = Math.cos(ph) * r, z = Math.sin(ph) * r, idx = [];
for (let j = 0; j < nn; j++) { const t = j / nn * Math.PI * 2; idx.push(v.length); v.push([c[0] + rr * Math.cos(t), c[1] + rr * Math.sin(t), c[2] + z]); }
rings.push(idx);
}
for (let i = 0; i < nl; i++) for (let j = 0; j < nn; j++) f.push([rings[i][j], rings[i][(j + 1) % nn], rings[i + 1][(j + 1) % nn], rings[i + 1][j]]);
return mk(v, f, m, o);
}
function xf(s, fn) { const t = Object.assign({}, s); t.v = s.v.map(fn); if (s.geo) { const g = s.geo; t.geo = Object.assign({}, g, { a: fn(g.a), b: fn(g.b), e1: vsub(fn(vadd(g.a, g.e1)), fn(g.a)), e2: vsub(fn(vadd(g.a, g.e2)), fn(g.a)) }); } return fin(t); }
function rotZ(p, c, a) { const x = p[0] - c[0], y = p[1] - c[1], co = Math.cos(a), si = Math.sin(a); return [c[0] + x * co - y * si, c[1] + x * si + y * co, p[2]]; }
function rotY(p, h, a) { const dx = p[0] - h[0], dz = p[2] - h[2], co = Math.cos(a), si = Math.sin(a); return [h[0] + dx * co - dz * si, p[1], h[2] + dx * si + dz * co]; }
function rotX(p, h, a) { const dy = p[1] - h[1], dz = p[2] - h[2], co = Math.cos(a), si = Math.sin(a); return [p[0], h[1] + dy * co - dz * si, h[2] + dy * si + dz * co]; }
/* decals: planar drawings in cm units on a face */
function decal(s, o, uvec, vvec, n, fn) { (s.decals = s.decals || []).push({ o, u: uvec, v: vvec, n, fn }); return s; }
function T(ctx, str, x, y, size, col, al) { ctx.save(); ctx.scale(1, -1); ctx.font = '800 ' + size + 'px "Trebuchet MS",sans-serif'; ctx.fillStyle = col; ctx.textAlign = al || 'center'; ctx.textBaseline = 'middle'; ctx.fillText(str, x, -y); ctx.restore(); }
/* sorting: no depth buffer, so order solids per view */
function sortSolids(L) {
const n = L.length; if (n < 2) return L.slice();
const bb = L.map(s => { let a = 1e9, b = 1e9, c = -1e9, d = -1e9; for (const p of s.v) { const q = pj(p); if (q[0] < a) a = q[0]; if (q[0] > c) c = q[0]; if (q[1] < b) b = q[1]; if (q[1] > d) d = q[1]; } return [a, b, c, d]; });
const dep = L.map(s => vdot(s.c, CAMV));
const after = L.map(() => []), indeg = new Array(n).fill(0);
const V = CAMV, E = .05;
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
const A = L[i], B = L[j]; let first = 0;
if (A.grp && A.grp === B.grp && (A.ord || 0) !== (B.ord || 0)) first = (A.ord || 0) < (B.ord || 0) ? 1 : -1;
else {
if (bb[i][2] < bb[j][0] || bb[j][2] < bb[i][0] || bb[i][3] < bb[j][1] || bb[j][3] < bb[i][1]) continue;
let best = 0, bk = 0;
for (let a = 0; a < 3; a++) {
const w = Math.abs(V[a]); if (w < .08) continue;
if (A.mx[a] <= B.mn[a] + E) { if (w > best) { best = w; bk = V[a] > 0 ? 1 : -1; } }
else if (B.mx[a] <= A.mn[a] + E) { if (w > best) { best = w; bk = V[a] > 0 ? -1 : 1; } }
}
if (best > 0) first = bk;
else { const d = dep[i] - dep[j]; first = Math.abs(d) < .01 ? 0 : (d < 0 ? 1 : -1); }
}
if (first === 1) { after[i].push(j); indeg[j]++; } else if (first === -1) { after[j].push(i); indeg[i]++; }
}
const out = [], done = new Array(n).fill(false); let left = n;
while (left) {
let pick = -1, pd = 1e9;
for (let i = 0; i < n; i++) if (!done[i] && indeg[i] === 0 && dep[i] < pd) { pd = dep[i]; pick = i; }
if (pick < 0) { for (let i = 0; i < n; i++) if (!done[i] && dep[i] < pd) { pd = dep[i]; pick = i; } }
done[pick] = true; left--; out.push(L[pick]);
for (const j of after[pick]) indeg[j]--;
}
return out;
}
function hull2(P) {
const p = P.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); if (p.length < 3) return p;
const cr = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]), lo = [], up = [];
for (const q of p) { while (lo.length >= 2 && cr(lo[lo.length - 2], lo[lo.length - 1], q) <= 0) lo.pop(); lo.push(q); }
for (let i = p.length - 1; i >= 0; i--) { const q = p[i]; while (up.length >= 2 && cr(up[up.length - 2], up[up.length - 1], q) <= 0) up.pop(); up.push(q); }
lo.pop(); up.pop(); return lo.concat(up);
}
let ctx, HITS = [], RENDER_K = 0;
function polyPath(P) { ctx.beginPath(); ctx.moveTo(P[0][0], P[0][1]); for (let i = 1; i < P.length; i++) ctx.lineTo(P[i][0], P[i][1]); ctx.closePath(); }
const inkW = w => w * clamp(SC / 6, .6, 1.8);
function drawDecals(s, al) {
if (!s.decals) return;
for (const d of s.decals) {
if (vdot(d.n, CAMV) < .06) continue;
const o = pj(d.o), u = pj(vadd(d.o, d.u)), v = pj(vadd(d.o, d.v));
ctx.save(); ctx.globalAlpha = al; ctx.transform(u[0] - o[0], u[1] - o[1], v[0] - o[0], v[1] - o[1], o[0], o[1]);
d.fn(ctx); ctx.restore();
}
}
function fluidDisc(s, t, k) {
const g = s.geo; if (!g) return null; const c = vlerp(g.a, g.b, t), r = (g.r0 + (g.r1 - g.r0) * t) * k, P = [];
for (let i = 0; i < g.n * 2; i++) { const a = i / (g.n * 2) * Math.PI * 2; P.push(pj(vadd(c, vadd(vmul(g.e1, r * Math.cos(a)), vmul(g.e2, r * Math.sin(a)))))); }
return P;
}
function drawSolid(s, al, k) {
const P = s.v.map(pj); ctx.globalAlpha = al; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
const ghost = s.layer === 'shell' && k > 0;
if (s.open) {
s.f.forEach((f, i) => {
const n = s.n[i]; if (!n || i === s.capB) return; if (vdot(n, CAMV) > 0) return;
polyPath(f.map(j => P[j])); const c = s.inner ? tone(vmul(n, -1), s.inner) : '#caa';
ctx.fillStyle = c; ctx.strokeStyle = c; ctx.lineWidth = .6; ctx.fill(); ctx.stroke();
});
for (const fl of (s.fluids || [])) { const D = fluidDisc(s, fl.t, fl.k || .96); if (D) { polyPath(D); ctx.fillStyle = fl.col; ctx.fill(); ctx.strokeStyle = INK; ctx.lineWidth = inkW(1); ctx.stroke(); } }
if (s.contents) { const cs = s.contents.slice().sort((a, b) => vdot(a.c, CAMV) - vdot(b.c, CAMV)); for (const c of cs) drawSolid(c, al, k); ctx.globalAlpha = al; }
} else if (ghost) {
s.f.forEach((f, i) => { const n = s.n[i]; if (!n || vdot(n, CAMV) > 0) return; polyPath(f.map(j => P[j])); ctx.globalAlpha = al * .5; ctx.fillStyle = s.mat.m; ctx.fill(); });
ctx.globalAlpha = al;
}
s.f.forEach((f, i) => {
const n = s.n[i]; if (!n || vdot(n, CAMV) <= 0) return;
if (s.open && i === s.capB) return;
const pts = f.map(j => P[j]); polyPath(pts);
const c = (s.flat ? s.mat.m : tone(n, s.mat)); ctx.fillStyle = c; ctx.strokeStyle = c; ctx.lineWidth = .7; ctx.fill(); ctx.stroke();
if (s.edges || i === s.capA || i === s.capB) { ctx.strokeStyle = INK; ctx.lineWidth = inkW(s.edges ? .9 : 1.1); ctx.stroke(); }
});
if (s.open && s.capB != null) { polyPath(s.f[s.capB].map(j => P[j])); ctx.strokeStyle = INK; ctx.lineWidth = inkW(1.3); ctx.stroke(); }
drawDecals(s, al);
if (s.ink > 0) { const H = hull2(P); if (H.length > 2) { polyPath(H); ctx.strokeStyle = INK; ctx.lineWidth = inkW(s.ink); ctx.stroke(); if (s.act) HITS.push({ act: s.act, poly: H }); } }
ctx.globalAlpha = 1;
}
function shadowOf(L, sx, sy) {
for (const s of L) {
if (s.cast === false) continue; const pts = [];
for (const p of s.v) { pts.push(pj([p[0], p[1], 0])); pts.push(pj([p[0] + sx * p[2], p[1] + sy * p[2], 0])); }
const H = hull2(pts); if (H.length > 2) { polyPath(H); ctx.fill(); }
}
}
/* frame: scene(st) -> solids, plus overlays */
function renderFrame(st, scn) {
const W = CV.width, H = CV.height; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, W, H);
const dpr = W / CV.clientWidth; ctx.scale(dpr, dpr);
const w = CV.clientWidth, h = CV.clientHeight; SC = Math.min(w / (w < 560 ? 60 : 82), h / 74) * ZOOM; OXS = w / 2; OYS = h / 2 + 22; setCam(); HITS = [];
const k = ease(st.xr || 0); RENDER_K = k;
const all = scn.solids.filter(s => k > 0 || s.layer !== 'in');
const board = all.filter(s => s.layer === 'board'), rest = all.filter(s => s.layer !== 'board');
for (const b of board) drawSolid(b, 1, 0);
/* cast shadow, clipped to the board top */
let hl = Math.hypot(LD[0], LD[1]); const sd = hl < .05 ? [.4, .3] : [-LD[0] / hl * .42, -LD[1] / hl * .42];
ctx.save();
if (board[0]) { const b = board[0], zt = b.mx[2], q = [[b.mn[0], b.mn[1]], [b.mx[0], b.mn[1]], [b.mx[0], b.mx[1]], [b.mn[0], b.mx[1]]].map(p => pj([p[0], p[1], zt])); polyPath(q); ctx.clip(); }
ctx.fillStyle = '#8fb1cb'; ctx.globalAlpha = 1;
shadowOf(rest.filter(s => s.layer !== 'in'), sd[0], sd[1]);
ctx.restore();
const isSh = s => s.layer === 'shell' || (k > 0 && s.ghost); const shells = rest.filter(isSh), others = rest.filter(s => !isSh(s));
const alOf = s => (s.layer === 'shell' ? 1 - .84 * k : s.ghost ? 1 - .66 * k : s.layer === 'in' ? clamp(k * 1.4, 0, 1) : 1) * (s.am == null ? 1 : (s.layer === 'in' ? s.am : 1 - (1 - s.am) * k));
const order = k > 0 ? sortSolids(shells).concat(sortSolids(others)) : sortSolids(rest);
for (const s of order) { if (s.layer === 'in' && k <= 0) continue; drawSolid(s, alOf(s), k); }
if (scn.overlay) scn.overlay(ctx, k);
ctx.globalAlpha = 1;
return k;
}
/* labels (callout pills; numbered badges on phones) */
function drawLabels(list, listEl) {
const w = CV.clientWidth, h = CV.clientHeight, phone = w < 560;
const A = list.map(l => { const p = pj(l.at); return { l, x: p[0], y: p[1] }; });
ctx.save(); ctx.lineJoin = 'round';
if (phone) {
A.forEach((a, i) => {
ctx.beginPath(); ctx.arc(a.x, a.y, 9, 0, 7); ctx.fillStyle = a.l.on ? '#e2583c' : '#fffaf0'; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = INK; ctx.stroke();
ctx.fillStyle = a.l.on ? '#fff' : INK; ctx.font = '800 11px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(i + 1), a.x, a.y + .5);
});
const key = list.map(l => l.text + (l.on ? 1 : 0)).join('|');
if (listEl.dataset.k !== key) { listEl.dataset.k = key; listEl.innerHTML = list.map((l, i) => '<div class="' + (l.on ? 'on' : '') + '"><b>' + (i + 1) + '</b>' + l.text + '</div>').join(''); }
ctx.restore(); return;
}
ctx.font = '800 11px "Trebuchet MS",sans-serif';
for (const a of A) a.pw = ctx.measureText(a.l.text).width + 18;
const cx = w / 2, cols = [A.filter(a => a.x < cx), A.filter(a => a.x >= cx)];
cols.forEach((col, ci) => {
col.sort((a, b) => a.y - b.y); let y = 58; const gap = 26;
for (const a of col) { y = Math.max(a.y, y + (y === 58 ? 0 : 0)); a.py = y; y += gap; }
let over = col.length ? col[col.length - 1].py - (h - 24) : 0; if (over > 0) { for (let i = col.length - 1; i >= 0; i--) { col[i].py -= over; if (i > 0 && col[i - 1].py > col[i].py - gap) { over = col[i - 1].py - (col[i].py - gap); } else break; } }
for (const a of col) { const px = ci === 0 ? 12 : w - 12 - a.pw; a.px = px; }
for (const a of col) {
const ex = ci === 0 ? a.px + a.pw : a.px, ey = a.py;
ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(a.x, a.y); ctx.strokeStyle = INK; ctx.lineWidth = 1.2; ctx.stroke();
ctx.beginPath(); ctx.arc(a.x, a.y, 3, 0, 7); ctx.fillStyle = a.l.on ? '#e2583c' : INK; ctx.fill();
}
for (const a of col) {
ctx.beginPath(); ctx.roundRect(a.px, a.py - 10, a.pw, 20, 10); ctx.fillStyle = a.l.on ? '#e2583c' : '#fffaf0'; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = INK; ctx.stroke();
ctx.fillStyle = a.l.on ? '#fff' : INK; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(a.l.text, a.px + a.pw / 2, a.py + .5);
}
});
if (listEl.dataset.k) { listEl.dataset.k = ''; listEl.innerHTML = ''; }
ctx.restore();
}
/* ===== RETRO ESPRESSO MACHINE: how it works + parts list =====
Steps: water in tank -> vibratory pump -> boiler (heating element keeps ~93C) -> 3-way valve -> group head
-> puck in the portafilter basket -> spouts -> cup. Steam: knob opens the valve on top of the boiler, steam runs
down the riser, out through the side wall and the wand into the milk.
Parts. CASE: plinth, lower body, stripe, upper body, top cap, dome, lid + hatch, drip tray, cord.
STRUCTURE: tank cradle, pump mount, boiler posts. MECHANISM: tank (+water), pump (3 pieces), tubes T1-T4, boiler,
flanges, heating element, 3-way valve, group head, shower screen, steam valve, riser, wand.
CONTROLS: brew button + switch, gauge, steam knob, wires. USER: portafilter, tamper, cup, milk pitcher, water jug. */
const BXC = 35;
const STX = { x: 20, y: -27 };
function trim(s, ax, v, hi) { if (hi) s.mx[ax] = Math.min(s.mx[ax], v); else s.mn[ax] = Math.max(s.mn[ax], v); return s; }
function gaugeDecal(c) {
const st = window.__st || {}; c.fillStyle = '#fffaf0'; c.beginPath(); c.arc(0, 0, 3.1, 0, 7); c.fill();
c.strokeStyle = INK; c.lineWidth = .18; c.stroke();
for (let i = 0; i <= 6; i++) { const a = (-135 + i * 45) * D2R; c.beginPath(); c.moveTo(Math.sin(a) * 2.3, Math.cos(a) * 2.3); c.lineTo(Math.sin(a) * 2.9, Math.cos(a) * 2.9); c.lineWidth = .2; c.stroke(); }
const a0 = (-135 + 270 * 9 / 12) * D2R; c.strokeStyle = '#d24b31'; c.lineWidth = .5; c.beginPath(); c.arc(0, 0, 2.1, Math.PI / 2 - (-135 + 270 * 12 / 12) * D2R, Math.PI / 2 - a0); c.stroke();
const a = (-135 + clamp((st.press || 0) / 12, 0, 1) * 270) * D2R;
c.strokeStyle = INK; c.lineWidth = .3; c.lineCap = 'round'; c.beginPath(); c.moveTo(0, 0); c.lineTo(Math.sin(a) * 2.5, Math.cos(a) * 2.5); c.stroke();
c.fillStyle = INK; c.beginPath(); c.arc(0, 0, .35, 0, 7); c.fill();
}
function knobDecal(c) {
const st = window.__st || {}; const a = (st.steamV || 0) * Math.PI / 2;
c.save(); c.rotate(a); c.fillStyle = '#fff6dc'; c.strokeStyle = INK; c.lineWidth = .18;
c.beginPath(); c.roundRect(-2.0, -.45, 4.0, .9, .45); c.fill(); c.stroke(); c.restore();
}
function frontLabels(c) {
T(c, 'BREW', 11, 22.7, 1.5, INK); T(c, 'BAR', -11, 22.5, 1.5, INK);
}
function plateDecal(c) {
c.fillStyle = '#f7fafb'; c.strokeStyle = INK; c.lineWidth = .22; c.beginPath(); c.roundRect(-11, 8, 22, 6, 1.2); c.fill(); c.stroke();
T(c, 'ESPRESSO', 0, 11.1, 3.0, '#c4452c');
c.fillStyle = '#d24b31'; c.fillRect(-9, 8.9, 18, .35);
}
function badgeDecal(c) {
c.fillStyle = '#d24b31'; c.strokeStyle = INK; c.lineWidth = .22; c.beginPath(); c.arc(0, 0, 3.4, 0, 7); c.fill(); c.stroke();
T(c, 'R', 0, 0, 4.2, '#fff6dc');
}
function ventDecal(c) {
c.fillStyle = '#b79c6e'; for (let i = 0; i < 7; i++) c.fillRect(-9, 7 + i * 3.2, 18, 1.1);
}
function grilleDecal(c) { c.fillStyle = '#6d7981'; for (let i = 0; i < 6; i++) c.fillRect(1, 1.2 + i * 1.8, 16, .8); }
function buildStatic() {
const A = []; const add = (s, o) => { Object.assign(s, o || {}); A.push(s); return s; };
const IN = (s, o) => add(s, Object.assign({ layer: 'in' }, o));
add(boxS(-8, -35, -2.4, 64, 35, 0, M.WOOD, { layer: 'board', ink: 2.6, cast: false }));
/* case */
add(prism('z', rrect(1, -20, 30, 20, [3, 3, 3, 3]), 0, 5, M.RED, { layer: 'shell', ink: 2.6 }));
const low = add(prism('z', rrect(3, -18, 29, 18, [3, 3, 3, 3]), 5, 17, M.CREAM, { layer: 'shell', ink: 2.6 }));
add(prism('z', rrect(2.6, -18.4, 29.4, 18.4, [3, 3, 3, 3]), 17, 21, M.RED, { layer: 'shell', ink: 2.4 }));
const up = add(prism('z', rrect(3, -18, 29, 18, [3, 3, 3, 3]), 21, 33, M.CREAM, { layer: 'shell', ink: 2.6 }));
add(prism('z', rrect(2, -19, 30, 19, [4, 4, 4, 4]), 33, 36, M.CHR, { layer: 'shell', ink: 2.6 }));
decal(low, [29, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], plateDecal);
decal(low, [3, 0, 0], [0, -1, 0], [0, 0, 1], [-1, 0, 0], ventDecal);
decal(up, [29, 0, 0], [0, 1, 0], [0, 0, 1], [1, 0, 0], frontLabels);
decal(up, [0, 18, 0], [-1, 0, 0], [0, 0, 1], [0, 1, 0], c => { c.translate(-10, 0); c.save(); c.translate(0, 27); badgeDecal(c); c.restore(); });
decal(up, [0, -18, 0], [1, 0, 0], [0, 0, 1], [0, -1, 0], c => { c.translate(10, 0); c.save(); c.translate(0, 27); badgeDecal(c); c.restore(); });
/* dome stack */
const dz = [[36, 38, 7.5, 7.0], [38, 40, 7.0, 5.6], [40, 41.5, 5.6, 3.2], [41.5, 42.6, 3.2, 1.2]];
dz.forEach(d => add(rod([18, -2, d[0]], [18, -2, d[1]], d[2], d[3], M.CHR, { layer: 'shell', ink: 2.4 }, 18)));
add(sph([18, -2, 44.2], 1.6, M.CHR, { layer: 'shell', ink: 2 }, 5, 10));
/* hatch under lid /
add(rod([7.5, 10.5, 36], [7.5, 10.5, 36.2], 3.0, 3.0, M.BLK, { layer: 'shell', ink: 1.2, cast: false }, 16));
/ drip tray, front of plinth */
const tray = add(boxS(30, -9, 0, 42, 9, 3, M.CHR, { ink: 2.4 }));
decal(tray, [42, -9, 3], [0, 1, 0], [-1, 0, 0], [0, 0, 1], grilleDecal);
/* cord out of the back /
[[[2.4, 12, 9], [-1.5, 12, 6]], [[-1.5, 12, 6], [-4, 12, .6]], [[-4, 12, .6], [-6, 24, .6]]].forEach((c, i) => add(rod(c[0], c[1], .55, .55, M.BLK, { grp: 'cord', ord: i, ink: .6 }, 8)));
/ group head + brew button + gauge (outside) */
add(prism('z', rrect(29, -6.5, 40, 6.5, [0, 3, 3, 0]), 25, 31, M.CHR, { ghost: true, ink: 2.4 }));
add(rod([29, 11, 28], [30.1, 11, 28], 3.3, 3.3, M.CHR, { ink: 1.8 }, 16));
const g = add(rod([29, -11, 27.5], [30.4, -11, 27.5], 3.6, 3.6, M.CHR, { ink: 1.8 }, 20));
decal(g, [30.4, -11, 27.5], [0, 1, 0], [0, 0, 1], [1, 0, 0], gaugeDecal);
/* steam knob chain (outside) */
add(rod([20, -18, 29.5], [20, -19.4, 29.5], 1.1, 1.1, M.CHR, { grp: 'steam', ord: 6, ink: 1 }, 10));
const kn = add(rod([20, -19.4, 29.5], [20, -21.6, 29.5], 2.6, 2.6, M.RED, { grp: 'steam', ord: 7, ink: 1.8, act: 'steam' }, 16));
decal(kn, [20, -21.6, 29.5], [1, 0, 0], [0, 0, 1], [0, -1, 0], knobDecal);
/* wand */
add(rod([20, -18, 24], [20, -22, 24], .8, .8, M.CHR, { grp: 'steam', ord: 3, ink: 1 }, 10));
add(rod([20, -22, 24], [20, -24.72, 12], .8, .8, M.CHR, { grp: 'steam', ord: 4, ink: 1 }, 10));
/* ---- inside ---- */
IN(boxS(4.3, -7, 5, 11.3, 13, 8.5, M.BLK, { ink: 1.4 })); // tank cradle
IN(boxS(15, 4, 5, 22, 8, 6.9, M.BLK, { ink: 1.4 })); // pump mount
IN(boxS(18.5, -11, 5, 21.5, -9, 20.5, M.BLK, { ink: 1.4 })); // boiler post 1
IN(boxS(18.5, 0, 5, 21.5, 2, 20.5, M.BLK, { ink: 1.4 })); // boiler post 2
IN(rod([11.3, 6, 9.5], [14, 6, 9.5], .75, .75, M.GLS, { grp: 'T1', ord: 0, ink: 1 }, 10));
const T2 = [[[23, 6, 9.5], [27.2, 6, 9.5]], [[27.2, 6, 9.5], [27.2, 6, 25]], [[27.2, 6, 25], [25.3, 6, 25]]];
T2.forEach((c, i) => IN(rod(c[0], c[1], .75, .75, M.GLS, { grp: 'boiler', ord: 1 + i, ink: 1 }, 10)));
IN(rod([20, -14, 26], [20, 9, 26], 5.5, 5.5, M.COP, { grp: 'boiler', ord: 0, am: .78, ink: 2.2 }, 20));
IN(rod([20, -14.8, 26], [20, -14, 26], 6.1, 6.1, M.STEEL, { ink: 1.8 }, 20));
IN(rod([20, 9, 26], [20, 9.8, 26], 6.1, 6.1, M.STEEL, { ink: 1.8 }, 20));
/* group feed: boiler -> valve -> head -> screen */
const T3 = [[[25.1, 0, 28], [26.2, 0, 28], .75, M.GLS], [[26.2, 0, 28], [28.2, 0, 28], 1.7, M.BRS], [[28.2, 0, 28], [35, 0, 28], .75, M.GLS], [[35, 0, 28], [35, 0, 25.6], .75, M.GLS]];
T3.forEach((c, i) => IN(rod(c[0], c[1], c[2], c[2], c[3], { grp: 'boiler', ord: 4 + i, ink: 1 }, 10)));
IN(boxS(26.4, -1.5, 29.7, 28, 1.5, 31.8, M.BLK, { grp: 'boiler', ord: 9, ink: 1.4 }));
IN(rod([35, 0, 25.2], [35, 0, 25.6], 3.2, 3.2, M.STEEL, { ink: 1.4 }, 16));
/* steam: valve on boiler top, riser, pipe through the wall */
IN(rod([20, -14.8, 29.5], [20, -18, 29.5], 1.3, 1.3, M.BRS, { grp: 'steam', ord: 0, ink: 1.2 }, 10));
IN(rod([20, -16.2, 29.5], [20, -16.2, 24], .7, .7, M.GLS, { grp: 'steam', ord: 1, ink: 1 }, 10));
IN(rod([20, -16.2, 24], [20, -18, 24], .7, .7, M.GLS, { grp: 'steam', ord: 2, ink: 1 }, 10));
/* brew switch behind the button, wires */
IN(boxS(27.4, 9.6, 26.4, 28.9, 12.4, 29.6, M.BLK, { ink: 1.4 }));
const W1 = [[[27.7, 11, 26.4], [27.7, 11, 9.5]], [[27.7, 11, 9.5], [22, 11, 9.5]], [[22, 11, 9.5], [22, 9.55, 9.5]]];
W1.forEach((c, i) => IN(rod(c[0], c[1], .3, .3, matOf('#d24b31'), { grp: 'w1', ord: i, ink: .5 }, 6)));
const W2 = [[[27.7, 11, 29.6], [27.7, 11, 32.2]], [[27.7, 11, 32.2], [27.7, -15.4, 32.2]], [[27.7, -15.4, 32.2], [27.7, -15.4, 23.2]], [[27.7, -15.4, 23.2], [22.2, -15.4, 23.2]]];
W2.forEach((c, i) => IN(rod(c[0], c[1], .3, .3, matOf('#2a2a33'), { grp: 'w2', ord: i, ink: .5 }, 6)));
/* element terminals (outside the flange) */
IN(rod([21.5, -14.8, 23.2], [21.5, -16.3, 23.2], .8, .8, M.BRS, { ink: 1 }, 8));
IN(rod([18.5, -14.8, 23.2], [18.5, -16.3, 23.2], .8, .8, M.BRS, { ink: 1 }, 8));
/* tank glass */
IN(boxS(4.2, -8, 8.5, 11.5, 14, 33, M.GLS, { grp: 'tank', ord: 1, am: .3, ink: 1.4 }));
return A;
}
function matOf(h) { return { l: mixc(h, '#ffffff', .25), m: h, d: mixc(h, '#000000', .35) }; }
/* ===== dynamic parts, rebuilt every frame from the state ===== */
const REST = { dx: 6, dy: 25, dz: -18 };
const KEYS = [[0, 6, 25, -18, 0], [.25, 6, 25, -6, 0], [.65, 0, 0, -4, 38], [.82, 0, 0, -.4, 36], [1, 0, 0, 0, 0]];
function pfPose(u) {
u = clamp(u, 0, 1);
for (let i = 0; i < KEYS.length - 1; i++) {
const a = KEYS[i], b = KEYS[i + 1];
if (u <= b[0]) { const t = ease((u - a[0]) / (b[0] - a[0])); return { dx: a[1] + (b[1] - a[1]) * t, dy: a[2] + (b[2] - a[2]) * t, dz: a[3] + (b[3] - a[3]) * t, ang: (a[4] + (b[4] - a[4]) * t) * D2R }; }
}
return { dx: 0, dy: 0, dz: 0, ang: 0 };
}
const GROUNDS = '#7a4a2c', WET = '#4a2b1a';
const innerM = { l: '#aab4ba', m: '#8d99a1', d: '#6d7981' };
function tamperAt(tt) {
// returns {x,y,zb,inside}
const rx = 47, ry = 16, bx = 41, by = 25;
if (tt <= 0 || tt >= 1) return { x: rx, y: ry, zb: 0, inside: false };
let x, y, zb;
if (tt < .12) { x = rx; y = ry; zb = 0; }
else if (tt < .30) { const t = ease((tt - .12) / .18); x = rx + (bx - rx) * t; y = ry + (by - ry) * t; zb = 14 * t; }
else if (tt < .46) { const t = ease((tt - .30) / .16); x = bx; y = by; zb = 14 + (6.9 - 14) * t; }
else if (tt < .62) { const t = ease((tt - .46) / .16); x = bx; y = by; zb = 6.9 + (5.95 - 6.9) * t; }
else if (tt < .72) { x = bx; y = by; zb = 5.95; }
else if (tt < .86) { const t = ease((tt - .72) / .14); x = bx; y = by; zb = 5.95 + (14 - 5.95) * t; }
else { const t = ease((tt - .86) / .14); x = bx + (rx - bx) * t; y = by + (ry - by) * t; zb = 14 * (1 - t); }
return { x, y, zb, inside: tt > .3 && tt < .86 };
}
function jugAt(j) {
const K = [[0, 14, 27, 0, 0], [.2, 14, 27, 40, 0], [.42, 7.5, 24, 44, 0], [.58, 7.5, 22.2, 40.7, 1.2], [.84, 7.5, 22.2, 40.7, 1.2], [.92, 7.5, 24, 44, 0], [1, 14, 27, 0, 0]];
j = clamp(j, 0, 1);
for (let i = 0; i < K.length - 1; i++) {
const a = K[i], b = K[i + 1];
if (j <= b[0]) { const t = ease((j - a[0]) / (b[0] - a[0])); return [0, 1, 2, 3, 4].slice(1).map(q => a[q] + (b[q] - a[q]) * t); }
}
return [14, 27, 0, 0];
}
function buildDynamic(st) {
const A = []; const add = (s, o) => { Object.assign(s, o || {}); A.push(s); return s; };
const IN = (s, o) => add(s, Object.assign({ layer: 'in' }, o));
const k = ease(st.xr || 0); const t = st.time || 0;
/* tank water (inside the glass) */
const wl = 8.8 + (31.9 - 8.8) * clamp(st.tank, 0, 1);
if (st.tank > .005) IN(boxS(4.5, -7.7, 8.8, 11.2, 13.7, wl, M.WTR, { grp: 'tank', ord: 0, ink: 0, flat: true, am: .5 }));
/* pump with its little shake */
const sh = st.pump > .2 ? Math.sin(t * 60) * .12 * st.pump : 0;
const pz = 9.5 + sh;
IN(rod([14, 6, pz], [16, 6, pz], 2.6, 2.6, M.PUMP, { ink: 1.8 }, 14));
IN(rod([16, 6, pz], [19.5, 6, pz], 3.0, 3.0, M.COP, { ink: 1.8 }, 14));
IN(rod([19.5, 6, pz], [23, 6, pz], 2.6, 2.6, M.PUMP, { ink: 1.8 }, 14));
IN(boxS(21.4, 8.62, pz - .5, 22.6, 9.6, pz + .5, M.BRS, { ink: 1 }));
/* heating element glows when on */
const hc = (m, h) => ({ l: mixc(m.l, '#ffd27a', h), m: mixc(m.m, '#ff6a2a', h), d: mixc(m.d, '#c2330d', h) });
const em = hc(M.STEEL, clamp(st.heat, 0, 1) * .95);
IN(rod([21.5, -14, 23.2], [21.5, 4, 23.2], .9, .9, em, { grp: 'boiler', ord: 12, ink: 1 }, 8));
IN(rod([18.5, -14, 23.2], [18.5, 4, 23.2], .9, .9, em, { grp: 'boiler', ord: 12, ink: 1 }, 8));
IN(rod([21.5, 4, 23.2], [18.5, 4, 23.2], .9, .9, em, { grp: 'boiler', ord: 13, ink: 1 }, 8));
/* lid slides aside when refilling */
const ly = 10.5 + 5.1 * ease(st.lid);
add(rod([7.5, ly, 36.2], [7.5, ly, 37.2], 3.4, 3.4, M.CHR, { layer: 'shell', ink: 2, act: 'refill' }, 16));
add(rod([7.5, ly, 37.2], [7.5, ly, 38.4], 1.0, 1.0, M.BLK, { layer: 'ext', ink: 1.4, act: 'refill' }, 10));
/* brew button head */
const bp = st.btn > 0 ? .8 : 0;
add(rod([30.1 - bp, 11, 28], [31.6 - bp, 11, 28], 2.5, 2.5, M.RED, { ink: 1.8, act: 'brew' }, 14));
/* portafilter */
const pose = pfPose(st.pf); const c0 = [BXC, 0, 0];
const fn = p => { const q = rotZ(p, c0, pose.ang); return [q[0] + pose.dx, q[1] + pose.dy, q[2] + pose.dz]; };
const tp = tamperAt(st.tampT);
const open = st.pf < .98;
const basket = rod([BXC, 0, 21.2], [BXC, 0, 25], 3.0, 3.6, M.STEEL, { open, inner: innerM, ink: 2.2, act: 'pf', fluids: [] }, 16);
const bk = xf(basket, fn); bk.open = open; bk.inner = innerM; bk.act = 'pf'; bk.ink = 2.2; bk.capA = basket.capA; bk.capB = basket.capB;
if (st.lvl > .01) bk.fluids = [{ t: st.lvl, col: st.spent ? WET : GROUNDS, k: .98 }]; else bk.fluids = [];
bk.contents = [];
if (tp.inside && st.pf < .01) {
const tb = rod([tp.x, tp.y, tp.zb], [tp.x, tp.y, tp.zb + 1.2], 3.0, 3.0, M.STEEL, { ink: 1.6 }, 14); bk.contents.push(tb);
}
add(bk);
add(xf(rod([BXC + 3.6, 0, 23], [BXC + 19, 0, 22.2], 1.7, 1.45, M.BLK, { ink: 2, act: 'pf' }, 10), fn));
trim(A[A.length - 1], 0, BXC + 3.6);
add(xf(rod([BXC, -1.9, 18.4], [BXC, -1.9, 21.2], 1.25, 1.9, M.STEEL, { ink: 1.6, act: 'pf' }, 10), fn));
add(xf(rod([BXC, 1.9, 18.4], [BXC, 1.9, 21.2], 1.25, 1.9, M.STEEL, { ink: 1.6, act: 'pf' }, 10), fn));
/* rest block that holds the handle when the portafilter is set down */
add(boxS(REST.dx + 50.6, REST.dy - 2.5, 0, REST.dx + 54.6, REST.dy + 2.5, 2.6, M.WOOD, { ink: 1.6 }));
/* tamper */
const tz = tp.zb;
if (!tp.inside) add(rod([tp.x, tp.y, tz], [tp.x, tp.y, tz + 1.2], 3.0, 3.0, M.STEEL, { grp: 'tamper', ord: 0, ink: 1.6, act: 'tamp' }, 14));
add(rod([tp.x, tp.y, tz + 1.2], [tp.x, tp.y, tz + 4.2], 1.0, 1.0, M.BLK, { grp: 'tamper', ord: 1, ink: 1, act: 'tamp' }, 10));
add(rod([tp.x, tp.y, tz + 4.2], [tp.x, tp.y, tz + 8.2], 2.3, 2.3, M.BLK, { grp: 'tamper', ord: 2, ink: 1.8, act: 'tamp' }, 12));
/* cup, saucer, streams */
add(rod([BXC, 0, 3], [BXC, 0, 3.6], 5.2, 5.2, M.WHT, { ink: 1.6 }, 18));
const cup = rod([BXC, 0, 3.6], [BXC, 0, 9.2], 2.3, 3.7, M.WHT, { open: true, inner: { l: '#f4efe2', m: '#e4dcc8', d: '#c9bfa5' }, ink: 2.2 }, 16);
cup.fluids = [];
const lvT = .12 + .62 * clamp(st.cup, 0, 1);
if (st.cup > .01) cup.fluids = [{ t: lvT, col: '#6b4129', k: .97 }, { t: Math.min(lvT + .035, .8), col: '#d3a36a', k: .96 }];
cup.contents = [];
const surf = 3.6 + lvT * 5.6; const sa = st.stream;
if (sa > .01 && st.pf > .99) {
const zEnd = st.cup > .01 ? surf : 3.9; const zTop = 18.4;
const zNow = zTop - (zTop - zEnd) * clamp(sa, 0, 1);
[-1, 1].forEach((sg, i) => {
const yAt = z => sg * (1.9 - 1.0 * (zTop - z) / (zTop - 3.8));
if (zNow > 9.2) add(rod([BXC, yAt(zTop), zTop], [BXC, yAt(zNow), zNow], .4, .4, M.COF, { ink: .6, grp: 'stream' + i, ord: 0 }, 6));
else { add(rod([BXC, yAt(zTop), zTop], [BXC, yAt(9.3), 9.3], .4, .4, M.COF, { ink: .6, grp: 'stream' + i, ord: 0 }, 6)); cup.contents.push(rod([BXC, yAt(9.3), 9.3], [BXC, yAt(zNow), zNow], .4, .4, M.COF, { ink: .5 }, 6)); }
});
}
add(cup);
const ch = [[[BXC, 3.5, 7.8], [BXC, 5.7, 7.4]], [[BXC, 5.7, 7.4], [BXC, 5.9, 5.2]], [[BXC, 5.9, 5.2], [BXC, 3.4, 4.6]]];
ch.forEach((c, i) => { const r = add(rod(c[0], c[1], .5, .5, M.WHT, { grp: 'cuph', ord: i, ink: 1 }, 8)); trim(r, 1, 3.72); });
/* milk pitcher + wand tip */
const pit = rod([STX.x, STX.y, .2], [STX.x, STX.y, 12], 3.9, 4.8, M.STEEL, { open: true, inner: innerM, ink: 2.2 }, 18);
const ml = .5 + .22 * st.milk;
pit.fluids = [{ t: ml, col: '#efe6d2', k: .97 }]; if (st.milk > .15) pit.fluids.push({ t: ml + .05 * st.milk, col: '#ffffff', k: .96 });
pit.contents = [rod([STX.x, -24.72, 12], [STX.x, -26.2, 5.5], .6, .45, M.CHR, { ink: 1 }, 8)];
add(pit);
const ph = [[[25.4, STX.y, 10.5], [28, STX.y, 10.2]], [[28, STX.y, 10.2], [28.2, STX.y, 4.2]], [[28.2, STX.y, 4.2], [24.9, STX.y, 3.5]]];
ph.forEach((c, i) => { const r = add(rod(c[0], c[1], .55, .55, M.STEEL, { grp: 'pith', ord: i, ink: 1 }, 8)); trim(r, 0, 24.9); });
/* water jug */
const jp = jugAt(st.jug); const jx = jp[0], jy = jp[1], jz = jp[2], th = jp[3];
const jf = p => { const q = rotX(p, [0, 0, 0], th); return [q[0] + jx, q[1] + jy, q[2] + jz]; };
add(xf(rod([0, 0, 0], [0, 0, 11], 3.6, 3.9, M.WHT, { open: true, inner: { l: '#f4efe2', m: '#e4dcc8', d: '#c9bfa5' }, ink: 2.2, act: 'refill' }, 16), jf)).open = true;
const J = A[A.length - 1]; J.inner = { l: '#f4efe2', m: '#e4dcc8', d: '#c9bfa5' }; J.capA = 16; J.capB = 17; J.ink = 2.2; J.act = 'refill';
[[[0, 3.9, 9.5], [0, 6.2, 9]], [[0, 6.2, 9], [0, 6.4, 3]], [[0, 6.4, 3], [0, 3.7, 2.5]]].forEach((c, i) => {
const r = add(xf(rod(c[0], c[1], .6, .6, M.WHT, { grp: 'jugh', ord: i, ink: 1, act: 'refill' }, 8), jf)); trim(r, 1, jy + (th > .5 ? -100 : 3.85) , false);
});
/* pouring stream */
if (st.pour > .01) {
const lip = jf([0, -3.9, 11]); const top = [lip[0], lip[1], lip[2]]; const endz = 36.2 + (wl - 36.2) * k;
add(rod(top, [top[0], top[1], endz], .45, .45, M.WTR, { ink: .6 }, 6, true));
}
return A;
}
/* ===== state, overlay, labels, interaction ===== */
const STATIC = buildStatic();
const st = window.__st = { xr: 0, xrT: 0, labels: false, pf: 0, pfT: 0, lvl: 0, spent: false, tampT: 0, tamping: false, job: null,
brewT: 0, brewing: false, steam: 0, steamV: 0, steamOn: false, steamTime: 0, milk: 0, press: 0, pump: 0, stream: 0, cup: 0,
tank: .85, temp: 93, heat: 0, lid: 0, jug: 0, jugging: false, pour: 0, btn: 0, time: 0, auto: false };
const BREW_T = 7, TAMP_T = 3.2, JUG_T = 5.2;
let flashT = 0;
const $ = id => document.getElementById(id);
function flash(msg) { const f = $('flash'); f.textContent = msg; f.classList.add('on'); clearTimeout(flashT); flashT = setTimeout(() => f.classList.remove('on'), 2200); }
const idle = () => !st.brewing && !st.tamping && !st.jugging;
/* actions */
function actPf() {
if (st.brewing) return flash('Wait for the shot to finish');
if (st.tamping || st.jugging) return flash('Busy');
st.job = null; st.pfT = st.pfT > .5 ? 0 : 1; if (st.pfT === 0 && st.spent) flash('Portafilter out: spent puck');
}
function actDose() {
if (st.brewing || st.jugging) return flash('Busy');
if (st.tamping) return;
st.job = 'dose'; st.pfT = 0;
if (st.spent) { flash('Knock out the old puck'); }
}
function actBrew() {
if (st.brewing) return; if (st.tamping || st.jugging) return flash('Busy');
if (st.lvl < .5 || st.spent) { return flash(st.spent ? 'Puck is spent: add fresh coffee' : 'Add coffee first'); }
if (st.tank < .12) return flash('Tank is empty: refill it');
st.btn = 1; st.job = 'brew'; st.pfT = 1;
if (st.pf < .99) flash('Locking the portafilter first');
}
function actSteam() {
if (st.steamOn) { st.steamOn = false; return; }
if (st.tank < .06) return flash('Tank is empty: refill it');
st.steamOn = true; st.steamTime = 0; if (st.milk > .9) st.milk = 0;
}
function actRefill() {
if (st.brewing || st.tamping) return flash('Busy');
if (st.jugging) return; if (st.tank > .95) return flash('Tank is already full');
st.jugging = true; st.jugT = 0;
}
const BUTTONS = [['Portafilter', actPf], ['Add coffee', actDose], ['Brew', actBrew, true], ['Steam', actSteam], ['Refill tank', actRefill]];
const ACTS = { pf: actPf, brew: actBrew, steam: actSteam, refill: actRefill, tamp: actDose };
function tick(dt) {
st.time += dt;
st.xr += (st.xrT - st.xr) * Math.min(1, dt * 5);
const sp = .85 * dt; st.pf = clamp(st.pf + clamp(st.pfT - st.pf, -sp, sp), 0, 1);
/* jobs wait for the portafilter */
if (st.job === 'dose' && st.pf < .001 && !st.tamping) { st.job = null; st.tamping = true; st.tampT = .001; st.spent = false; st.lvl = 0; }
if (st.job === 'brew' && st.pf > .999 && !st.brewing) { st.job = null; st.brewing = true; st.brewT = 0; st.cup = 0; st.btn = 1; }
if (st.tamping) {
st.tampT += dt / TAMP_T; const u = st.tampT;
st.lvl = u < .12 ? .96 * ease(u / .12) : u < .46 ? .96 : u < .62 ? .96 - .24 * ease((u - .46) / .16) : .72;
if (u >= 1) { st.tamping = false; st.tampT = 0; st.lvl = .72; }
}
/* brew */
let pumpTarget = 0, heatNeed = 0;
if (st.brewing) {
st.brewT += dt; const u = st.brewT / BREW_T;
pumpTarget = u < .92 ? 1 : 0;
st.press = 9 * ease(seg(u, 0, .14)) * (1 - ease(seg(u, .9, 1)));
st.stream = u < .2 ? 0 : u < .9 ? ease(seg(u, .2, .27)) : 0;
st.cup = .8 * ease(seg(u, .22, .9));
if (pumpTarget) { st.tank = Math.max(0, st.tank - .11 * dt / BREW_T / .92); st.temp -= 5 * dt / BREW_T; }
if (u >= 1) { st.brewing = false; st.btn = 0; st.spent = true; st.press = 0; st.stream = 0; st.cup = .8; }
} else { st.press = Math.max(0, st.press - dt * 12); st.stream = 0; }
st.pump += (pumpTarget - st.pump) * Math.min(1, dt * 10);
/* steam */
st.steam += ((st.steamOn ? 1 : 0) - st.steam) * Math.min(1, dt * 6);
st.steamV += ((st.steamOn ? 1 : 0) - st.steamV) * Math.min(1, dt * 6);
if (st.steamOn) {
st.steamTime += dt; st.milk = Math.min(1, st.milk + dt / 7); st.temp -= 2.2 * dt; st.tank = Math.max(0, st.tank - .012 * dt);
if (st.steamTime > 8 || st.tank <= 0) st.steamOn = false;
}
/* element thermostat */
if (st.temp < 90) st.on = true; if (st.temp > 95) st.on = false;
heatNeed = st.on ? 1 : 0; if (st.on) st.temp += 7 * dt;
st.heat += (heatNeed - st.heat) * Math.min(1, dt * 6);
/* refill */
if (st.jugging) {
st.jugT += dt / JUG_T; const j = st.jugT; st.jug = j;
st.lid = j < .05 ? 0 : j < .9 ? ease(seg(j, .03, .15)) * (1 - ease(seg(j, .86, .98))) : 0;
st.pour = j > .6 && j < .84 ? 1 : 0;
if (st.pour) st.tank = Math.min(1, st.tank + dt / (JUG_T * .24) * (1 - .0));
if (j >= 1) { st.jugging = false; st.jug = 0; st.lid = 0; st.pour = 0; st.tank = Math.max(st.tank, .98); st.jugT = 0; }
}
}
/* flows: dashed water / steam lines drawn over the see-through */
function flowLine(pts, col, a, w, dash, speed, rev) {
if (a < .02) return; const P = pts.map(pj);
ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.globalAlpha = a * .5; ctx.strokeStyle = col; ctx.lineWidth = w * 1.9 * (SC / 6);
ctx.beginPath(); ctx.moveTo(P[0][0], P[0][1]); for (let i = 1; i < P.length; i++) ctx.lineTo(P[i][0], P[i][1]); ctx.stroke();
ctx.globalAlpha = a; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = w * (SC / 6); ctx.setLineDash([dash * SC / 6, dash * 1.2 * SC / 6]); ctx.lineDashOffset = -(st.time * speed * SC / 6) * (rev ? -1 : 1);
ctx.beginPath(); ctx.moveTo(P[0][0], P[0][1]); for (let i = 1; i < P.length; i++) ctx.lineTo(P[i][0], P[i][1]); ctx.stroke(); ctx.restore();
}
const FL = {
fill: [[11.5, 6, 9.5], [14, 6, 9.5], [23, 6, 9.5], [27.2, 6, 9.5], [27.2, 6, 25], [25.2, 6, 25]],
boil: [[25.2, 6, 25], [22, 3, 24.6], [22, 0, 27.4], [25.1, 0, 28]],
group: [[25.1, 0, 28], [35, 0, 28], [35, 0, 25.8]],
puck: [[35, 0, 25.4], [35, 0, 21.4]],
steam: [[20, -13.5, 29.5], [20, -16.2, 29.5], [20, -16.2, 24], [20, -22, 24], [20, -24.72, 12], [20, -26.2, 5.5]]
};
function overlay(c, k) {
if (k > .15) {
const a = k * st.pump; flowLine(FL.fill, '#2e8fd6', a, 1.5, 2.4, 22); flowLine(FL.boil, '#2e8fd6', a, 1.5, 2.4, 22);
const g = k * clamp(st.press / 6, 0, 1) * (st.brewing ? 1 : 0);
flowLine(FL.group, '#2e8fd6', g, 1.5, 2.4, 22); flowLine(FL.puck, '#2e8fd6', g, 1.5, 2, 22);
flowLine(FL.steam, '#b9c3cc', k * st.steam, 1.6, 2.2, 26);
}
/* steam puffs over the pitcher */
if (st.steam > .05) {
for (let i = 0; i < 6; i++) {
const ph = (st.time * .9 + i / 6) % 1, p = pj([STX.x + Math.sin(i * 2.1 + st.time) * 1.2, STX.y + Math.cos(i * 1.7) * 1.2, 12.5 + ph * 9]);
c.globalAlpha = st.steam * (1 - ph) * .75; c.fillStyle = '#ffffff'; c.strokeStyle = INK; c.lineWidth = 1; c.beginPath(); c.arc(p[0], p[1], (1.6 + ph * 2.6) * SC / 6, 0, 7); c.fill(); c.stroke();
}
c.globalAlpha = 1;
}
}
function labelList(k) {
const on = st.brewing, L = [];
if (k > .6) {
L.push({ at: [8, 3, 22], text: 'WATER TANK', on: st.pour > 0 || (on && st.pump > .2) });
L.push({ at: [18, 6, 12.4], text: 'PUMP', on: st.pump > .2 });
L.push({ at: [20, -6, 28.6], text: 'BOILER', on: on || st.steam > .2 || st.heat > .2 });
L.push({ at: [20, -7, 23.2], text: 'HEATING ELEMENT', on: st.heat > .2 });
L.push({ at: [20, -16.2, 29.5], text: 'STEAM VALVE', on: st.steam > .2 });
L.push({ at: [34, 3, 29.5], text: 'GROUP HEAD', on: on && st.press > 3 });
L.push({ at: [28, 11, 29], text: 'BREW SWITCH', on: on });
L.push({ at: [BXC + 12, 0, 23], text: 'PORTAFILTER', on: st.pf > .02 && st.pf < .99 });
} else {
L.push({ at: [8, 10.5, 38.4], text: 'TANK LID', on: st.jugging });
L.push({ at: [34, 3, 29], text: 'GROUP HEAD', on: on && st.press > 3 });
L.push({ at: [BXC + 12, 0, 23.2], text: 'PORTAFILTER', on: st.pf > .02 && st.pf < .99 });
L.push({ at: [30.5, 11, 28], text: 'BREW BUTTON', on: on });
L.push({ at: [30.2, -11, 29.5], text: 'PRESSURE GAUGE', on: on && st.press > 1 });
L.push({ at: [20, -20.5, 31], text: 'STEAM KNOB', on: st.steam > .2 });
L.push({ at: [20, -23.4, 18], text: 'STEAM WAND', on: st.steam > .2 });
L.push({ at: [36, 6, 3], text: 'DRIP TRAY', on: false });
}
return L;
}
/* page wiring */
CV = $('cv'); ctx = CV.getContext('2d');
const STATICS = STATIC;
function resize() { const r = CV.getBoundingClientRect(), d = Math.min(2, window.devicePixelRatio || 1); CV.width = Math.round(r.width * d); CV.height = Math.round(r.height * d); }
window.addEventListener('resize', resize); resize();
function frame() {
const dyn = buildDynamic(st);
const k = renderFrame(st, { solids: STATICS.concat(dyn), overlay });
if (st.labels) drawLabels(labelList(k), $('lbl')); else if ($('lbl').dataset.k) { $('lbl').dataset.k = ''; $('lbl').innerHTML = ''; }
$('lbl').style.display = (st.labels && CV.clientWidth < 560) ? 'block' : 'none';
$('stats').innerHTML = '<div>Temp<span>' + Math.round(st.temp) + ' C</span></div><div>Water<span>' + Math.round(st.tank * 100) + '%</span></div><div>Pressure<span>' + st.press.toFixed(1) + ' bar</span></div>';
}
let last = 0;
function loop(ts) {
const dt = Math.min(.05, (ts - last) / 1000 || .016); last = ts;
tick(dt); if (st.auto) YAW += 14 * dt; frame(); requestAnimationFrame(loop);
}
/* buttons */
const bar = $('bar');
BUTTONS.forEach(b => { const e = document.createElement('button'); e.className = 'btn' + (b[2] ? ' main' : ''); e.textContent = b[0]; e.dataset.n = b[0]; e.onclick = () => b[1](); bar.appendChild(e); });
function sw(label, get, set) { const e = document.createElement('button'); e.className = 'btn sw'; e.textContent = label; e.onclick = () => { set(!get()); e.classList.toggle('on', get()); }; $('top').appendChild(e); return e; }
const swXr = sw('See-through', () => st.xrT > 0, v => st.xrT = v ? 1 : 0);
const swLb = sw('Labels', () => st.labels, v => st.labels = v);
const swTt = sw('Turntable', () => st.auto, v => st.auto = v);
const fv = document.createElement('button'); fv.className = 'btn sw'; fv.textContent = 'Front view'; fv.onclick = () => { YAW = 0; PITCH = 14; st.auto = false; swTt.classList.remove('on'); }; $('top').appendChild(fv);
$('zin').onclick = () => ZOOM = clamp(ZOOM * 1.2, .5, 3.5); $('zout').onclick = () => ZOOM = clamp(ZOOM / 1.2, .5, 3.5);
CV.addEventListener('wheel', e => { e.preventDefault(); ZOOM = clamp(ZOOM * (e.deltaY < 0 ? 1.1 : 1 / 1.1), .5, 3.5); }, { passive: false });
/* pointer: tap a part to press it, drag to turn */
function inPoly(p, P) { let c = false; for (let i = 0, j = P.length - 1; i < P.length; j = i++) if ((P[i][1] > p[1]) !== (P[j][1] > p[1]) && p[0] < (P[j][0] - P[i][0]) * (p[1] - P[i][1]) / (P[j][1] - P[i][1]) + P[i][0]) c = !c; return c; }
let drag = null;
CV.addEventListener('pointerdown', e => {
const r = CV.getBoundingClientRect(), p = [e.clientX - r.left, e.clientY - r.top];
let act = null; for (let i = HITS.length - 1; i >= 0; i--) if (inPoly(p, HITS[i].poly)) { act = HITS[i].act; break; }
drag = { x: e.clientX, y: e.clientY, act, moved: false }; CV.setPointerCapture(e.pointerId); CV.classList.add('drag');
});
CV.addEventListener('pointermove', e => {
if (!drag) return; const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true;
if (drag.moved) { YAW -= dx * .45; PITCH = clamp(PITCH + dy * .35, 4, 82); drag.x = e.clientX; drag.y = e.clientY; st.auto = false; swTt.classList.remove('on'); }
});
CV.addEventListener('pointerup', e => { if (drag && !drag.moved && drag.act && ACTS[drag.act]) ACTS[drag.act](); drag = null; CV.classList.remove('drag'); });
window.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') YAW += 6; else if (e.key === 'ArrowRight') YAW -= 6; else if (e.key === 'ArrowUp') PITCH = clamp(PITCH + 4, 4, 82); else if (e.key === 'ArrowDown') PITCH = clamp(PITCH - 4, 4, 82);
else if (e.key === ' ') { e.preventDefault(); actBrew(); }
});
/* url params: ?yaw=&pitch=&xr=1&labels=1&press=Brew@300,...&zoom= */
(function () {
const q = new URLSearchParams(location.search);
if (q.has('yaw')) YAW = +q.get('yaw'); if (q.has('pitch')) PITCH = +q.get('pitch'); if (q.has('zoom')) ZOOM = +q.get('zoom');
if (q.get('xr') === '1') { st.xrT = 1; st.xr = 1; swXr.classList.add('on'); }
if (q.get('labels') === '1') { st.labels = true; swLb.classList.add('on'); }
if (q.get('tank')) st.tank = +q.get('tank');
if (q.has('press')) q.get('press').split(',').forEach(s => { const [n, ms] = s.split('@'); setTimeout(() => { const b = BUTTONS.find(b => b[0] === n); if (b) b[1](); }, +ms || 0); });
})();
window.addEventListener('error', e => { document.documentElement.dataset.errors = (document.documentElement.dataset.errors || '') + e.message + ' @' + e.lineno + ' | '; });
requestAnimationFrame(loop);
</script></body></html>
retro-espresso-machine.html
PNG · 1536×1024
Example file from a real run - the skill writes it into your workspace.
Connects securely to your tools. The creator never sees your data.
About this skill
Building interactive 3D technical illustrations usually requires heavy game engines or complex WebGL setups that are difficult to maintain and integrate. colorfigures-pro replaces these with a lightweight, hand-inked isometric system that produces self-contained HTML files. It solves the problem of showing how complex machines work by providing a built-in "See-through" mode and interactive mechanical simulations without the overhead of traditional 3D frameworks.
What it does
- Isometric Modeling creates objects like appliances, machines, or buildings using a specialized 3D coordinate system.
- Interactive Mechanisms simulates moving parts, button presses, and sequential logic (e.g., coin insertion to item drop).
- X-Ray Visualization toggles a see-through ghost shell to reveal internal components like gears, motors, and wiring.
- Hand-Inked Aesthetics applies flat-shaded tones and variable-weight ink silhouettes for a professional sketched look.
- Automated Sorting handles complex depth-sorting of 3D parts to prevent visual overlapping artifacts without a depth buffer.
How it works
- Model the Object by defining geometry, materials, and internal parts in a figure script using provided geometric primitives.
- Define Interactions by creating buttons and animation sequences that move parts along realistic physical paths.
- Configure Views by setting camera limits, enabling full 360-degree rotation, and adding localized labels for components.
- Compile and Ship using the build script to inline dependencies into a single, portable HTML file ready for browser viewing.
Frameworks & tools
This skill uses a custom JavaScript engine for rendering and a Python 3 build script for asset inlining. It produces standard HTML/SVG output compatible with modern browsers and mobile devices.
Why this beats prompting it yourself
Writing raw SVG or Canvas code for interactive 3D objects frequently leads to depth-sorting bugs and broken perspective. This skill includes a specialized engine that handles camera math, realistic shading, and complex z-index sorting that standard LLM prompts fail to solve consistently.
Use cases
- Technical Documentation showing the internal components of industrial hardware.
- Educational Interactives explaining how everyday machines like toasters or pumps function.
- Product Landing Pages featuring a playable, 360-degree stylized version of a product.
- Internal Tools for visualizing complex assembly structures or building layouts.
Known limitations
The engine does not support a depth buffer, so complex concave shapes must be built from convex pieces. It lacks support for emoji or symbols in text and requires Python 3 for final builds.
Find more skills like this on the Claude Skills Marketplace, the Codex Skills Marketplace and the Gemini Skills Marketplace, or browse Claude Skills for Frontend & Web Apps.
No depth buffer; requires convex decomposition for concave parts. No support for symbols/emoji. Requires Python 3 for final file compilation. Only supports isometric projection.
How to use
- 1
Connect your agent to the Agensi MCP
Copy the prompt and paste it into Claude, ChatGPT, Cursor or any agent that supports MCP. It sets up the connection for you. Manual setup
- 2
Sign in to Agensi
Your agent shows a sign-in link. Sign in and click Allow.
- 3
Ask for what you need
Your agent loads this skill and uses it on your task. It always asks before buying a skill.
Reviews
No reviews yet
Be one of the first to try it. Every listed skill goes through the trust checks below.
Security scanned
Checked by our 8-point scan before listing
7 uses
Times used to date
Money-back guarantee
Doesn't do what its page promises? You get your money back.
Trust & safety
Security scan
Security scan passed 16 days ago