<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cutting Board Designer</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="icon" href="favicon.ico" sizes="48x48">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<meta name="theme-color" content="#f3f1ee">
<script async src="https://www.googletagmanager.com/gtm.js?id=G-5L2V53QM7R}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-5L2V53QM7R', { page_location: location.origin + location.pathname, page_title: document.title });
</script>
<link rel="preload" href="fonts/barlow-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="fonts/barlow-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin>
<style>
/* ===================== Cutting Board Designer — styles =====================
   A workshop tool: the board renders are the colour; the interface stays
   neutral, dense and legible (at a desk and on a phone at the saw).
   One family (Barlow) for everything; IBM Plex Mono only for step specs/code. */

@font-face{font-family:Barlow;font-weight:400;font-style:normal;font-display:swap;src:url(fonts/barlow-latin-400-normal.woff2) format("woff2")}
@font-face{font-family:Barlow;font-weight:500;font-style:normal;font-display:swap;src:url(fonts/barlow-latin-500-normal.woff2) format("woff2")}
@font-face{font-family:Barlow;font-weight:600;font-style:normal;font-display:swap;src:url(fonts/barlow-latin-600-normal.woff2) format("woff2")}
@font-face{font-family:"IBM Plex Mono";font-weight:400;font-style:normal;font-display:swap;src:url(fonts/ibm-plex-mono-latin-400-normal.woff2) format("woff2")}

:root{
  /* neutrals: a pale, slightly warm workshop grey — not cream */
  --ground:oklch(95.2% .004 80);
  --surface:oklch(99.2% .002 80);
  --sunk:oklch(96.6% .004 80);          /* insets: render stages, code, hovers */
  --line:oklch(89.5% .006 75);
  --line2:oklch(66% .012 70);           /* control borders: 3:1 against the surface */
  --ink:oklch(23% .015 55);             /* walnut-dark text */
  --ink2:oklch(38% .014 60);            /* secondary text that still reads at the saw */
  --mute:oklch(48% .014 65);            /* metadata, ≥4.5:1 on --surface and --ground */
  /* one accent: the sienna of the old badge, for action, focus and selection only */
  --accent:oklch(49% .13 42);
  --accent-ink:oklch(42% .12 42);
  --accent-wash:oklch(95% .025 50);
  --sel:oklch(86% .06 50);
  /* states */
  --err:oklch(45% .15 30); --err-bg:oklch(95% .03 30);
  --warn:oklch(45% .09 75); --warn-bg:oklch(95.5% .045 90);
  /* data: grain classes (end / edge / face) */
  --g0:oklch(58% .17 40); --g1:oklch(52% .14 255); --g2:oklch(56% .13 150);
  /* step types — functional colour, used only on the step letter */
  --stock:oklch(45% .07 60); --finish:oklch(52% .1 80); --cut:oklch(50% .14 35);
  --transform:oklch(47% .12 300); --glue:oklch(47% .08 175); --trim:oklch(47% .03 240);
  --r:6px; --r2:8px;
  --ctl:32px;                           /* control height */
  --sans:Barlow,"Segoe UI",system-ui,sans-serif;
  --monof:"IBM Plex Mono",ui-monospace,monospace;
  color-scheme:light;
  accent-color:var(--accent);
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html,body{margin:0;background:var(--ground);color:var(--ink);font:15px/1.45 var(--sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
::selection{background:var(--sel);color:var(--ink)}
input,textarea{caret-color:var(--accent)}
html{scrollbar-color:var(--line2) transparent}
button,input,select,textarea{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.mono{font-variant-numeric:tabular-nums}
code{font:13px var(--monof);background:var(--sunk);border:1px solid var(--line);border-radius:4px;padding:0 4px}
svg.i{width:16px;height:16px;flex:none;stroke:currentColor;fill:none;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round;vertical-align:-3px}

/* ---------- header ---------- */
header{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:10px 18px;border-bottom:1px solid var(--line);background:var(--surface)}
h1{font:600 19px/1 var(--sans);margin:0;display:flex;align-items:center;gap:10px;letter-spacing:-.005em}
h1 .logo{width:22px;height:22px;border-radius:3px;background:
  linear-gradient(90deg,oklch(88% .045 80) 0 25%,oklch(36% .045 50) 25% 50%,oklch(88% .045 80) 50% 75%,oklch(36% .045 50) 75%)}
#projname{border:1px solid transparent;background:transparent;border-radius:var(--r);padding:4px 8px;min-width:120px;max-width:260px;color:var(--ink2);height:var(--ctl)}
#projname:hover{border-color:var(--line)}
#projname:focus{border-color:var(--line2);background:var(--surface);outline:none}
.hgrow{flex:1}
.hctl{display:flex;flex-wrap:wrap;gap:6px;align-items:center}

/* ---------- controls ---------- */
.btn,select,.seg button{border:1px solid var(--line2);background:var(--surface);border-radius:var(--r);padding:0 10px;min-height:var(--ctl);cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:background-color .15s,border-color .15s}
select{padding:0 28px 0 9px;appearance:none;background:var(--surface) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23615548' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 10px center}
.btn:hover,.seg button:hover,select:hover{border-color:var(--ink2)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent-ink);border-color:var(--accent-ink)}
.btn:disabled,.seg button:disabled{opacity:.5;cursor:default}
.seg{display:inline-flex}
.seg button{border-radius:0;margin-left:-1px}
.seg button:first-child{border-radius:var(--r) 0 0 var(--r);margin-left:0}
.seg button:last-child{border-radius:0 var(--r) var(--r) 0}
.seg button.on{background:var(--ink);color:var(--surface);border-color:var(--ink);position:relative;z-index:1}
.seg button:focus-visible{position:relative;z-index:2}
input[type=checkbox]{width:16px;height:16px;margin:0}

/* ---------- layout ---------- */
.layout{display:grid;grid-template-columns:minmax(380px,560px) 1fr;gap:20px;padding:20px;align-items:start}
.pctl .seg{flex-wrap:wrap}
h2{font:600 15px/1.3 var(--sans);margin:0}
.colhead{display:flex;justify-content:space-between;align-items:center;margin:20px 2px 10px}
.colhead label{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--mute)}
.colhead select{min-height:28px;font-size:13px}

/* ---------- step cards ---------- */
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r2);margin-bottom:14px;position:relative}
.card.sel{border-color:var(--accent)}
.card.sel .head{background:var(--accent-wash)}
.card::before{content:"";position:absolute;left:24px;top:-15px;height:14px;border-left:1px dashed var(--line2)}
.card:first-child::before{display:none}
.head{display:flex;align-items:center;gap:8px;padding:8px 8px 8px 12px;border-bottom:1px solid var(--line);border-radius:var(--r2) var(--r2) 0 0;cursor:pointer}
.badge{font:600 13px/1 var(--sans);font-variant-numeric:tabular-nums;color:#fff;min-width:24px;height:24px;border-radius:4px;display:grid;place-items:center;padding:0 5px;background:var(--c)}
.t-stock{--c:var(--stock)} .t-finish{--c:var(--finish)} .t-cut{--c:var(--cut)} .t-transform{--c:var(--transform)} .t-glue{--c:var(--glue)} .t-trim{--c:var(--trim)}
.ctype{font-weight:600;white-space:nowrap}
.note{flex:1;min-width:40px;border:1px solid transparent;background:transparent;border-radius:var(--r);padding:3px 6px;color:var(--ink2);text-overflow:ellipsis}
.note:hover{border-color:var(--line)}
.note:focus{border-color:var(--line2);background:var(--surface);outline:none}
.cbtns{display:flex;gap:0;margin-left:auto}
.icon{border:0;background:transparent;width:30px;height:30px;border-radius:var(--r);cursor:pointer;color:var(--mute);display:inline-grid;place-items:center;padding:0}
.icon:hover{background:var(--sunk);color:var(--ink)}
.icon.danger:hover{background:var(--err-bg);color:var(--err)}
.body{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px 12px;padding:12px}
.fld{display:flex;flex-direction:column;gap:4px;min-width:0}
.fld.wide{grid-column:1/-1}
.fld.wide2{grid-column:span 2}
.fl{font-size:13px;font-weight:500;color:var(--ink2)}
.hint{font-size:13px;color:var(--mute);text-wrap:pretty}
.fld input[type=text],.fld input[type=number],.fld select,.op select,.op input{width:100%;border:1px solid var(--line2);border-radius:var(--r);padding:0 9px;min-height:var(--ctl);background-color:var(--surface);min-width:0;font-variant-numeric:tabular-nums}
.fld select,.op select{padding-right:28px}
.fld input:focus,.fld select:focus,.op input:focus,.op select:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
input.mono{font-family:var(--monof);font-size:14px}
#popts.on{background:var(--ink);color:var(--surface);border-color:var(--ink)}
.row{display:flex;gap:8px;align-items:center}
.swatch{width:24px;height:24px;border-radius:4px;flex:none;box-shadow:inset 0 0 0 1px oklch(0% 0 0/.18)}
.swatch.sm{width:14px;height:14px;border-radius:3px}
.chk{grid-column:1/-1;display:flex;gap:8px;align-items:center;font-size:14px;cursor:pointer}
.chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
.chip{border:1px solid var(--line);background:var(--sunk);border-radius:4px;padding:1px 7px;font:13px var(--sans);font-variant-numeric:tabular-nums;color:var(--ink2);cursor:pointer}
.chip:hover{border-color:var(--line2);color:var(--ink)}
.hint .chip,.hint code{font-size:12px}
.ops{display:flex;flex-direction:column;gap:6px}
.op{display:grid;grid-template-columns:1.2fr auto 1.4fr auto .8fr auto;gap:6px;align-items:center}
.op input{width:90px}
.op .by{font-size:13px;color:var(--mute)}
.out{padding:0 12px 12px}
.summary{font-size:13px;font-variant-numeric:tabular-nums;color:var(--mute);padding-top:2px}
.err{color:var(--err);background:var(--err-bg);border-radius:var(--r);padding:6px 9px;margin-top:6px;font-size:14px}
.warn{color:var(--warn);background:var(--warn-bg);border-radius:var(--r);padding:6px 9px;margin-top:6px;font-size:14px}
.thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:flex-end}
.thumb{border:1px solid transparent;background:transparent;padding:3px;border-radius:var(--r);cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:2px}
.thumb canvas,.thumb img{display:block}
.thumb:hover{border-color:var(--line2)}
.thumb.on{border-color:var(--accent);background:var(--accent-wash)}
.tn{font-size:12px;font-variant-numeric:tabular-nums;color:var(--mute)}
.more{font-size:13px;color:var(--mute);align-self:center}
#addbar{display:flex;flex-wrap:wrap;gap:6px;padding:4px 0 12px}
.add{border:1px solid var(--line2);color:var(--ink);background:var(--surface);border-radius:var(--r);padding:0 10px 0 8px;min-height:var(--ctl);cursor:pointer;font-weight:500;display:inline-flex;align-items:center;gap:7px}
.add::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--c)}
.add:hover{border-color:var(--c);background:var(--sunk)}

/* ---------- summary panel ---------- */
#materials{font-size:14px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r2);padding:12px 14px;margin-bottom:6px}
#materials:empty{display:none}
.mt{font:600 15px/1.3 var(--sans);margin:0 0 8px}
.mfinal{display:grid;grid-template-columns:auto 1fr;align-items:baseline;gap:2px 12px;margin:0 -6px 10px;padding:6px;border-radius:var(--r);cursor:pointer}
.mfinal:hover{background:var(--sunk)}
.mfinal .mfl{font-size:14px;color:var(--ink2)}
.mfinal b{font-size:20px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.005em}
.mfinal .mnote{grid-column:2;font-size:13px;color:var(--mute)}
.mfinal.mwaste{margin-top:-8px;cursor:default}
.mfinal.mwaste:hover{background:none}
.mfinal.mwaste b{font-size:17px}
.msub{font-size:14px;font-weight:600;color:var(--ink);margin:14px 0 6px}
.shop{display:grid;grid-template-columns:auto auto 1fr;gap:3px 10px;margin:0 0 4px;align-items:baseline}
.shop .n{font-weight:600;font-variant-numeric:tabular-nums;text-align:right}
.shop .l{color:var(--ink)}
.shop .d{color:var(--mute);font-size:13px}
.mrow{display:grid;grid-template-columns:auto 1fr auto;gap:8px;align-items:center;padding:2px 0}
.mgroup{border-top:1px solid var(--line);padding:8px 0 4px}
.mhead b{font-weight:600}
.mhead .mono{color:var(--ink2)}
.mitem{display:grid;grid-template-columns:2.6em 1fr auto;gap:8px;align-items:baseline;padding:4px 6px 4px 22px;margin:0 -6px;cursor:pointer;border-radius:var(--r)}
.mitem:hover{background:var(--sunk)}
.mitem .mnote{display:block;font-size:13px;color:var(--mute)}
.mitem .mbf{font-size:13px;color:var(--mute);white-space:nowrap}
.mitem .mq{color:var(--ink2)}
.munused{display:inline-block;margin-top:2px;font-size:12px;color:var(--warn);background:var(--warn-bg);border-radius:3px;padding:0 5px}

/* ---------- preview ---------- */
.viewbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px}
.viewbar label{display:flex;gap:6px;align-items:center;font-size:14px}
.viewbar .inl{display:none}
.inline3d{position:relative;margin-top:10px}
.inline3d canvas,.inline3d img{display:block;width:100%;border-radius:var(--r);background:var(--sunk)}
.card.sel .inline3d canvas,.card.sel .inline3d img{outline:1px solid var(--line2)}
.inline3d .icv{cursor:pointer}
.inline3d .ihint{font-size:13px;color:var(--mute);margin-top:6px}
.inline3d .iclose{position:absolute;top:8px;right:8px;width:34px;height:34px;border-radius:var(--r);border:1px solid var(--line2);background:var(--surface);cursor:pointer;display:grid;place-items:center}
#preview{position:sticky;top:12px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r2);padding:14px 16px 16px;min-width:0}
#pcollapse,#popts,.pbtns{display:none}
.ptop{display:flex;flex-wrap:wrap;justify-content:space-between;gap:4px 12px;align-items:baseline}
#ptitle{font:600 17px/1.3 var(--sans);text-wrap:balance}
#ptitle .pnote{font-weight:400;color:var(--ink2)}
#pdims{font-size:14px;font-variant-numeric:tabular-nums;color:var(--ink2);white-space:pre}
.pctl{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:center;margin:12px 0}
.pctl .seg button,.pctl .btn,#glctl select{padding-left:9px;padding-right:9px;font-size:14px}
#glctl select{padding-right:26px}
.pctl label{display:flex;gap:6px;align-items:center;font-size:14px;cursor:pointer}
#glctl{margin-top:-4px}
#stage{min-height:200px;display:flex;align-items:center;justify-content:center;background:var(--sunk);border-radius:var(--r);padding:10px;overflow:hidden}
.facecv{display:block;max-width:100%;touch-action:none}
.glcv{cursor:grab}.glcv:active{cursor:grabbing}
.empty{color:var(--mute);padding:30px;text-align:center}
#readout{min-height:22px;font-size:14px;margin-top:8px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;color:var(--ink2)}
#readout i,.legend i{display:inline-block;width:10px;height:10px;border-radius:2px}
.g0{background:var(--g0)} .g1{background:var(--g1)} .g2{background:var(--g2)}
.gtxt{color:#fff;padding:0 4px;border-radius:3px}
#pstats{margin-top:8px}
.srow{display:flex;align-items:center;gap:10px}
.sl{font-size:13px;color:var(--mute);white-space:nowrap}
.bar{flex:1;display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--line);gap:1px}
.bar span{min-width:2px;font-size:0}
.legend{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:13px;font-variant-numeric:tabular-nums;margin-top:6px;color:var(--ink2)}
.legend span{display:flex;align-items:center;gap:6px}

/* ---------- dialogs ---------- */
.overlay{position:fixed;inset:0;background:oklch(20% .02 55/.4);display:grid;place-items:center;z-index:50;padding:14px}
.dlg{background:var(--surface);border-radius:var(--r2);max-width:560px;width:100%;max-height:90vh;overflow:auto;padding:16px 18px;box-shadow:0 12px 40px oklch(20% .03 55/.25),0 2px 6px oklch(20% .03 55/.12)}
.dh{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:17px}
.da{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}
#cloudid{font-size:13px;color:var(--ink2);background:var(--sunk)}
.cloud h4{margin:4px 0 6px;font-size:14px}
.cloud .cid{display:grid;gap:6px;margin-bottom:8px}
.cloud code,.mine code{font-size:13px}
.row.wrap{flex-wrap:wrap}
.openrow{margin-top:10px}
.openrow input{flex:1;min-width:0;border:1px solid var(--line2);border-radius:var(--r);padding:0 9px;min-height:var(--ctl);background:var(--surface)}
.mine{margin-top:12px}.mine summary,.jsonbox summary{cursor:pointer;font-size:14px;font-weight:600;margin:6px 0}
.dlg .mrow{display:flex;gap:8px;align-items:center;padding:4px 0;border-top:1px solid var(--line)}
.mrow.on .mname{font-weight:600}.mrow .mname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.jsonbox{margin-top:12px;border-top:1px solid var(--line);padding-top:4px}
.acct{display:grid;gap:12px}
.acct .fld input{width:100%;border:1px solid var(--line2);border-radius:var(--r);padding:0 9px;min-height:var(--ctl);background:var(--surface)}
.acct .fld input:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
.acct h4{margin:4px 0 6px;font-size:14px}
.acct-grid{grid-template-columns:repeat(2,minmax(0,1fr));padding:0 0 10px}
.acctline{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;padding:0 0 10px;margin-bottom:6px;border-bottom:1px solid var(--line)}
.acctline .btn{margin-left:auto}
.cloud h4{margin:14px 0 6px}
.mylist{display:flex;flex-direction:column;max-height:40vh;overflow:auto}
.mylist .mrow select{min-height:30px;font-size:13px;padding-left:7px;max-width:13em}
.mylist .mrow .btn{min-height:30px;padding:0 8px;font-size:13px}
.danger-btn{color:var(--err);border-color:var(--err)}
.danger-btn:hover{background:var(--err-bg)}
input[type=search]{flex:1;min-width:0;border:1px solid var(--line2);border-radius:var(--r);padding:0 9px;min-height:var(--ctl);background:var(--surface)}
@media (max-width:520px){.acct-grid{grid-template-columns:1fr}.mylist .mrow{flex-wrap:wrap}.mylist .mrow .mname{flex-basis:100%}}
.cstatus{display:block;margin-top:10px;min-height:1em}
.dlg textarea{width:100%;border:1px solid var(--line2);border-radius:var(--r);padding:8px;font:12px var(--monof);background:var(--surface)}
.woods{display:flex;flex-direction:column;gap:4px;max-height:46vh;overflow:auto;margin-bottom:10px}
.wrow{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:4px 0;border-bottom:1px solid var(--line)}
.addwood{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.addwood input[type=text]{flex:1;min-width:150px;border:1px solid var(--line2);border-radius:var(--r);padding:0 9px;min-height:var(--ctl)}
.addwood input[type=color]{width:42px;height:34px;border:1px solid var(--line2);border-radius:var(--r);padding:2px;background:var(--surface)}
.help{max-width:68ch}
.help p,.help li{margin:6px 0;text-wrap:pretty}
.help ul{padding-left:18px}

/* ---------- narrow screens: the preview comes first, controls tuck away ---------- */
@media (max-width:900px){
  :root{--ctl:40px}
  html,body{font-size:16px}
  .layout{grid-template-columns:minmax(0,1fr);padding:10px;gap:12px}
  header{padding:8px 10px}
  h1{font-size:18px}
  #preview{order:-1;position:static;padding:12px}
  #pcollapse,#popts{display:inline-flex}
  .pbtns{display:flex}
  .ptop{align-items:center}
  .pbtns{display:flex;gap:4px;margin-left:auto}
  #ptitle{font-size:16px}
  #ptitle .pnote{display:none}
  #pdims{order:3;flex-basis:100%}
  #preview .pctl{margin:10px 0}
  #preview:not(.opts) .pctl .opt,#preview:not(.opts) #glctl{display:none!important}
  #preview.collapsed #stage,#preview.collapsed .pctl,#preview.collapsed #glctl,#preview.collapsed #pstats,#preview.collapsed #readout,#preview.collapsed #popts{display:none!important}
  #pstats{display:none}
  .card{scroll-margin-top:12px}
  #readout{font-size:14px;margin-top:8px}
  .fld input[type=text],.fld input[type=number],.fld select,.op select,.op input,input.mono{font-size:16px}
  .icon{width:40px;height:40px}
  .head{flex-wrap:wrap;row-gap:2px}
  .head .note{order:5;flex-basis:100%;margin:0 0 2px 26px}
}
@media (max-width:520px){
  .body{grid-template-columns:repeat(2,minmax(0,1fr))}
  .op{grid-template-columns:1fr 1fr}.op .by{display:none}
  .mitem{grid-template-columns:2.4em 1fr}.mitem .mbf{grid-column:2}
  .hctl{width:100%}
  .hctl #examples{flex:1 1 100%;min-width:0}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}

</style>
</head>
<body>
<header>
  <h1><span class="logo"></span>Cutting Board Designer</h1>
  <input id="projname" placeholder="Untitled design" aria-label="Design name">
  <button id="cloudid" class="btn" hidden></button>
  <span class="hgrow"></span>
  <div class="hctl">
    <select id="examples" aria-label="Load an example"></select>
    <span class="seg"><button data-units="in">in</button><button data-units="mm">mm</button></span>
    <button class="btn" id="btn-woods">Woods</button>
    <button class="btn" id="btn-project">Save / load</button>
    <button class="btn" id="btn-account">Sign in</button>
    <button class="btn" id="btn-help">Help</button>
  </div>
</header>
<div class="layout">
  <section>
    <div id="materials"></div>
    <div class="colhead"><h2>Steps</h2>
      <label>Thumbnails <select id="thumbface"><option value="top">Top</option><option value="end">End</option><option value="front">Front</option><option value="3d">3D (WebGL)</option></select></label></div>
    <div id="steps"></div>
    <div id="addbar"></div>
  </section>
  <aside id="preview">
    <div class="ptop"><div id="ptitle"></div><div id="pdims"></div>
      <span class="pbtns"><button class="btn" id="popts" aria-expanded="false" title="Show all view options"><svg class="i" viewBox="0 0 24 24"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg>View</button>
      <button class="btn" id="pcollapse" aria-expanded="true" title="Hide or show the preview"><svg class="i" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg><span>Hide</span></button></span></div>
    <div class="pctl">
      <span class="seg">
        <button data-face="3d">3D</button><button data-face="top">Top</button><button data-face="front">Front</button><button data-face="end">End</button><button data-face="bottom">Bottom</button><button data-face="plan" id="tab-plan">Cut plan</button>
      </span>
      <span class="seg opt"><button data-mode="wood" class="on">Wood</button><button data-mode="grain">Grain map</button></span>
      <span class="seg opt" title="Renderer for the 3D view"><button data-renderer="classic">Classic</button><button data-renderer="webgl">WebGL</button></span>
      <label class="opt"><input type="checkbox" id="glue" checked> Glue lines</label>
      <label class="opt"><input type="checkbox" id="oiled" checked> Oiled</label>
      <button class="btn opt" id="btn-reseed" title="New random figure for every board"><svg class="i" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg>New figure</button>
    </div>
    <div id="glctl" class="pctl" style="display:none">
      <span class="seg"><button data-cam="iso">Iso</button><button data-cam="top">Top</button><button data-cam="front">Front</button><button data-cam="end">End</button><button data-cam="under">Under</button></span>
      <button class="btn" data-cam="reset">Reset view</button>
      <span class="seg" id="glall"><button data-glall="one">This piece</button><button data-glall="all">All pieces</button></span>
      <label title="Lay the offcuts out beside the board"><input type="checkbox" id="offcuts"> Offcuts</label>
      <label title="Put a familiar object beside the board">Scale <select id="scaleobj"><option value="none">None</option><option value="ruler">12″ ruler</option><option value="banana">Banana</option></select></label>
    </div>
    <div id="stage"></div>
    <div id="readout"></div>
    <div id="pstats"></div>
  </aside>
</div>
<script>
// ===================== Cutting board designer — core model =====================
// Every piece is a node. Sampling a point in a piece's local box walks down to the
// source board, carrying the point and the view direction, so grain is exact.
// Node types: 0 = board, 1 = ref (affine view of a child), 2 = glue (children along an axis)

// pore: amount · poreSize: cell size (in) · ringPorous: big pores in earlywood · rays: strength · raySp: spacing · rayLen: fleck length
const SPECIES_BUILTIN = [
  { id: 'maple', name: 'Hard Maple', base: '#ecd9b4', dark: '#c9a678', ring: 0.11, contrast: 0.26, wave: 0.22, pore: 0.25, poreSize: 0.008, rays: 0.35, raySp: 0.05, rayLen: 0.15, hue: 0.03 },
  { id: 'walnut', name: 'Black Walnut', base: '#674632', dark: '#2a1910', ring: 0.15, contrast: 0.5, wave: 0.3, pore: 0.6, poreSize: 0.014, rays: 0.15, raySp: 0.06, rayLen: 0.2, hue: 0.07 },
  { id: 'cherry', name: 'Cherry', base: '#c27148', dark: '#87421f', ring: 0.12, contrast: 0.36, wave: 0.25, pore: 0.35, poreSize: 0.009, rays: 0.3, raySp: 0.06, rayLen: 0.2, hue: 0.05 },
  { id: 'sapele', name: 'Sapele', base: '#96532f', dark: '#5c2d17', ring: 0.09, contrast: 0.35, wave: 0.06, pore: 0.5, poreSize: 0.011, rays: 0.1, ribbon: 0.16, hue: 0.04 },
  { id: 'whiteoak', name: 'White Oak', base: '#d2af7a', dark: '#8c6638', ring: 0.18, contrast: 0.55, wave: 0.35, pore: 0.9, poreSize: 0.016, ringPorous: true, rays: 1, raySp: 0.12, rayLen: 0.9, hue: 0.04 },
  { id: 'redoak', name: 'Red Oak', base: '#d29c72', dark: '#8f5634', ring: 0.18, contrast: 0.58, wave: 0.35, pore: 1, poreSize: 0.018, ringPorous: true, rays: 0.6, raySp: 0.12, rayLen: 0.5, hue: 0.04 },
  { id: 'ash', name: 'White Ash', base: '#e5d1a8', dark: '#ab8c57', ring: 0.17, contrast: 0.55, wave: 0.35, pore: 0.85, poreSize: 0.014, ringPorous: true, rays: 0.15, raySp: 0.07, rayLen: 0.2, hue: 0.03 },
  { id: 'hickory', name: 'Hickory', base: '#dbbe91', dark: '#9b7547', ring: 0.15, contrast: 0.45, wave: 0.3, pore: 0.6, poreSize: 0.013, ringPorous: true, rays: 0.15, raySp: 0.07, rayLen: 0.2, hue: 0.08 },
  { id: 'mahogany', name: 'Mahogany', base: '#9e5634', dark: '#66331c', ring: 0.12, contrast: 0.28, wave: 0.12, pore: 0.6, poreSize: 0.013, rays: 0.15, ribbon: 0.1, hue: 0.04 },
  { id: 'purpleheart', name: 'Purpleheart', base: '#813f72', dark: '#4b1f43', ring: 0.12, contrast: 0.28, wave: 0.12, pore: 0.45, poreSize: 0.011, rays: 0.1, hue: 0.05 },
  { id: 'padauk', name: 'Padauk', base: '#cc4b2c', dark: '#8a2a15', ring: 0.12, contrast: 0.3, wave: 0.15, pore: 0.6, poreSize: 0.014, rays: 0.1, hue: 0.06 },
  { id: 'yellowheart', name: 'Yellowheart', base: '#e6be41', dark: '#b18a1c', ring: 0.12, contrast: 0.25, wave: 0.15, pore: 0.35, poreSize: 0.009, rays: 0.1, hue: 0.04 },
  { id: 'bloodwood', name: 'Bloodwood', base: '#a32e26', dark: '#621612', ring: 0.1, contrast: 0.25, wave: 0.12, pore: 0.35, poreSize: 0.009, rays: 0.1, hue: 0.04 },
  { id: 'wenge', name: 'Wenge', base: '#5a432f', dark: '#17100a', ring: 0.1, contrast: 0.8, wave: 0.08, pore: 0.9, poreSize: 0.016, rays: 0.2, raySp: 0.05, rayLen: 0.15, hue: 0.04 },
  { id: 'zebrawood', name: 'Zebrawood', base: '#dec28c', dark: '#3f2c1a', ring: 0.26, contrast: 0.85, wave: 0.06, pore: 0.5, poreSize: 0.012, rays: 0.05, hue: 0.03 },
];

function hexToRgb(h) {
  const m = /^#?([0-9a-f]{6})$/i.exec(h || '');
  if (!m) return [128, 128, 128];
  const n = parseInt(m[1], 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function darkenHex(h, f) {
  const c = hexToRgb(h).map(v => Math.round(v * f));
  return '#' + c.map(v => v.toString(16).padStart(2, '0')).join('');
}
function speciesMap(custom) {
  const map = {};
  for (const s of SPECIES_BUILTIN.concat(custom || [])) {
    const dark = s.dark || darkenHex(s.base, 0.62);
    map[s.id] = Object.assign({ ring: 0.13, contrast: 0.45, wave: 0.2, pore: 0.3, poreSize: 0.011, rays: 0.15, raySp: 0.07, rayLen: 0.25, hue: 0.04 }, s, {
      dark, rgb: hexToRgb(s.base), drgb: hexToRgb(dark),
    });
  }
  return map;
}

function hashStr(s) {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return h >>> 0;
}
function rng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6D2B79F5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// ---------- node constructors ----------
let UID = 0;
const mk = o => { o.uid = ++UID; return o; };
function boardNode(sp, L, W, T, sawing, seedKey) {
  const r = rng(hashStr(seedKey));
  const R0 = 3.5 + r() * 5;              // distance from pith to the board
  let py, pz;
  if (sawing === 'quarter') { py = -R0; pz = T * (0.2 + 0.6 * r()); }
  else if (sawing === 'rift') { py = -R0 * 0.72; pz = -R0 * 0.72; }
  else { py = W * (0.25 + 0.5 * r()); pz = -R0; }
  return mk({
    t: 0, dims: [L, W, T], sp, py, pz,
    seed: r() * 100, tone: 0.93 + r() * 0.14,
    // the pith is never quite parallel to the board: this tilt is what draws cathedral arches on flat-sawn faces
    ty: (r() - 0.5) * 0.02, tz: sawing === 'quarter' ? (r() - 0.5) * 0.01 : (0.035 + r() * 0.05) * (r() < 0.5 ? -1 : 1),
    id: hashStr(seedKey) & 0xffffff,
  });
}
const snap = v => (Math.abs(v) < 1e-12 ? 0 : Math.abs(v - 1) < 1e-12 ? 1 : Math.abs(v + 1) < 1e-12 ? -1 : v);

function rotMat(axis, deg) {
  const a = deg * Math.PI / 180, c = snap(Math.cos(a)), s = snap(Math.sin(a));
  if (axis === 0) return [1, 0, 0, 0, c, -s, 0, s, c];
  if (axis === 1) return [c, 0, s, 0, 1, 0, -s, 0, c];
  return [c, -s, 0, s, c, 0, 0, 0, 1];
}
function mulMV(M, v) {
  return [M[0] * v[0] + M[1] * v[1] + M[2] * v[2], M[3] * v[0] + M[4] * v[1] + M[5] * v[2], M[6] * v[0] + M[7] * v[1] + M[8] * v[2]];
}
const transpose = M => [M[0], M[3], M[6], M[1], M[4], M[7], M[2], M[5], M[8]];

function rotateNode(node, axis, deg) {
  deg = ((deg % 360) + 360) % 360;
  if (deg === 0) return node;
  const Rm = rotMat(axis, deg);
  const [X, Y, Z] = node.dims;
  const mn = [Infinity, Infinity, Infinity], mx = [-Infinity, -Infinity, -Infinity];
  for (const cx of [0, X]) for (const cy of [0, Y]) for (const cz of [0, Z]) {
    const p = mulMV(Rm, [cx, cy, cz]);
    for (let k = 0; k < 3; k++) { mn[k] = Math.min(mn[k], p[k]); mx[k] = Math.max(mx[k], p[k]); }
  }
  const Rt = transpose(Rm);
  const dims = mx.map((v, k) => Math.max(0, v - mn[k]));
  return mk({ t: 1, dims, child: node, R: Rt, tr: mulMV(Rt, mn), along: axis === 0 ? node.along : undefined, cutOf: axis === 0 ? node.cutOf : undefined });
}

function clipHalf(poly, n, c, keepGreater) {
  const out = [];
  for (let i = 0; i < poly.length; i++) {
    const p = poly[i], q = poly[(i + 1) % poly.length];
    const dp = n[0] * p[0] + n[1] * p[1] - c, dq = n[0] * q[0] + n[1] * q[1] - c;
    const ip = keepGreater ? dp >= 0 : dp <= 0, iq = keepGreater ? dq >= 0 : dq <= 0;
    if (ip) out.push(p);
    if (ip !== iq) { const t = dp / (dp - dq); out.push([p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])]); }
  }
  return out;
}

// Cut a piece (viewed from the top) into strips. alpha: 0 = rip (parallel to length), 90 = crosscut.
// Cut a piece (viewed from the top) into strips. alpha: 0 = rip (parallel to length), 90 = crosscut.
// widthsEnd (optional): taper — each strip goes from widths[k] at the start to widthsEnd[k] at the end.
function cutBevel(node, alpha, beta, widths, kerf, offset, maxStrips, keepRem) {
  const th = -alpha * Math.PI / 180, be = beta * Math.PI / 180;
  const d = [snap(Math.cos(th)), snap(Math.sin(th)), 0], n0 = [-d[1], d[0], 0];
  const nb = [n0[0] * Math.cos(be), n0[1] * Math.cos(be), snap(Math.sin(be))];          // the tilted blade plane's normal
  const w = [d[1] * nb[2] - d[2] * nb[1], d[2] * nb[0] - d[0] * nb[2], d[0] * nb[1] - d[1] * nb[0]];
  const [X, Y, Z] = node.dims;
  const box = boxPoly(X, Y, Z);
  let nmin = Infinity, nmax = -Infinity;
  for (const f of box) for (const p of f) { const v = dot3(nb, p); nmin = Math.min(nmin, v); nmax = Math.max(nmax, v); }
  const pieces = [], lines = [], labels = [], planes = [];
  let a = nmin + offset, i = 0, guard = 0, partial = 0, lastU = a;
  const topLine = c => {   // where the blade plane meets the top face, for the cut plan
    const k = (c - nb[2] * Z) / Math.cos(be);
    return [[d[0] * -1e3 + n0[0] * k, d[1] * -1e3 + n0[1] * k], [d[0] * 1e3 + n0[0] * k, d[1] * 1e3 + n0[1] * k]];
  };
  while (a < nmax - 1e-6 && (!maxStrips || pieces.length < maxStrips) && guard++ < 2000) {
    let s = widths[i % widths.length]; i++;
    if (a + s > nmax + 1e-6) { if (!keepRem) break; s = nmax - a; if (s < 1e-4) break; partial = s; }
    let poly = clipPoly(box, nb, a);
    if (poly) poly = clipPoly(poly, [-nb[0], -nb[1], -nb[2]], -(a + s));
    if (poly) {
      let dmin = Infinity, dmax = -Infinity, wmin = Infinity, wmax = -Infinity;
      for (const f of poly) for (const p of f) { const u = dot3(d, p), v = dot3(w, p); dmin = Math.min(dmin, u); dmax = Math.max(dmax, u); wmin = Math.min(wmin, v); wmax = Math.max(wmax, v); }
      const o = [0, 1, 2].map(k => dmin * d[k] + a * nb[k] + wmin * w[k]);
      pieces.push(mk({ t: 1, dims: [dmax - dmin, s, wmax - wmin], child: node, R: [d[0], nb[0], w[0], d[1], nb[1], w[1], d[2], nb[2], w[2]], tr: o, along: dmin, cutOf: node.uid }));
      const mid = (a + s / 2 - nb[2] * Z / 2) / Math.cos(be);
      labels.push([((dmin + dmax) / 2) * d[0] + mid * n0[0], ((dmin + dmax) / 2) * d[1] + mid * n0[1]]);
    }
    if (a > nmin + 1e-6) planes.push({ n: nb, c: a });
    if (a + s < nmax - 1e-6) planes.push({ n: nb, c: a + s });
    lines.push(topLine(a), topLine(a + s));
    lastU = a + s;
    a += s + kerf;
  }
  let cuts = pieces.length ? pieces.length - 1 + (offset > 1e-6 ? 1 : 0) + (nmax - lastU > 1e-4 ? 1 : 0) : 0;
  return { pieces, plan: { lines, labels, dims: node.dims }, avail: nmax - nmin - offset, partial, cuts, planes };
}
function cutNode(node, alpha, widths, kerf, offset, maxStrips, keepRem, widthsEnd, bevel) {
  if (bevel && !widthsEnd) return cutBevel(node, alpha, bevel, widths, kerf, offset, maxStrips, keepRem);
  const th = -alpha * Math.PI / 180;
  const d = [snap(Math.cos(th)), snap(Math.sin(th))], n = [-d[1], d[0]];
  const [X, Y, Z] = node.dims;
  const rect = [[0, 0], [X, 0], [X, Y], [0, Y]];
  let nmin = Infinity, nmax = -Infinity, dminT = Infinity, dmaxT = -Infinity;
  for (const p of rect) {
    const v = n[0] * p[0] + n[1] * p[1]; nmin = Math.min(nmin, v); nmax = Math.max(nmax, v);
    const u = d[0] * p[0] + d[1] * p[1]; dminT = Math.min(dminT, u); dmaxT = Math.max(dmaxT, u);
  }
  const Ld = dmaxT - dminT;
  const P = (s, a) => [d[0] * (dminT + s * Ld) + n[0] * a, d[1] * (dminT + s * Ld) + n[1] * a];
  const pieces = [], lines = [], labels = [];
  const R = [d[0], n[0], 0, d[1], n[1], 0, 0, 0, 1];
  let A0 = nmin + offset, A1 = A0, i = 0, guard = 0, partial = 0;
  while (Math.min(A0, A1) < nmax - 1e-6 && (!maxStrips || pieces.length < maxStrips) && guard++ < 2000) {
    const w0 = widths[i % widths.length], w1 = widthsEnd ? widthsEnd[i % widthsEnd.length] : w0; i++;
    let U0 = A0 + w0, U1 = A1 + w1;
    if (Math.max(U0, U1) > nmax + 1e-6) {
      if (!keepRem) break;
      U0 = Math.min(U0, nmax); U1 = Math.min(U1, nmax);
      if (U0 - A0 < 1e-4 && U1 - A1 < 1e-4) break;
      partial = Math.max(U0 - A0, U1 - A1);
    }
    if (!widthsEnd) {
      // parallel strip: tight bbox from the clipped polygon
      let poly = clipHalf(rect, n, A0, true);
      poly = clipHalf(poly, n, U0, false);
      if (poly.length >= 3) {
        let dmin = Infinity, dmax = -Infinity;
        for (const p of poly) { const v = d[0] * p[0] + d[1] * p[1]; dmin = Math.min(dmin, v); dmax = Math.max(dmax, v); }
        const o = [dmin * d[0] + A0 * n[0], dmin * d[1] + A0 * n[1], 0];
        pieces.push(mk({ t: 1, dims: [dmax - dmin, U0 - A0, Z], child: node, R, tr: o, along: dmin, cutOf: node.uid }));
        labels.push([((dmin + dmax) / 2) * d[0] + (A0 + U0) / 2 * n[0], ((dmin + dmax) / 2) * d[1] + (A0 + U0) / 2 * n[1]]);
      }
    } else {
      const base = Math.min(A0, A1), top = Math.max(U0, U1);
      const o = [dminT * d[0] + base * n[0], dminT * d[1] + base * n[1], 0];
      const clip = [
        [-(A1 - A0) / Ld, 1, 0, A0 - base],          // y' >= lower line
        [(U1 - U0) / Ld, -1, 0, base - U0],          // y' <= upper line
      ];
      pieces.push(mk({ t: 1, dims: [Ld, top - base, Z], child: node, R, tr: o, clip, along: dminT, cutOf: node.uid }));
      labels.push(P(0.5, (A0 + U0 + A1 + U1) / 4));
    }
    lines.push([P(0, A0), P(1, A1)], [P(0, U0), P(1, U1)]);
    A0 = U0 + kerf; A1 = U1 + kerf;
  }
  // saw cuts: one between each pair of strips, plus one to free the first strip (if there's an offset) and the last (if there's an offcut)
  let cuts = 0;
  if (pieces.length) {
    cuts = pieces.length - 1;
    if (offset > 1e-6) cuts++;
    if (Math.max(nmax - (A0 - kerf), nmax - (A1 - kerf)) > 1e-4) cuts++;
  }
  return { pieces, plan: { lines, labels, dims: node.dims }, avail: nmax - nmin - offset, partial, cuts };
}

const pickFn = (target, list, seed) => {
  if (!target || target === 'none') return () => false;
  const set = target === 'list' ? parseIndexList(list) : null;
  const r = target === 'random' ? rng(hashStr(String(seed))) : null;
  const rnd = [];
  return k => {
    if (target === 'all') return true;
    if (target === 'odd') return k % 2 === 1;
    if (target === 'even') return k % 2 === 0;
    if (set) return set.has(k);
    if (r) { while (rnd.length < k) rnd.push(r() < 0.5); return rnd[k - 1]; }
    return false;
  };
};

// material extent of a piece along axis a in the column at (u,v) (local coords); null if none
function columnExtent(p, a, ua, va, u, v) {
  const L = p.dims[a], N = 40;
  const pt = [0, 0, 0]; pt[ua] = u; pt[va] = v;
  const has = t => { pt[a] = t; return !!sample(p, pt[0], pt[1], pt[2], 0, 0, 1); };
  let first = -1, last = -1;
  for (let k = 0; k <= N; k++) { const t = Math.min(L, Math.max(0, (k / N) * L)); if (has(t)) { if (first < 0) first = k; last = k; } }
  if (first < 0) return null;
  const bis = (inside, outside) => { for (let k = 0; k < 14; k++) { const m = (inside + outside) / 2; if (has(m)) inside = m; else outside = m; } return inside; };
  const lo = first === 0 ? 0 : bis((first / N) * L, ((first - 1) / N) * L);
  const hi = last === N ? L : bis((last / N) * L, ((last + 1) / N) * L);
  return [lo, hi];
}

// Glue pieces along an axis (0 = end to end, 1 = edge to edge, 2 = face to face / stack)
// opts: { align, stagger, pick(k)->bool, offAmount, nest }
// ---- cross-grain check: at each glue line, compare how the two sides move in the plane of the joint.
// Wood swells and shrinks across the grain but hardly along it. Along a direction u in the glue
// plane, a piece moves in proportion to 1 − (g·u)² (g its grain), so the two sides agree when their
// grain, projected onto the glue plane, is the same line: same direction, or mirrored across the
// joint as in a chevron or herringbone. The mismatch is the largest eigenvalue of pb·pbᵀ − pa·paᵀ
// (pa, pb the projected grain); for two grains lying in the joint it is the sine of the angle between them.
function grainDir(node, x, y, z) {
  const g = [0, 0, 0];
  for (let k = 0; k < 3; k++) {
    const d = [0, 0, 0]; d[k] = 1;
    if (!sample(node, x, y, z, d[0], d[1], d[2])) return null;
    g[k] = VX;                                   // the board's length (its grain) is local x
  }
  return g;
}
function crossGrainJoints(node, maxAngle) {
  const out = [];
  if (!node || node.t !== 2 || !node.kids || node.kids.length < 2) return out;
  const ax = node.axis, [ua, va] = [0, 1, 2].filter(k => k !== ax);
  const sinMax = Math.sin((maxAngle || 25) * Math.PI / 180);
  const kids = node.kids.map((q, i) => ({ q, i })).sort((a, b) => a.q.o[ax] - b.q.o[ax]);
  for (let j = 1; j < kids.length; j++) {
    const A = kids[j - 1].q, B = kids[j].q;
    const c = B.o[ax];
    const u0 = Math.max(A.o[ua], B.o[ua]), u1 = Math.min(A.o[ua] + A.node.dims[ua], B.o[ua] + B.node.dims[ua]);
    const v0 = Math.max(A.o[va], B.o[va]), v1 = Math.min(A.o[va] + A.node.dims[va], B.o[va] + B.node.dims[va]);
    if (u1 - u0 < 0.05 || v1 - v0 < 0.05) continue;
    let worst = 0;
    for (const fu of [0.15, 0.5, 0.85]) for (const fv of [0.2, 0.5, 0.8]) {
      const p = [0, 0, 0]; p[ua] = u0 + (u1 - u0) * fu; p[va] = v0 + (v1 - v0) * fv;
      const pa = p.slice(), pb = p.slice(); pa[ax] = c - 0.02; pb[ax] = c + 0.02;
      const ga = grainDir(node, ...pa), gb = grainDir(node, ...pb);
      if (!ga || !gb) continue;
      const d11 = gb[ua] * gb[ua] - ga[ua] * ga[ua], d22 = gb[va] * gb[va] - ga[va] * ga[va], d12 = gb[ua] * gb[va] - ga[ua] * ga[va];
      worst = Math.max(worst, Math.abs(d11 + d22) / 2 + Math.sqrt((d11 - d22) * (d11 - d22) / 4 + d12 * d12));
    }
    if (worst > sinMax) out.push({ a: kids[j - 1].i, b: kids[j].i, angle: Math.round(Math.asin(Math.min(1, worst)) * 180 / Math.PI) });
  }
  // a grid glue-up is rows glued together: check inside each row too
  for (const q of node.kids) if (q.node.t === 2 && q.node !== node) for (const r of crossGrainJoints(q.node, maxAngle)) out.push(Object.assign(r, { inner: true }));
  return out;
}

function glueNodes(pieces, axis, opts) {
  opts = opts || {};
  const align = opts.align || 'start', stagger = opts.stagger || 0;
  if (!pieces.length) throw new Error('Nothing to glue — the input is empty');
  const alignAx = axis === 2 ? [0, 1] : axis === 1 ? (align === 'center' ? [0, 2] : [0]) : (align === 'center' ? [1, 2] : [1]);
  const staggerAx = axis === 0 ? 1 : 0;
  const max = [0, 0, 0];
  for (const p of pieces) for (let k = 0; k < 3; k++) max[k] = Math.max(max[k], p.dims[k]);
  // 'keep': restore positions along the length from the cut that made them
  // rejoin only the pieces that came out of the same cut (the first such piece decides)
  const first = pieces.find(p => p.along != null);
  const withAlong = first ? pieces.filter(p => p.along != null && p.cutOf === first.cutOf) : [];
  const keeps = new Set(withAlong);
  const aMin = withAlong.length ? Math.min(...withAlong.map(p => p.along)) : 0;
  const aMax = withAlong.length ? Math.max(...withAlong.map(p => p.along + p.dims[staggerAx])) : 0;
  const kids = pieces.map((p, i) => {
    const o = [0, 0, 0];
    for (const ax of alignAx) {
      if (align === 'keep' && ax === staggerAx && withAlong.length) o[ax] = keeps.has(p) ? p.along - aMin : (aMax - aMin - p.dims[ax]) / 2;
      else o[ax] = align === 'start' ? 0 : align === 'end' ? max[ax] - p.dims[ax] : (max[ax] - p.dims[ax]) / 2;
    }
    o[staggerAx] += stagger * i + (opts.pick && opts.pick(i + 1) ? (opts.offAmount || 0) : 0);
    return { node: p, o, lo: 0, hi: 0 };
  });
  for (let ax = 0; ax < 3; ax++) {
    if (ax === axis) continue;
    const mn = Math.min(...kids.map(k => k.o[ax]));
    for (const k of kids) k.o[ax] -= mn;
  }
  if (!opts.nest) {
    let cur = 0;
    for (const k of kids) { k.o[axis] = cur; k.lo = cur; cur += k.node.dims[axis]; k.hi = cur; }
  } else {
    // place each piece so it just touches the material already placed (a skyline over columns)
    const [ua, va] = [0, 1, 2].filter(k => k !== axis);
    const U = Math.max(...kids.map(k => k.o[ua] + k.node.dims[ua])), V = Math.max(...kids.map(k => k.o[va] + k.node.dims[va]));
    const budget = 900;
    const nu = Math.max(2, Math.min(240, Math.round(Math.sqrt(budget * U / Math.max(V, 1e-6))))), nv = Math.max(2, Math.min(240, Math.round(budget / nu)));
    const sky = new Float64Array(nu * nv).fill(-Infinity);
    let cur = 0;
    kids.forEach((k, i) => {
      const prof = [];
      for (let iu = 0; iu < nu; iu++) for (let iv = 0; iv < nv; iv++) {
        const gu = (iu + 0.5) / nu * U, gv = (iv + 0.5) / nv * V;
        const lu = gu - k.o[ua], lv = gv - k.o[va];
        prof.push(lu < 0 || lv < 0 || lu > k.node.dims[ua] || lv > k.node.dims[va] ? null : columnExtent(k.node, axis, ua, va, lu, lv));
      }
      let pos = -Infinity;
      prof.forEach((e, ci) => { if (e && sky[ci] > -Infinity) pos = Math.max(pos, sky[ci] - e[0]); });
      if (pos === -Infinity) pos = cur;              // nothing underneath: butt against the previous bbox
      if (i === 0) pos = 0;
      k.o[axis] = pos; k.lo = pos; k.hi = pos + k.node.dims[axis];
      prof.forEach((e, ci) => { if (e) sky[ci] = Math.max(sky[ci], pos + e[1]); });
      cur = Math.max(cur, k.hi);
    });
    const mn = Math.min(...kids.map(k => k.lo));
    for (const k of kids) { k.o[axis] -= mn; k.lo -= mn; k.hi -= mn; }
  }
  const dims = [0, 0, 0];
  for (let ax = 0; ax < 3; ax++) dims[ax] = Math.max(...kids.map(k => k.o[ax] + k.node.dims[ax]));
  return mk({ t: 2, dims, axis, kids, nest: !!opts.nest });
}

// ---------- sampling ----------
let SX = 0, SY = 0, SZ = 0, VX = 0, VY = 0, VZ = 0, KEY = 0;
function sample(n, x, y, z, vx, vy, vz) { KEY = 0; return samp(n, x, y, z, vx, vy, vz); }
function samp(n, x, y, z, vx, vy, vz) {
  for (let guard = 0; guard < 400; guard++) {
    const d = n.dims;
    if (x < 0 || y < 0 || z < 0 || x > d[0] || y > d[1] || z > d[2]) return null;
    if (n.t === 0) { SX = x; SY = y; SZ = z; VX = vx; VY = vy; VZ = vz; KEY = (Math.imul(KEY, 31) + n.id) | 0; return n; }
    if (n.t === 4) { if (!finKeep(n, x, y, z)) return null; n = n.child; continue; }
    if (n.t === 1) {
      if (n.clip) for (const c of n.clip) if (c[0] * x + c[1] * y + c[2] * z < c[3] - 1e-9) return null;
      const R = n.R, t = n.tr;
      const nx = R[0] * x + R[1] * y + R[2] * z + t[0];
      const ny = R[3] * x + R[4] * y + R[5] * z + t[1];
      const nz = R[6] * x + R[7] * y + R[8] * z + t[2];
      const wx = R[0] * vx + R[1] * vy + R[2] * vz;
      const wy = R[3] * vx + R[4] * vy + R[5] * vz;
      const wz = R[6] * vx + R[7] * vy + R[8] * vz;
      x = nx; y = ny; z = nz; vx = wx; vy = wy; vz = wz; n = n.child;
      continue;
    }
    const c = n.axis === 0 ? x : n.axis === 1 ? y : z;
    const kids = n.kids;
    if (n.nest) {
      for (let f = kids.length - 1; f >= 0; f--) {
        const k = kids[f];
        if (c < k.lo || c > k.hi) continue;
        const r = samp(k.node, x - k.o[0], y - k.o[1], z - k.o[2], vx, vy, vz);
        if (r) { KEY = (Math.imul(KEY, 31) + f + 1) | 0; return r; }
      }
      return null;
    }
    let lo = 0, hi = kids.length - 1, f = -1;
    while (lo <= hi) {
      const m = (lo + hi) >> 1;
      if (c < kids[m].lo) hi = m - 1;
      else if (c > kids[m].hi) lo = m + 1;
      else { f = m; break; }
    }
    if (f < 0) return null;
    const k = kids[f];
    KEY = (Math.imul(KEY, 31) + f + 1) | 0;
    x -= k.o[0]; y -= k.o[1]; z -= k.o[2]; n = k.node;
  }
  return null;
}


// grain class of the last sample: 0 end, 1 edge, 2 face
function grainClass() {
  const ax = Math.abs(VX), ay = Math.abs(VY), az = Math.abs(VZ);
  return ax >= ay && ax >= az ? 0 : ay >= az ? 1 : 2;
}

function hash3(a, b, c) {
  let h = Math.imul(a, 374761393) + Math.imul(b, 668265263) + Math.imul(c, 2147483647);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

const GRAIN_RGB = [[214, 72, 22], [28, 116, 214], [47, 158, 68]];
const GRAIN_NAMES = ['End grain', 'Edge grain', 'Face grain'];

function vnoise(x, y, z) {
  const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
  let xf = x - xi, yf = y - yi, zf = z - zi;
  xf = xf * xf * (3 - 2 * xf); yf = yf * yf * (3 - 2 * yf); zf = zf * zf * (3 - 2 * zf);
  const a = hash3(xi, yi, zi), b = hash3(xi + 1, yi, zi), c = hash3(xi, yi + 1, zi), d = hash3(xi + 1, yi + 1, zi);
  const e = hash3(xi, yi, zi + 1), f = hash3(xi + 1, yi, zi + 1), g = hash3(xi, yi + 1, zi + 1), k = hash3(xi + 1, yi + 1, zi + 1);
  const x1 = a + (b - a) * xf, x2 = c + (d - c) * xf, x3 = e + (f - e) * xf, x4 = g + (k - g) * xf;
  const y1 = x1 + (x2 - x1) * yf, y2 = x3 + (x4 - x3) * yf;
  return y1 + (y2 - y1) * zf;
}
const sstep = (a, b, x) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
let PIX = 0.02;       // size of one pixel in inches — set by the renderer, used to filter fine detail
let OILED = true;

// colour of the last sample into buf at index o
function shade(b, cls, mode, bright, buf, o) {
  const sp = b.sp, sd = b.seed;
  const dy = SY - b.py - SX * b.ty, dz = SZ - b.pz - SX * b.tz;
  // growth rings, bent by low-frequency noise stretched along the grain → natural cathedrals & waves
  const n1 = vnoise(SX * 0.13 + sd, SY * 0.3 + sd, SZ * 0.3) - 0.5;
  const n2 = vnoise(SX * 0.6 + sd * 2, SY * 1.6, SZ * 1.6 + sd) - 0.5;
  const r0 = Math.sqrt(dy * dy + dz * dz);
  const r = r0 + sp.wave * (0.9 * n1 + 0.25 * n2);
  const ph = r / sp.ring + 0.08 * n2;
  const f = ph - Math.floor(ph);
  // earlywood fades gradually into latewood, then a crisp boundary into next year's earlywood
  let late = 0.35 * sstep(0.3, 0.8, f) + 0.65 * sstep(0.72, 0.97, f);
  // filter rings that are finer than ~2 px (long-grain faces show them wider; this is a safe lower bound)
  const aa = Math.max(0, Math.min(1, (sp.ring / PIX - 1.5) / 2.5));
  let t = sp.contrast * (late * aa + 0.28 * (1 - aa));
  // fine fibre texture: high frequency across the grain, low along it
  if (PIX < 0.05) t += 0.07 * (vnoise(SX * 1.2 + sd, SY * 45, SZ * 45) - 0.5);
  // ribbon stripe (interlocked grain, e.g. sapele)
  if (sp.ribbon && cls !== 0) t += sp.ribbon * Math.sin(r0 * 9 + 2 * n1) * (cls === 1 ? 1 : 0.4);
  // pores: dots on end grain, short streaks on long grain; ring-porous woods cluster them in earlywood
  if (sp.pore) {
    const cs = sp.poreSize;
    if (PIX < cs * 2) {
      const gy = SY / cs, gz = SZ / cs, iy = Math.floor(gy), iz = Math.floor(gz);
      const hp = hash3(iy, iz, (Math.floor(SX / (cs * 14) + hash3(iy, iz, 5) * 9) * 7919) ^ b.id);
      const dens = sp.ringPorous ? (f < 0.22 ? 0.55 : 0.05) : 0.16;
      // the pore is a round-ish dot in its cell, not the whole cell
      const qy = gy - iy - 0.5, qz = gz - iz - 0.5;
      if (hp < dens * sp.pore && qy * qy + qz * qz < 0.12) t += cls === 0 ? 0.55 : 0.38;
    } else t += sp.pore * (sp.ringPorous ? 0.05 : 0.03);
  }
  // medullary rays: flecks on quartersawn faces, short lines on flatsawn faces, radial lines on end grain
  if (sp.rays && PIX < sp.raySp) {
    // rays are wavy ribbons, so a quartersawn face slices through them as flecks
    const tang = r0 * Math.atan2(dz, dy) + sp.raySp * 1.5 * (vnoise(SX * 0.5 + sd, r0 * 1.5, 7) - 0.5);
    const u = tang / sp.raySp, cell = Math.floor(u), fr = u - cell;
    const sx = SX / sp.rayLen + hash3(cell, 11, b.id) * 7, seg = Math.floor(sx), sf = sx - seg;
    const seg2 = Math.floor(r0 * 2.5 + hash3(cell, 13, b.id) * 3);
    // each ray is a lens: widest in the middle of its length
    const wdt = (0.1 + 0.12 * hash3(cell, seg, 3)) * Math.sin(Math.PI * sf);
    if (hash3(cell, seg * 131 + seg2, b.id ^ 0x5bd1) < 0.55 && Math.abs(fr - 0.5) < wdt) t += (cls === 0 ? 0.3 : -0.32) * sp.rays;
  }
  if (cls === 0) t += 0.12;                       // end grain soaks up light
  t = Math.max(0, Math.min(1, t));
  // slow colour drift within a board
  const drift = vnoise(SX * 0.06 + sd * 3, SY * 0.25, SZ * 0.25 + sd) - 0.5;
  const c = sp.rgb, dk = sp.drgb, tone = b.tone * bright * (1 + 0.1 * drift);
  let R = (c[0] + (dk[0] - c[0]) * t) * tone, G = (c[1] + (dk[1] - c[1]) * t) * tone, B = (c[2] + (dk[2] - c[2]) * t) * tone;
  const hv = sp.hue * drift * 2;                   // slight hue shift (warmer / cooler)
  R *= 1 + hv; B *= 1 - hv;
  if (OILED && mode !== 'grain') {               // oil finish: deeper and more saturated
    const l = 0.3 * R + 0.59 * G + 0.11 * B;
    R = (l + (R - l) * 1.22) * 0.9; G = (l + (G - l) * 1.22) * 0.9; B = (l + (B - l) * 1.22) * 0.88;
  }
  if (mode === 'grain') {
    const lum = (0.3 * R + 0.59 * G + 0.11 * B) / 255;
    const g = GRAIN_RGB[cls], k = 0.32 + 0.95 * lum;
    R = g[0] * k; G = g[1] * k; B = g[2] * k;
  }
  buf[o] = R > 255 ? 255 : R < 0 ? 0 : R; buf[o + 1] = G > 255 ? 255 : G < 0 ? 0 : G; buf[o + 2] = B > 255 ? 255 : B < 0 ? 0 : B; buf[o + 3] = 255;
}

// faces: top (look down -z), front (look +y at y=0), end (look -x at x=X), bottom (look +z, mirrored correctly)
function faceSetup(node, face) {
  const [X, Y, Z] = node.dims;
  if (face === 'front') return { W: X, H: Z, D: Y, v: [0, -1, 0], pt: (u, w, dd) => [u, dd, Z - w] };
  if (face === 'end') return { W: Y, H: Z, D: X, v: [1, 0, 0], pt: (u, w, dd) => [X - dd, u, Z - w] };
  if (face === 'bottom') return { W: X, H: Y, D: Z, v: [0, 0, -1], pt: (u, w, dd) => [u, w, dd] };
  return { W: X, H: Y, D: Z, v: [0, 0, 1], pt: (u, w, dd) => [u, Y - w, Z - dd] };
}

function probe(node, face, u, w) {
  const F = faceSetup(node, face);
  const steps = 48, eps = Math.min(F.D * 1e-3, 1e-3);
  for (let k = 0; k <= steps; k++) {
    const dd = k === 0 ? eps : (k / steps) * F.D - eps;
    const p = F.pt(u, w, dd);
    const b = sample(node, p[0], p[1], p[2], F.v[0], F.v[1], F.v[2]);
    if (b) {
      const cls = grainClass();
      const ang = Math.asin(Math.min(1, Math.abs(VX))) * 180 / Math.PI; // grain angle to the surface
      return { b, cls, depth: dd, ang, key: KEY };
    }
  }
  return null;
}

// Render a face into an RGBA buffer. Returns stats for directly visible surface.
function renderFace(node, face, scale, opts) {
  opts = opts || {};
  const F = faceSetup(node, face);
  const w = Math.max(1, Math.round(F.W * scale)), h = Math.max(1, Math.round(F.H * scale));
  const buf = new Uint8ClampedArray(w * h * 4);
  const keys = new Int32Array(w * h);
  const hit = new Uint8Array(w * h);
  const mode = opts.mode || 'wood', bright = opts.bright || 1;
  PIX = 1 / scale; OILED = opts.oiled !== false;
  const counts = [0, 0, 0], spc = {};
  let surf = 0;
  const steps = 40, eps = Math.min(F.D * 1e-3, 1e-3);
  const vx = F.v[0], vy = F.v[1], vz = F.v[2];
  const tmp = new Uint8ClampedArray(4);
  const finTop = node.t === 4 && (face === 'top' || face === 'bottom');
  let lastCls = 0, lastSp = null, lastDepth = 0;
  // shade one sample point; returns true if it hit material
  const px = (u, wv, out, oo) => {
    let b = null, depth = 0;
    for (let k = 0; k <= steps; k++) {
      const dd = k === 0 ? eps : (k / steps) * F.D - eps;
      if (opts.maxDepth != null && dd > opts.maxDepth) break;
      const p = F.pt(u, wv, dd);
      b = sample(node, p[0], p[1], p[2], vx, vy, vz);
      if (b) { depth = k / steps; break; }
    }
    if (!b) return false;
    const cls = grainClass();
    lastCls = cls; lastSp = b.sp.id; lastDepth = depth;
    let lit = 1;
    if (finTop) {
      // finished pieces: light the surface by its slope (roundovers, chamfers, grooves)
      const p = F.pt(u, wv, 0), x = p[0], y = p[1], hh = 0.01, k = face === 'top' ? 1 : 2;
      const r0 = finEval(node, x, y);
      const flatHere = r0[0] > 2 * hh && (k === 1 ? r0[1] >= node.dims[2] - 1e-9 : r0[2] <= 1e-9);
      const gx = flatHere ? 0 : (finEval(node, x + hh, y)[k] - finEval(node, x - hh, y)[k]) / (2 * hh);
      const gy = flatHere ? 0 : (finEval(node, x, y + hh)[k] - finEval(node, x, y - hh)[k]) / (2 * hh);
      if (gx || gy) {
        const s = face === 'top' ? 1 : -1;
        const nx = -gx * s, ny = -gy * s * (face === 'top' ? 1 : -1), nz = 1, L = Math.hypot(nx, ny, nz);
        lit = Math.max(0.45, Math.min(1.25, 0.55 + 0.45 * ((-0.4 * nx + 0.5 * ny + 0.77 * nz) / L) / 0.77));
      }
      depth *= 0.5;
    }
    shade(b, cls, mode, bright * lit * (1 - 0.55 * depth), out, oo);
    return true;
  };
  for (let j = 0; j < h; j++) {
    const wv = (j + 0.5) / scale;
    for (let i = 0; i < w; i++) {
      const idx = j * w + i;
      if (!px((i + 0.5) / scale, wv, buf, idx * 4)) continue;
      hit[idx] = 1; keys[idx] = KEY;
      if (lastDepth === 0) { counts[lastCls]++; surf++; spc[lastSp] = (spc[lastSp] || 0) + 1; }
    }
  }
  // edge-aware anti-aliasing: supersample pixels where pieces meet or the outline is
  if (opts.aa !== false) {
    const edge = new Uint8Array(w * h);
    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
      const idx = j * w + i;
      const r = i + 1 < w ? idx + 1 : -1, d = j + 1 < h ? idx + w : -1;
      for (const n of [r, d]) {
        if (n < 0) continue;
        if (hit[idx] !== hit[n] || (hit[idx] && keys[idx] !== keys[n])) { edge[idx] = 1; edge[n] = 1; }
      }
    }
    const offs = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]];
    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
      const idx = j * w + i;
      if (!edge[idx]) continue;
      let R = 0, G = 0, B = 0, A = 0;
      for (const [ou, ov] of offs) {
        if (px((i + ou) / scale, (j + ov) / scale, tmp, 0)) { R += tmp[0]; G += tmp[1]; B += tmp[2]; A++; }
      }
      const o = idx * 4;
      if (!A) { buf[o + 3] = 0; continue; }
      buf[o] = R / A; buf[o + 1] = G / A; buf[o + 2] = B / A; buf[o + 3] = 255 * A / 4;
    }
  }
  if (opts.glueLines !== false) {
    // glue lines: a fine dark seam, fainter when zoomed out
    const k = scale >= 20 ? 0.62 : scale >= 8 ? 0.75 : 0.88;
    for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) {
      const idx = j * w + i;
      if (!hit[idx]) continue;
      if ((i > 0 && hit[idx - 1] && keys[idx - 1] !== keys[idx]) || (j > 0 && hit[idx - w] && keys[idx - w] !== keys[idx])) {
        const o = idx * 4; buf[o] *= k; buf[o + 1] *= k; buf[o + 2] *= k;
      }
    }
  }
  return { w, h, buf, counts, spc, surf };
}

// colour of a single point seen along (vx,vy,vz); returns [r,g,b] or null
function pointColor(node, x, y, z, vx, vy, vz, opts) {
  const b = sample(node, x, y, z, vx, vy, vz);
  if (!b) return null;
  PIX = opts.pix || 0.02; OILED = opts.oiled !== false;
  const out = [0, 0, 0, 0];
  shade(b, grainClass(), opts.mode || 'wood', opts.bright || 1, out, 0);
  return [out[0], out[1], out[2]];
}
// outline of a finished piece's plan shape (outer edge and holes): segments with outward normals
function finOutline(fin, step) {
  const [X, Y] = fin.dims;
  const nx = Math.max(8, Math.ceil(X / step)), ny = Math.max(8, Math.ceil(Y / step));
  const dx = X / nx, dy = Y / ny;
  const D = new Float64Array((nx + 3) * (ny + 3));
  // pad one cell outside so the outer edge closes
  const at = (i, j) => D[(j + 1) * (nx + 3) + (i + 1)];
  for (let j = -1; j <= ny + 1; j++) for (let i = -1; i <= nx + 1; i++) {
    const x = i * dx, y = j * dy;
    D[(j + 1) * (nx + 3) + (i + 1)] = (x < -1e-9 || y < -1e-9 || x > X + 1e-9 || y > Y + 1e-9) ? -1 : (() => { const r = finEval(fin, Math.min(X, Math.max(0, x)), Math.min(Y, Math.max(0, y))); return Math.min(r[0], r[1] - r[2]); })();
  }
  const segs = [];
  const lerp = (x0, y0, v0, x1, y1, v1) => { const t = v0 / (v0 - v1); return [x0 + t * (x1 - x0), y0 + t * (y1 - y0)]; };
  for (let j = -1; j <= ny; j++) for (let i = -1; i <= nx; i++) {
    const x0 = i * dx, y0 = j * dy, x1 = x0 + dx, y1 = y0 + dy;
    const a = at(i, j), b = at(i + 1, j), cc = at(i + 1, j + 1), d = at(i, j + 1);
    const pts = [];
    if ((a >= 0) !== (b >= 0)) pts.push(lerp(x0, y0, a, x1, y0, b));
    if ((b >= 0) !== (cc >= 0)) pts.push(lerp(x1, y0, b, x1, y1, cc));
    if ((cc >= 0) !== (d >= 0)) pts.push(lerp(x1, y1, cc, x0, y1, d));
    if ((d >= 0) !== (a >= 0)) pts.push(lerp(x0, y1, d, x0, y0, a));
    for (let k = 0; k + 1 < pts.length; k += 2) {
      const p = pts[k], q = pts[k + 1];
      const mx = Math.min(X, Math.max(0, (p[0] + q[0]) / 2)), my = Math.min(Y, Math.max(0, (p[1] + q[1]) / 2)), h = 0.004;
      const E = (x, y) => { const r = finEval(fin, Math.min(X, Math.max(0, x)), Math.min(Y, Math.max(0, y))); return (x < 0 || y < 0 || x > X || y > Y) ? -1 : Math.min(r[0], r[1] - r[2]); };
      let gx = (E(mx + h, my) - E(mx - h, my)), gy = (E(mx, my + h) - E(mx, my - h));
      const L = Math.hypot(gx, gy) || 1;
      const inner = planD(fin, fin.planAll.filter(f => f.kind === 'corner'), mx, my) > 0.03;
      segs.push({ inner, p: p.map((v, k2) => Math.min(k2 ? Y : X, Math.max(0, v))), q: q.map((v, k2) => Math.min(k2 ? Y : X, Math.max(0, v))), n: [-gx / L, -gy / L] });
    }
  }
  return segs;
}

// ---------- convex polyhedron clipping (shared with the WebGL renderer) ----------
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
function boxPoly(X, Y, Z) {
  const v = (x, y, z) => [x, y, z];
  return [
    [v(0, 0, 0), v(0, Y, 0), v(X, Y, 0), v(X, 0, 0)],   // z = 0 (normal -z)
    [v(0, 0, Z), v(X, 0, Z), v(X, Y, Z), v(0, Y, Z)],   // z = Z
    [v(0, 0, 0), v(X, 0, 0), v(X, 0, Z), v(0, 0, Z)],   // y = 0
    [v(0, Y, 0), v(0, Y, Z), v(X, Y, Z), v(X, Y, 0)],   // y = Y
    [v(0, 0, 0), v(0, 0, Z), v(0, Y, Z), v(0, Y, 0)],   // x = 0
    [v(X, 0, 0), v(X, Y, 0), v(X, Y, Z), v(X, 0, Z)],   // x = X
  ];
}
// keep the side where n·p >= c
function clipPoly(faces, n, c) {
  const eps = 1e-9;
  let anyOut = false, anyIn = false;
  for (const f of faces) for (const p of f) { const d = dot3(n, p) - c; if (d < -eps) anyOut = true; else anyIn = true; }
  if (!anyOut) return faces;
  if (!anyIn) return null;
  const out = [], cap = [];
  for (const f of faces) {
    const nf = [];
    for (let i = 0; i < f.length; i++) {
      const p = f[i], q = f[(i + 1) % f.length];
      const dp = dot3(n, p) - c, dq = dot3(n, q) - c;
      const ip = dp >= -eps, iq = dq >= -eps;
      if (ip) nf.push(p);
      if (ip !== iq) {
        const t = dp / (dp - dq);
        const x = [p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]), p[2] + t * (q[2] - p[2])];
        nf.push(x); cap.push(x);
      }
      if (ip && Math.abs(dp) <= eps) cap.push(p);
    }
    if (nf.length >= 3) out.push(nf);
  }
  if (cap.length >= 3) {
    // dedupe and order the cap polygon around its centroid, facing -n
    const pts = [];
    for (const p of cap) if (!pts.some(q => Math.abs(q[0] - p[0]) + Math.abs(q[1] - p[1]) + Math.abs(q[2] - p[2]) < 1e-7)) pts.push(p);
    if (pts.length >= 3) {
      const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length, cy = pts.reduce((s, p) => s + p[1], 0) / pts.length, cz = pts.reduce((s, p) => s + p[2], 0) / pts.length;
      const a = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
      let u = [n[1] * a[2] - n[2] * a[1], n[2] * a[0] - n[0] * a[2], n[0] * a[1] - n[1] * a[0]];
      const ul = Math.hypot(...u); u = u.map(x => x / ul);
      const w = [n[1] * u[2] - n[2] * u[1], n[2] * u[0] - n[0] * u[2], n[0] * u[1] - n[1] * u[0]];
      pts.sort((p, q) => {
        const ap = Math.atan2(dot3(w, [p[0] - cx, p[1] - cy, p[2] - cz]), dot3(u, [p[0] - cx, p[1] - cy, p[2] - cz]));
        const aq = Math.atan2(dot3(w, [q[0] - cx, q[1] - cy, q[2] - cz]), dot3(u, [q[0] - cx, q[1] - cy, q[2] - cz]));
        return aq - ap;  // clockwise around n = counter-clockwise around -n
      });
      out.push(pts);
    }
  }
  return out.length >= 4 ? out : null;
}
function polyVolume(faces) {
  let v = 0;
  for (const f of faces) for (let i = 1; i + 1 < f.length; i++) {
    const a = f[0], b = f[i], c = f[i + 1];
    v += (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6;
  }
  return Math.abs(v);
}

// A: 3x3 row-major map root → local, a: offset. Constraint in local coords n_l·p_l >= c_l becomes (Aᵀn_l)·p >= c_l − n_l·a
function flattenNode(root) {
  const cells = [];
  const I = [1, 0, 0, 0, 1, 0, 0, 0, 1];
  const atn = (A, nl) => [A[0] * nl[0] + A[3] * nl[1] + A[6] * nl[2], A[1] * nl[0] + A[4] * nl[1] + A[7] * nl[2], A[2] * nl[0] + A[5] * nl[1] + A[8] * nl[2]];
  const clipLocal = (poly, A, a, nl, cl) => poly && clipPoly(poly, atn(A, nl), cl - dot3(nl, a));
  const clipBox = (poly, A, a, d) => {
    for (let k = 0; k < 3 && poly; k++) {
      const e = [0, 0, 0]; e[k] = 1;
      poly = clipLocal(poly, A, a, e, 0);
      if (poly) poly = clipLocal(poly, A, a, [-e[0], -e[1], -e[2]], -d[k]);
    }
    return poly;
  };
  let guard = 0;
  const rec = (n, A, a, poly) => {
    if (++guard > 60000) throw new Error('Design is too complex for the WebGL view');
    poly = clipBox(poly, A, a, n.dims);
    if (!poly) return;
    if (n.t === 0) { if (polyVolume(poly) > 1e-7) cells.push({ poly, A, a, board: n }); return; }
    if (n.t === 4) throw new Error('WebGL can only show finishing steps (edges, grooves, handles) at the end of a design');
    if (n.t === 1) {
      if (n.clip) for (const c of n.clip) { poly = clipLocal(poly, A, a, [c[0], c[1], c[2]], c[3]); if (!poly) return; }
      const R = n.R, t = n.tr;
      const A2 = [
        R[0] * A[0] + R[1] * A[3] + R[2] * A[6], R[0] * A[1] + R[1] * A[4] + R[2] * A[7], R[0] * A[2] + R[1] * A[5] + R[2] * A[8],
        R[3] * A[0] + R[4] * A[3] + R[5] * A[6], R[3] * A[1] + R[4] * A[4] + R[5] * A[7], R[3] * A[2] + R[4] * A[5] + R[5] * A[8],
        R[6] * A[0] + R[7] * A[3] + R[8] * A[6], R[6] * A[1] + R[7] * A[4] + R[8] * A[7], R[6] * A[2] + R[7] * A[5] + R[8] * A[8],
      ];
      const a2 = [R[0] * a[0] + R[1] * a[1] + R[2] * a[2] + t[0], R[3] * a[0] + R[4] * a[1] + R[5] * a[2] + t[1], R[6] * a[0] + R[7] * a[1] + R[8] * a[2] + t[2]];
      rec(n.child, A2, a2, poly);
      return;
    }
    const e = [0, 0, 0]; e[n.axis] = 1;
    const ne = [-e[0], -e[1], -e[2]];
    // nested glue-ups can overlap slightly: later pieces win, like the sampler
    n.kids.forEach((k, f) => {
      let p = clipLocal(poly, A, a, e, k.lo);
      if (p) p = clipLocal(p, A, a, ne, -k.hi);
      if (!p) return;
      rec(k.node, A, [a[0] - k.o[0], a[1] - k.o[1], a[2] - k.o[2]], p);
    });
  };
  rec(root, I, [0, 0, 0], boxPoly(...root.dims));
  return cells;
}


// ---------- shop check: a flat side on the table, and how deep the blade goes ----------
const FLAT_CACHE = new Map();
function pieceFaces(node) {
  let F = FLAT_CACHE.get(node.uid);
  if (F) return F;
  const cells = flattenNode(node);
  const verts = [];
  for (const c of cells) for (const f of c.poly) for (const p of f) verts.push(p);
  const faces = new Map();
  for (const c of cells) for (const f of c.poly) {
    let nx = 0, ny = 0, nz = 0;
    for (let i = 0; i < f.length; i++) { const p = f[i], q = f[(i + 1) % f.length]; nx += (p[1] - q[1]) * (p[2] + q[2]); ny += (p[2] - q[2]) * (p[0] + q[0]); nz += (p[0] - q[0]) * (p[1] + q[1]); }
    const L = Math.hypot(nx, ny, nz); if (L < 1e-12) continue;
    const n = [nx / L, ny / L, nz / L], d = dot3(n, f[0]);
    const key = n.map(v => Math.round(v * 1e4)).join(',');
    let e = faces.get(key);
    if (!e) { let sup = -Infinity; for (const p of verts) sup = Math.max(sup, dot3(n, p)); e = { n, sup, area: 0 }; faces.set(key, e); }
    if (Math.abs(d - e.sup) < 1e-6) e.area += L / 2;      // only faces on the outside, in that direction
  }
  F = { cells, faces: [...faces.values()].filter(e => e.area >= 0.5) };
  FLAT_CACHE.set(node.uid, F);
  if (FLAT_CACHE.size > 400) FLAT_CACHE.delete(FLAT_CACHE.keys().next().value);
  return F;
}
// planes: [{n, c}] meaning n·p = c in the piece's coordinates → the best way to lay the piece down for these cuts
function cutCheck(node, planes, bevel) {
  bevel = bevel || 0;
  if (node.t === 4 || !planes.length) return { ok: true, depth: 0, tilt: 0 };
  let F;
  try { F = pieceFaces(node); } catch (e) { return { ok: true, depth: 0, tilt: 0 }; }
  // each cut may be made with the piece on a different face (you can turn it between cuts)
  let worst = { ok: true, depth: 0, tilt: 0 };
  for (const pl of planes) {
    let best = null;
    for (const face of F.faces) {
      const f = face.n;
      const tilt = Math.asin(Math.min(1, Math.abs(dot3(pl.n, f)))) * 180 / Math.PI;
      // the face on the table must give exactly the blade tilt this step asks for (0 = square cut)
      if (Math.abs(tilt - bevel) > 1) continue;
      let b = [f[0] - dot3(f, pl.n) * pl.n[0], f[1] - dot3(f, pl.n) * pl.n[1], f[2] - dot3(f, pl.n) * pl.n[2]];
      const bl = Math.hypot(...b) || 1; b = b.map(v => v / bl);
      let lo = Infinity, hi = -Infinity;
      for (const c of F.cells) for (const pg of c.poly) for (let i = 0; i < pg.length; i++) {
        const p = pg[i], q = pg[(i + 1) % pg.length];
        const dp = dot3(pl.n, p) - pl.c, dq = dot3(pl.n, q) - pl.c;
        if ((dp > 1e-9 && dq > 1e-9) || (dp < -1e-9 && dq < -1e-9)) continue;
        const t = Math.abs(dp - dq) < 1e-12 ? 0 : dp / (dp - dq);
        const x = [p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1]), p[2] + t * (q[2] - p[2])];
        const v = dot3(b, x); lo = Math.min(lo, v); hi = Math.max(hi, v);
      }
      const depth = hi > lo ? hi - lo : 0;
      if (!best || depth < best.depth - 1e-9) best = { ok: true, depth, tilt };
    }
    if (!best) return { ok: false };
    if (best.depth > worst.depth) worst = best;
  }
  return worst;
}
function cutAdvice(checks, mm) {
  const F = v => typeof fmtU === 'function' ? fmtU(v) : (Math.round(v * 100) / 100) + '″';
  const msgs = [];
  const bad = checks.filter(c => !c.r.ok);
  if (bad.length) msgs.push(`No flat face of the piece can sit on the saw table for this cut${bad.length < checks.length ? ` (${bad.map(c => c.label).slice(0, 4).join(', ')})` : ''} — turn it so a flat face is down, and tilt the blade with “Blade bevel” instead of tilting the piece`);
  const deep = checks.filter(c => c.r.ok && c.r.depth > 3 * mm + 1e-6);
  if (deep.length) {
    const d = deep.reduce((a, c) => c.r.depth > a.r.depth ? c : a);
    msgs.push(`Cut is ${F(d.r.depth)} deep${d.r.tilt > 0.5 ? ` at a ${Math.round(d.r.tilt)}° bevel` : ''} — over ${F(3 * mm)} is a challenge on a table saw (bandsaw, or cut from both sides)`);
  }
  return msgs.length ? msgs.join('. ') + '.' : '';
}
// ---------- trim / square-up ----------
function maxRect(grid, nx, ny) {
  const hts = new Int32Array(nx);
  let best = { area: 0 };
  for (let j = 0; j < ny; j++) {
    for (let i = 0; i < nx; i++) hts[i] = grid[j * nx + i] ? hts[i] + 1 : 0;
    const st = [];
    for (let i = 0; i <= nx; i++) {
      const hh = i < nx ? hts[i] : 0;
      let start = i;
      while (st.length && st[st.length - 1][1] >= hh) {
        const [si, sh] = st.pop();
        const area = sh * (i - si);
        if (area > best.area) best = { area, i0: si, i1: i, j0: j - sh + 1, j1: j + 1 };
        start = si;
      }
      st.push([start, hh]);
    }
  }
  return best;
}
// Largest solid rectangle in the (a,b) plane, solid through axis c over [lo[c],hi[c]]. Mutates lo/hi for a,b.
function squareAxes(node, lo, hi, a, b, c) {
  const cs = [0.1, 0.5, 0.9].map(f => lo[c] + (hi[c] - lo[c]) * f);
  const pt = [0, 0, 0];
  const solid = (va, vb) => { pt[a] = va; pt[b] = vb; for (const vc of cs) { pt[c] = vc; if (!sample(node, pt[0], pt[1], pt[2], 0, 0, 1)) return false; } return true; };
  const A = hi[a] - lo[a], B = hi[b] - lo[b];
  const na = Math.max(20, Math.min(260, Math.round(A * 10))), nb = Math.max(20, Math.min(260, Math.round(B * 10)));
  const da = A / na, db = B / nb;
  const grid = new Uint8Array(na * nb);
  for (let j = 0; j < nb; j++) for (let i = 0; i < na; i++) grid[j * na + i] = solid(lo[a] + (i + 0.5) * da, lo[b] + (j + 0.5) * db) ? 1 : 0;
  const r = maxRect(grid, na, nb);
  if (!r.area) throw new Error('No solid rectangle found to square up');
  let a0 = lo[a] + r.i0 * da, a1 = lo[a] + r.i1 * da, b0 = lo[b] + r.j0 * db, b1 = lo[b] + r.j1 * db;
  const colOk = (va, ya, yb) => { for (let k = 0; k <= 40; k++) if (!solid(va, ya + (yb - ya) * k / 40)) return false; return true; };
  const rowOk = (vb, xa, xb) => { for (let k = 0; k <= 40; k++) if (!solid(xa + (xb - xa) * k / 40, vb)) return false; return true; };
  // already solid right out to the edge? keep the exact edge — searching for it would shave a few millionths off a square piece
  const refine = (ok, inner, outer) => { if (!ok(inner)) return inner; if (ok(outer)) return outer; let p = inner, q = outer; for (let k = 0; k < 14; k++) { const m = (p + q) / 2; if (ok(m)) p = m; else q = m; } return p; };
  const ia0 = a0 + da / 2, ia1 = a1 - da / 2, ib0 = b0 + db / 2, ib1 = b1 - db / 2;
  a0 = refine(v => colOk(v, ib0, ib1), ia0, Math.max(lo[a], a0 - da));
  a1 = refine(v => colOk(v, ib0, ib1), ia1, Math.min(hi[a], a1 + da));
  b0 = refine(v => rowOk(v, a0, a1), ib0, Math.max(lo[b], b0 - db));
  b1 = refine(v => rowOk(v, a0, a1), ib1, Math.min(hi[b], b1 + db));
  lo[a] = a0; hi[a] = a1; lo[b] = b0; hi[b] = b1;
}
// Shrink the box to the material's bounding box (column scans with bisection, so sharp tips survive)
function tightAxes(node, lo, hi) {
  const nlo = [Infinity, Infinity, Infinity], nhi = [-Infinity, -Infinity, -Infinity];
  for (let a = 0; a < 3; a++) {
    const [ua, va] = [0, 1, 2].filter(k => k !== a);
    const U = hi[ua] - lo[ua], V = hi[va] - lo[va];
    const budget = 2400;
    const nu = Math.max(2, Math.min(400, Math.round(Math.sqrt(budget * U / Math.max(V, 1e-6))))), nv = Math.max(2, Math.min(400, Math.round(budget / nu)));
    let bestLo = null, bestHi = null;
    for (let iu = 0; iu < nu; iu++) for (let iv = 0; iv < nv; iv++) {
      const u = lo[ua] + (iu + 0.5) / nu * U, v = lo[va] + (iv + 0.5) / nv * V;
      const e = columnExtent(node, a, ua, va, u, v);
      if (!e) continue;
      if (!bestLo || e[0] < bestLo[0]) bestLo = [e[0], u, v];
      if (!bestHi || e[1] > bestHi[0]) bestHi = [e[1], u, v];
    }
    if (!bestLo) throw new Error('This piece has no material');
    // hill-climb from the best columns toward the true extreme (tips often sit on an edge, between columns)
    const climb = (best, sign) => {
      let [val, u, v] = best, su = U / nu, sv = V / nv;
      for (let it = 0; it < 16; it++) {
        let moved = false;
        for (const [du, dv] of [[su, 0], [-su, 0], [0, sv], [0, -sv]]) {
          const nu2 = Math.min(hi[ua], Math.max(lo[ua], u + du)), nv2 = Math.min(hi[va], Math.max(lo[va], v + dv));
          const e = columnExtent(node, a, ua, va, nu2, nv2);
          if (!e) continue;
          const nvl = sign > 0 ? e[1] : e[0];
          if (sign > 0 ? nvl > val + 1e-9 : nvl < val - 1e-9) { val = nvl; u = nu2; v = nv2; moved = true; }
        }
        if (!moved) { su /= 2; sv /= 2; }
      }
      return val;
    };
    nlo[a] = Math.max(lo[a], climb(bestLo, -1)); nhi[a] = Math.min(hi[a], climb(bestHi, 1));
  }
  for (let a = 0; a < 3; a++) { lo[a] = nlo[a]; hi[a] = nhi[a]; }
}
// mode: 'none' | 'top' | 'end' | 'both' | 'tight'
function trimNode(node, mode, t) {
  if (mode === true) mode = 'top';
  if (!mode) mode = 'none';
  const lo = [0, 0, 0], hi = node.dims.slice();
  const trimAx = ax => { if (ax === 0) { lo[0] += t.x0; hi[0] -= t.x1; } else if (ax === 1) { lo[1] += t.y0; hi[1] -= t.y1; } else { lo[2] += t.z0; hi[2] -= t.z1; } };
  const check = () => { if (hi[2] <= lo[2]) throw new Error('Planed away the whole thickness'); if (hi[0] <= lo[0] || hi[1] <= lo[1]) throw new Error('Trimmed away the whole piece'); };
  if (mode === 'top') { trimAx(2); check(); squareAxes(node, lo, hi, 0, 1, 2); trimAx(0); trimAx(1); }
  else if (mode === 'end') { trimAx(0); check(); squareAxes(node, lo, hi, 1, 2, 0); trimAx(1); trimAx(2); }
  else if (mode === 'both') { squareAxes(node, lo, hi, 1, 2, 0); squareAxes(node, lo, hi, 0, 1, 2); trimAx(0); trimAx(1); trimAx(2); }
  else if (mode === 'tight') { tightAxes(node, lo, hi); trimAx(0); trimAx(1); trimAx(2); }
  else { trimAx(0); trimAx(1); trimAx(2); }
  check();
  return mk({ t: 1, dims: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]], child: node, R: [1, 0, 0, 0, 1, 0, 0, 0, 1], tr: lo, along: node.along != null ? node.along + lo[0] : undefined, cutOf: node.cutOf });
}


// ---------- offcuts ----------
function wasteVolume(node) { let v = 0; try { for (const cell of flattenNode(node)) v += polyVolume(cell.poly); } catch (e) { v = node.dims[0] * node.dims[1] * node.dims[2]; } return v; }
const WASTE_MEMO = new WeakMap();
function stepWaste(res) {
  if (!res.wasteSrc) return [];
  if (WASTE_MEMO.has(res.wasteSrc)) return WASTE_MEMO.get(res.wasteSrc);
  const out = [];
  for (const f of res.wasteSrc || []) for (const w of f()) {
    // shrink the outline to the wood using its exact polytopes (fast), and measure the volume from the same cells
    let cells; try { cells = flattenNode(w); } catch (e) { continue; }
    let v = 0; const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
    for (const cell of cells) { v += polyVolume(cell.poly); for (const fc of cell.poly) for (const p of fc) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], p[k]); hi[k] = Math.max(hi[k], p[k]); } }
    if (!(v > 0.02)) continue;
    const [X, Y, Z] = w.dims, cl = (a, b) => Math.max(0, Math.min(b, a));
    let t; try { t = trimNode(w, 'none', { x0: cl(lo[0], X), x1: cl(X - hi[0], X), y0: cl(lo[1], Y), y1: cl(Y - hi[1], Y), z0: cl(lo[2], Z), z1: cl(Z - hi[2], Z) }); } catch (e) { continue; }
    t.wasteVol = v; out.push(t);
  }
  WASTE_MEMO.set(res.wasteSrc, out);
  return out;
}

// ---------- objects for scale ----------
const PROP_SPECIES = {
  steel: { id: '_steel', name: 'Steel', base: '#b9bec4', dark: '#9aa1a8', ring: 0.5, contrast: 0.02, wave: 0, pore: 0, rays: 0, hue: 0 },
  ink: { id: '_ink', name: 'Ink', base: '#1d1f22', dark: '#101113', ring: 0.5, contrast: 0.01, wave: 0, pore: 0, rays: 0, hue: 0 },
  banana: { id: '_banana', name: 'Banana', base: '#f3d34a', dark: '#e2b932', ring: 0.9, contrast: 0.05, wave: 0.05, pore: 0, rays: 0, hue: 0.01 },
  peel: { id: '_peel', name: 'Banana end', base: '#6b4f2a', dark: '#4a3519', ring: 0.5, contrast: 0.05, wave: 0, pore: 0, rays: 0, hue: 0 },
};
let PROP_MAP = null;
const propSp = k => (PROP_MAP || (PROP_MAP = speciesMap(Object.values(PROP_SPECIES))))[PROP_SPECIES[k].id];
const PROPS = new Map();
function scaleProp(kind, units) {
  const key = kind + '|' + units;
  if (PROPS.has(key)) return PROPS.get(key);
  const f = units === 'mm' ? 25.4 : 1;   // models are built in inches
  let node = null;
  if (kind === 'ruler') {
    // 12" steel rule, 1 1/4" wide: ticks every 1/4", longer at halves and inches
    const W = 1.25 * f, T = 0.09 * f, tick = 0.035 * f, end = 0.25 * f;
    const kids = []; let x = 0;
    const seg = (node, w) => { kids.push({ node, o: [x, 0, 0], lo: x, hi: x + w }); x += w; };
    const plate = w => boardNode(propSp('steel'), w, W, T, 'flat', 'ruler' + kids.length);
    seg(plate(end), end);
    for (let q = 0; q <= 48; q++) {
      const L = (q % 4 === 0 ? 0.5 : q % 2 === 0 ? 0.32 : 0.2) * f;
      const col = mk({ t: 2, axis: 1, dims: [tick, W, T], kids: [
        { node: boardNode(propSp('ink'), tick, L, T, 'flat', 'tk' + q), o: [0, 0, 0], lo: 0, hi: L },
        { node: boardNode(propSp('steel'), tick, W - L, T, 'flat', 'tks' + q), o: [0, L, 0], lo: L, hi: W } ] });
      seg(col, tick);
      if (q < 48) seg(plate(0.25 * f - tick), 0.25 * f - tick);
    }
    seg(plate(end), end);
    node = mk({ t: 2, axis: 0, dims: [x, W, T], kids });
    node.label = '12″ ruler';
  } else if (kind === 'banana') {
    // a medium banana, about 7 1/2" long: curved, tapering, with brown ends; octagonal sections
    const Lb = 7.5 * f, R = 0.66 * f, sag = 1.35 * f, N = 16;
    const cy = u => sag * (1 - u * u), rad = u => R * Math.sqrt(Math.max(0.05, 1 - Math.pow(Math.abs(u), 3)));
    const Wt = (sag + R * 2 + 0.2 * f), H = R * 2;
    const kids = [];
    for (let i = 0; i < N; i++) {
      const xa = Lb * i / N, xb = Lb * (i + 1) / N, ua = xa / Lb * 2 - 1, ub = xb / Lb * 2 - 1;
      const ca = cy(ua) + R + 0.1 * f, cb = cy(ub) + R + 0.1 * f, ra = rad(ua), rb = rad(ub), dx = xb - xa;
      const sp = i === 0 || i === N - 1 ? propSp('peel') : propSp('banana');
      const board = boardNode(sp, dx, Wt, H, 'flat', 'banana' + i);
      // planes n·p ≥ c in the segment's local frame (x from 0 to dx); centre line and radius vary linearly
      const clip = [];
      const lin = (ny, nz, k0, k1) => { // keep ny*y + nz*z ≥ k(x), k linear from k0 at x=0 to k1 at x=dx  →  -(k1-k0)/dx * x + ny y + nz z ≥ k0
        clip.push([-(k1 - k0) / dx, ny, nz, k0]); };
      const zc = R;                       // centre height (sits on the table)
      const s2 = Math.SQRT1_2;
      lin(1, 0, ca - ra, cb - rb);        // y ≥ centre - r
      lin(-1, 0, -(ca + ra), -(cb + rb)); // y ≤ centre + r
      lin(0, 1, zc - ra, zc - rb);        // z ≥ zc - r
      lin(0, -1, -(zc + ra), -(zc + rb)); // z ≤ zc + r
      // chamfers → octagon
      for (const [sy, sz] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) lin(sy * s2, sz * s2, sy * s2 * ca + sz * s2 * zc - ra * 0.92, sy * s2 * cb + sz * s2 * zc - rb * 0.92);
      const seg = mk({ t: 1, dims: [dx, Wt, H], child: board, R: [1, 0, 0, 0, 1, 0, 0, 0, 1], tr: [0, 0, 0], clip });
      kids.push({ node: seg, o: [xa, 0, 0], lo: xa, hi: xb });
    }
    node = mk({ t: 2, axis: 0, dims: [Lb, Wt, H], kids });
    node.label = 'banana';
  }
  if (node) node.prop = true;
  // an even colour: no board-to-board tone or figure variation across the segments
  (function even(n) { if (!n) return; if (n.t === 0) { n.tone = 1; n.ty = 0; n.tz = 0; n.seed = 7; n.id = 7; } if (n.child) even(n.child); if (n.kids) n.kids.forEach(k => even(k.node)); })(node);
  PROPS.set(key, node);
  return node;
}

// ---------- dimension parsing ----------
function parseDim(str, label) {
  if (typeof str === 'number') return str;
  const s = String(str ?? '').trim().replace(/["″]|in$/g, '').trim();
  if (s === '') return 0;
  let m;
  if ((m = /^(-?\d+(?:\.\d+)?)$/.exec(s))) return parseFloat(m[1]);
  if ((m = /^(-?)(\d+)\/(\d+)$/.exec(s))) return (m[1] ? -1 : 1) * (+m[2] / +m[3]);
  if ((m = /^(-?)(\d+)[\s-]+(\d+)\/(\d+)$/.exec(s))) return (m[1] ? -1 : 1) * (+m[2] + +m[3] / +m[4]);
  throw new Error(`${label || 'Value'}: can't read “${str}”`);
}
function parseDimList(str, label) {
  const parts = String(str ?? '').split(/[,;]/).map(p => p.trim()).filter(Boolean);
  if (!parts.length) throw new Error(`${label}: enter at least one width`);
  return parts.map(p => { const v = parseDim(p, label); if (!(v > 0)) throw new Error(`${label}: widths must be > 0`); return v; });
}
function parseIndexList(str) {
  const set = new Set();
  for (const part of String(str || '').split(/[,\s]+/).filter(Boolean)) {
    const m = /^(\d+)(?:-(\d+))?$/.exec(part);
    if (!m) throw new Error(`Piece list: can't read “${part}”`);
    const a = +m[1], b = m[2] ? +m[2] : a;
    for (let k = Math.min(a, b); k <= Math.max(a, b); k++) set.add(k);
  }
  return set;
}

// ---------- input spec: "A B A", "(A B)*", "C3-7", "D*" ----------
function tokenize(s) {
  const toks = []; let i = 0; s = s.toUpperCase();
  while (i < s.length) {
    const ch = s[i];
    if (/[\s,]/.test(ch)) { i++; continue; }
    if ('()*-'.includes(ch)) { toks.push(ch); i++; continue; }
    if (/[A-Z]/.test(ch)) { let j = i; while (j < s.length && /[A-Z]/.test(s[j])) j++; toks.push({ id: s.slice(i, j) }); i = j; continue; }
    if (/\d/.test(ch)) { let j = i; while (j < s.length && /\d/.test(s[j])) j++; toks.push({ num: +s.slice(i, j) }); i = j; continue; }
    throw new Error(`Input: unexpected “${ch}”`);
  }
  return toks;
}
function parseSpec(str) {
  const toks = tokenize(str || ''); let p = 0;
  const peek = () => toks[p];
  function seq(inGroup) {
    const items = [];
    while (p < toks.length) {
      const t = peek();
      if (t === ')') { if (!inGroup) throw new Error('Input: unmatched “)”'); break; }
      if (t === '(') {
        p++; const g = seq(true);
        if (peek() !== ')') throw new Error('Input: missing “)”');
        p++;
        let rep = 1;
        if (peek() === '*') { rep = Infinity; p++; }
        else if (peek() && peek().num != null) { rep = peek().num; p++; }
        items.push({ g, rep });
        continue;
      }
      if (t && t.id) {
        p++; const it = { id: t.id };
        if (peek() && peek().num != null) {
          it.n1 = peek().num; p++;
          if (peek() === '-') { p++; if (!peek() || peek().num == null) throw new Error('Input: range needs an end, e.g. C2-5'); it.n2 = peek().num; p++; }
        }
        if (peek() === '*') { if (it.n1 != null) throw new Error('Input: use C* or C3, not both'); it.all = true; p++; }
        items.push(it); continue;
      }
      throw new Error(`Input: unexpected “${typeof t === 'string' ? t : t.num}”`);
    }
    return items;
  }
  const items = seq(false);
  return items;
}
class Exhausted extends Error {}
function evalSpec(str, getList, usedInit) {
  const items = parseSpec(str);
  const used = {}; const out = [];
  if (usedInit) for (const k in usedInit) used[k] = new Set(usedInit[k]);
  const nextFree = (id, len) => { const u = used[id] || (used[id] = new Set()); for (let k = 1; k <= len; k++) if (!u.has(k)) return k; return 0; };
  const take = (id, k) => { (used[id] || (used[id] = new Set())).add(k); out.push({ id, k }); };
  function run(list) {
    for (const it of list) {
      if (it.g) {
        if (it.rep === Infinity) {
          let guard = 0;
          for (;;) {
            if (guard++ > 1000) throw new Error('Input: a (…)* group never runs out — it needs a bare step letter inside');
            const snapOut = out.length, snapUsed = JSON.stringify(Object.fromEntries(Object.entries(used).map(([k, v]) => [k, [...v]])));
            try { run(it.g); } catch (e) {
              if (!(e instanceof Exhausted)) throw e;
              out.length = snapOut;
              const u = JSON.parse(snapUsed); for (const k in used) delete used[k]; for (const k in u) used[k] = new Set(u[k]);
              break;
            }
          }
        } else for (let r = 0; r < it.rep; r++) run(it.g);
        continue;
      }
      const arr = getList(it.id);
      if (it.n1 != null) {
        const a = it.n1, b = it.n2 ?? it.n1;
        for (let k = a; a <= b ? k <= b : k >= b; k += a <= b ? 1 : -1) {
          if (k < 1 || k > arr.length) throw new Error(`Input: ${it.id}${k} doesn't exist (${it.id} has ${arr.length} piece${arr.length === 1 ? '' : 's'})`);
          take(it.id, k);
        }
      } else if (it.all) {
        let k; while ((k = nextFree(it.id, arr.length))) take(it.id, k);
      } else {
        const k = nextFree(it.id, arr.length);
        if (!k) throw new Exhausted(`Input: no unused pieces left in ${it.id} (it has ${arr.length})`);
        take(it.id, k);
      }
    }
  }
  run(items);
  return out;
}

// ---------- pipeline ----------
const PCACHE = new Map();
function shuffleList(arr, seed) {
  const r = rng(hashStr('shuffle:' + seed)); const a = arr.slice();
  for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
  return a;
}
function runPipeline(project) {
  const spMap = speciesMap(project.species);
  const outs = {}; const results = {}; const usage = {};
  for (const step of project.steps) {
    const res = { pieces: [], error: null, warn: null, plan: null, inputs: [] };
    results[step.id] = res;
    try {
      const getList = id => {
        if (id === step.id) throw new Error(`Input: a step can't use its own output (${id})`);
        if (!(id in outs)) throw new Error(`Input: step ${id} isn't above this one`);
        if (outs[id] === null) throw new Error(`Input: step ${id} has an error`);
        return outs[id];
      };
      let inputs = [];
      if (step.type !== 'stock') {
        if (!String(step.input || '').trim()) throw new Error('Input: choose which pieces to use, e.g. A*');
        const usedInit = {};
        for (const key in usage) { const m = /^([A-Z]+)(\d+)$/.exec(key); (usedInit[m[1]] || (usedInit[m[1]] = new Set())).add(+m[2]); }
        const refs = evalSpec(step.input, getList, usedInit);
        if (!refs.length) throw new Error('Input: no unused pieces left in that selection');
        const reused = [];
        for (const r of refs) {
          const key = r.id + r.k;
          if (usage[key] && usage[key] !== step.id) reused.push(`${key} (already used in ${usage[key]})`);
          else if (usage[key] === step.id && !reused.includes(key)) reused.push(`${key} (twice here)`);
          usage[key] = step.id;
        }
        if (reused.length) res.warn = 'Reused piece' + (reused.length > 1 ? 's' : '') + ': ' + reused.slice(0, 6).join(', ') + (reused.length > 6 ? '…' : '');
        res.inputs = refs;
        inputs = refs.map(r => outs[r.id][r.k - 1]);
      }
      const sig = JSON.stringify(step) + '|' + (project.seed || 0) + '|' + (step.type === 'stock' ? JSON.stringify(spMap[step.species] || null) : inputs.map(p => p.uid).join(','));
      const hit = PCACHE.get(step.id);
      if (hit && hit.sig === sig) { res.pieces = hit.pieces; res.plan = hit.plan; res.shop = hit.shop; res.wasteSrc = hit.wasteSrc; if (hit.stepWarn) res.warn = (res.warn ? res.warn + ' · ' : '') + hit.stepWarn; outs[step.id] = res.pieces; continue; }

      if (step.type === 'stock') {
        const sp = spMap[step.species];
        if (!sp) throw new Error('Pick a wood');
        const count = Math.max(1, Math.min(200, Math.round(+step.count || 1)));
        const L = parseDim(step.length, 'Length'), W = parseDim(step.width, 'Width'), T = parseDim(step.thickness, 'Thickness');
        if (!(L > 0 && W > 0 && T > 0)) throw new Error('Length, width and thickness must be > 0');
        for (let i = 0; i < count; i++) res.pieces.push(boardNode(sp, L, W, T, step.sawing, `${project.seed || 0}:${step.id}:${i}`));
        // sourcing hints: wide or thick boards are hard to find
        const F = v => typeof fmtU === 'function' ? fmtU(v) : v + '';
        const mm = project.units === 'mm' ? 25.4 : 1;
        const hint = [];
        if (W > 8 * mm + 1e-6) hint.push(`boards wider than ${F(8 * mm)} can be hard to find — consider edge-gluing narrower boards`);
        if (T > 1.5 * mm + 1e-6) hint.push(`stock thicker than ${F(1.5 * mm)} can be hard to find — consider laminating thinner boards`);
        if (hint.length) res.stepWarn = 'Sourcing: ' + hint.join('; ') + '.';
      } else if (step.type === 'cut') {
        const widths = parseDimList(step.widths, 'Strip width');
        const widthsEnd = step.taper ? parseDimList(step.widthsEnd, 'End width') : null;
        const kerf = parseDim(step.kerf, 'Kerf'), off = parseDim(step.offset, 'Start offset');
        if (kerf < 0) throw new Error('Kerf can’t be negative');
        const ang = parseFloat(step.angle);
        if (!isFinite(ang)) throw new Error('Angle: enter a number of degrees');
        const bevel = parseFloat(step.bevel || 0) || 0;
        if (Math.abs(bevel) > 45) throw new Error('Blade bevel: table saws tilt 0–45°');
        if (bevel && step.taper) throw new Error('Taper cuts can’t also be bevelled');
        inputs.forEach((p, i) => {
          const maxN = Math.max(0, Math.round(+step.max || 0));
          const r = cutNode(p, ang, widths, kerf, off, maxN, !!step.keepRem, widthsEnd, bevel);
          // with a strip count, "keep the leftover" keeps everything past the last strip as one more piece
          if (step.keepRem && maxN && r.pieces.length === maxN) {
            const ws = [], we = widthsEnd ? [] : null;
            for (let k = 0; k < maxN; k++) { ws.push(widths[k % widths.length]); if (we) we.push(widthsEnd[k % widthsEnd.length]); }
            ws.push(1e4); if (we) we.push(1e4);
            const t = cutNode(p, ang, ws, kerf, off, maxN + 1, true, we, bevel);
            if (t.pieces.length > maxN) { r.pieces.push(t.pieces[maxN]); r.rem = true; }
          }
          res.pieces.push(...r.pieces);
          // offcuts, worked out only when someone asks to see them: the strip skipped by the start offset,
          // and whatever is left past the last strip (unless it was kept as a piece)
          if (!step.taper) (res.wasteSrc || (res.wasteSrc = [])).push(() => {
            const out = [], n = r.pieces.length;
            if (off > kerf + 1e-3) { const h = cutNode(p, ang, [off - kerf], kerf, 0, 1, false, null, bevel); if (h.pieces.length) out.push(h.pieces[0]); }
            if (!step.keepRem && n) {
              const ws = []; for (let k = 0; k < n; k++) ws.push(widths[k % widths.length]); ws.push(1e4);
              const t = cutNode(p, ang, ws, kerf, off, n + 1, true, null, bevel);
              if (t.pieces.length > n) out.push(t.pieces[n]);
            }
            return out;
          });
          // shop check on the input piece, using every cut line of this step
          if (i < 12) {
            const planes = r.planes || r.plan.lines.map(([a, b]) => { const dx = b[0] - a[0], dy = b[1] - a[1], L = Math.hypot(dx, dy) || 1; const n = [-dy / L, dx / L, 0]; return { n, c: n[0] * a[0] + n[1] * a[1] }; });
            (res.checks || (res.checks = [])).push({ label: res.inputs[i] ? res.inputs[i].id + res.inputs[i].k : '', r: cutCheck(p, planes, Math.abs(bevel)) });
          }
          // classify by the wood's grain, not by the angle typed in: along the grain = rip, across = crosscut
          let along = null;
          { const th = -ang * Math.PI / 180, dx = Math.cos(th), dy = Math.sin(th);
            for (const f of [[0.5, 0.5], [0.3, 0.5], [0.7, 0.5], [0.5, 0.3], [0.5, 0.7]]) {
              if (sample(p, p.dims[0] * f[0], p.dims[1] * f[1], p.dims[2] / 2, dx, dy, 0)) { along = Math.abs(VX); break; } } }
          const kind = step.taper ? 'taper' : along == null ? 'angled' : along > 0.98 ? 'rip' : along < 0.02 ? 'cross' : 'angled';
          res.shop = res.shop || { saw: { rip: 0, cross: 0, angled: 0, taper: 0 } };
          res.shop.saw[kind] += r.cuts;
          // warn when the board can't give what was asked for: a strip count, or a whole width list
          const want = maxN || (!step.keepRem && (widths.length > 1 || (widthsEnd && widthsEnd.length > 1)) ? Math.max(widths.length, widthsEnd ? widthsEnd.length : 0) : 0);
          const full = r.pieces.length - (r.partial || r.rem ? 1 : 0);
          if (want && full < want) {
            let need0 = 0, need1 = 0;
            for (let k = 0; k < want; k++) { need0 += widths[k % widths.length]; need1 += widthsEnd ? widthsEnd[k % widthsEnd.length] : widths[k % widths.length]; }
            const need = Math.max(need0, need1) + kerf * (want - 1);
            (res.shortfalls || (res.shortfalls = [])).push({ piece: res.inputs[i] ? res.inputs[i].id + res.inputs[i].k : '', got: full, want, short: need - r.avail, partial: r.partial, byCount: !!maxN });
          }
          if (i === 0) res.plan = Object.assign(r.plan, { node: p });
        });
        if (!res.pieces.length) throw new Error('No strips fit — check the strip width and offset');
        { const adv = cutAdvice(res.checks || [], project.units === 'mm' ? 25.4 : 1); if (adv) res.stepWarn = (res.stepWarn ? res.stepWarn + ' ' : '') + adv; }
        if (res.shortfalls) {
          const F = v => typeof fmtU === 'function' ? fmtU(v) : (Math.round(v * 1000) / 1000) + '';
          const s = res.shortfalls;
          const one = x => `Only ${x.got} of ${x.want} ${x.byCount ? 'strips' : 'strips in the width list'} fit${s.length > 1 || inputs.length > 1 ? ' in ' + x.piece : ''} — you need ${F(x.short)} more board width` + (x.partial ? ` (the kept leftover strip is only ${F(x.partial)})` : '') + '.';
          const msg = s.length <= 2 ? s.map(one).join(' ') : one(s[0]) + ` Also short in ${s.length - 1} more piece${s.length > 2 ? 's' : ''}.`;
          res.stepWarn = msg;
        }
      } else if (step.type === 'transform') {
        let pieces = step.shuffle ? shuffleList(inputs, step.seed || 0) : inputs.slice();
        (step.ops || []).forEach((op, oi) => {
          const axis = op.kind === 'roll' ? 0 : op.kind === 'tip' ? 1 : 2;
          const ang = parseFloat(op.angle);
          if (!isFinite(ang)) throw new Error('Rotation angle must be a number');
          const pick = pickFn(op.target, op.list, `${step.seed || 0}:${oi}`);
          pieces = pieces.map((p, i) => pick(i + 1) ? rotateNode(p, axis, ang) : p);
        });
        if (step.reverse) pieces.reverse();
        res.pieces = pieces;
      } else if (step.type === 'glue') {
        const opts = {
          align: step.align || 'center', stagger: parseDim(step.stagger, 'Stagger'), nest: !!step.nest,
          pick: pickFn(step.offTarget, step.offList, `${step.seed || 0}:off`), offAmount: parseDim(step.offAmount, 'Offset'),
        };
        // "Pieces per glue-up": split the inputs into groups and make one glue-up of each (e.g. (C D C)6 in 3s → 6 sandwiches)
        const gs = String(step.groupSize ?? '').trim();
        const group = gs === '' ? 0 : Math.round(+gs);
        if (gs !== '' && !(group >= 1)) throw new Error('Pieces per glue-up: enter a whole number, or leave it blank for one glue-up');
        const groups = [];
        if (group && group < inputs.length) for (let i = 0; i < inputs.length; i += group) groups.push(inputs.slice(i, i + group));
        else groups.push(inputs);
        const one = list => {
          if (step.axis === 'grid') {
            const per = Math.max(1, Math.round(+step.perRow || 1));
            const rows = [];
            for (let i = 0; i < list.length; i += per) rows.push(glueNodes(list.slice(i, i + per), 0, { align: 'start' }));
            return glueNodes(rows, 1, opts);
          }
          return glueNodes(list, step.axis === 'x' ? 0 : step.axis === 'z' ? 2 : 1, opts);
        };
        res.pieces = groups.map(g => g.length > 1 ? one(g) : g[0]);
        const real = groups.filter(g => g.length > 1);
        res.shop = { glueups: real.length, joints: real.reduce((s, g) => s + g.length - 1, 0) };
        if (groups.length > 1 && groups[groups.length - 1].length !== group) res.stepWarn = `The last glue-up has only ${groups[groups.length - 1].length} of ${group} pieces — check the input count (${inputs.length} pieces)`;
        // cross-grain glue lines
        {
          const bad = [];
          let start = 0;
          groups.forEach((g, gi) => {
            if (g.length > 1 && step.axis !== 'grid') for (const j of crossGrainJoints(res.pieces[gi])) {
              const la = res.inputs && res.inputs[start + j.a], lb = res.inputs && res.inputs[start + j.b];
              bad.push({ pair: la && lb ? `${la.id}${la.k}–${lb.id}${lb.k}` : '', angle: j.angle });
            } else if (g.length > 1) for (const j of crossGrainJoints(res.pieces[gi])) bad.push({ pair: '', angle: j.angle });
            start += g.length;
          });
          if (bad.length) {
            const worst = Math.max(...bad.map(b => b.angle)), named = bad.filter(b => b.pair).slice(0, 4).map(b => b.pair);
            res.grainWarn = `Cross-grain glue line${bad.length > 1 ? 's' : ''}: ${bad.length} joint${bad.length > 1 ? 's' : ''} where the two sides swell and shrink differently along the glue line (as much as grain ${worst}° apart)${named.length ? ` (${named.join(', ')}${bad.length > named.length ? '…' : ''})` : ''}. Wood moves across the grain, not along it, so these joints can crack as the board takes on and loses moisture. Turn the pieces so the grain runs the same way, or mirrors across the glue line as in a herringbone.`;
            res.stepWarn = (res.stepWarn ? res.stepWarn + ' ' : '') + res.grainWarn;
          }
        }
      } else if (step.type === 'edges' || step.type === 'groove' || step.type === 'handle') {
        res.pieces = inputs.map(p => finishNode(p, finishFeats(step, p.dims)));
        // router setups: one per profile / groove / handle feature, per piece
        let ops = 0;
        res.pieces.forEach((q, i) => { const before = inputs[i].t === 4 ? inputs[i].feats.length : 0; for (const f of q.feats.slice(before)) ops += f.kind === 'grip' ? f.edges.length : 1; });
        res.shop = { router: ops };
      } else if (step.type === 'trim') {
        const t = {};
        for (const k of ['x0', 'x1', 'y0', 'y1', 'z0', 'z1']) { t[k] = parseDim(step[k], 'Trim'); if (t[k] < 0) throw new Error('Trim amounts can’t be negative'); }
        // "Plane to thickness": work out how much comes off each piece instead of taking fixed amounts
        const to = String(step.planeTo ?? '').trim() === '' ? 0 : parseDim(step.planeTo, 'Plane to');
        if (String(step.planeTo ?? '').trim() !== '' && !(to > 0)) throw new Error('Plane to: enter the finished thickness');
        res.pieces = inputs.map(p => {
          if (!to) return trimNode(p, step.square, t);
          const off = p.dims[2] - to;
          if (off < -1e-6) throw new Error(`Plane to ${step.planeTo}: a piece is only ${(+p.dims[2].toFixed(3))} thick already`);
          const f = step.planeFrom || 'both', amt = Math.max(0, off);
          return trimNode(p, step.square, { ...t, z1: f === 'bottom' ? 0 : f === 'top' ? amt : amt / 2, z0: f === 'top' ? 0 : f === 'bottom' ? amt : amt / 2 });
        });
        // trim cuts on the edges/ends that actually lost material; planed faces counted separately
        let tc = 0, pl = 0;
        if (step.square !== 'tight') res.pieces.forEach((q, i) => {
          const d0 = inputs[i].dims, lo = q.tr, hi = [0, 1, 2].map(k => q.tr[k] + q.dims[k]);
          for (const k of [0, 1]) { if (lo[k] > 1e-4) tc++; if (d0[k] - hi[k] > 1e-4) tc++; }
          if (lo[2] > 1e-4) pl++; if (d0[2] - hi[2] > 1e-4) pl++;
        });
        res.shop = { trim: tc, plane: pl };
        if (step.square !== 'tight') res.wasteSrc = [() => {
          const out = [];
          res.pieces.forEach((q, i) => {
            const p = inputs[i], [X, Y, Z] = p.dims, lo = q.tr, hi = [0, 1].map(k => q.tr[k] + q.dims[k]);
            const slab = (x0, x1, y0, y1) => { if (x1 - x0 > 1e-3 && y1 - y0 > 1e-3) out.push(mk({ t: 1, dims: [x1 - x0, y1 - y0, Z], child: p, R: [1, 0, 0, 0, 1, 0, 0, 0, 1], tr: [x0, y0, 0] })); };
            slab(0, lo[0], 0, Y); slab(hi[0], X, 0, Y); slab(lo[0], hi[0], 0, lo[1]); slab(lo[0], hi[0], hi[1], Y);
          });
          return out;
        }];
        if (step.square !== 'tight') {
          const checks = [];
          res.pieces.forEach((q, i) => {
            if (i >= 12) return;
            const d0 = inputs[i].dims, lo = q.tr, hi = [0, 1, 2].map(k => q.tr[k] + q.dims[k]);
            const planes = [];
            for (const k of [0, 1]) { const e = [0, 0, 0]; e[k] = 1; if (lo[k] > 1e-4) planes.push({ n: e, c: lo[k] }); if (d0[k] - hi[k] > 1e-4) planes.push({ n: e, c: hi[k] }); }
            // z trims are saw cuts only on a turned piece (on a flat board they are planing)
            const tilted = inputs[i].t === 1 && inputs[i].R && Math.abs(inputs[i].R[8]) < 0.999;
            if (tilted) { const e = [0, 0, 1]; if (lo[2] > 1e-4) planes.push({ n: e, c: lo[2] }); if (d0[2] - hi[2] > 1e-4) planes.push({ n: e, c: hi[2] }); }
            if (planes.length) checks.push({ label: res.inputs[i] ? res.inputs[i].id + res.inputs[i].k : '', r: cutCheck(inputs[i], planes) });
          });
          const adv = cutAdvice(checks, project.units === 'mm' ? 25.4 : 1);
          if (adv) res.stepWarn = (res.stepWarn ? res.stepWarn + ' ' : '') + adv;
        }
      }
      PCACHE.set(step.id, { sig, pieces: res.pieces, plan: res.plan, shop: res.shop, stepWarn: res.stepWarn, wasteSrc: res.wasteSrc });
      if (res.stepWarn) res.warn = (res.warn ? res.warn + ' · ' : '') + res.stepWarn;
      outs[step.id] = res.pieces;
    } catch (e) {
      res.error = e.message || String(e);
      res.pieces = [];
      outs[step.id] = null;
    }
  }
  return { results, spMap };
}


// ===================== Finishing: edges, corners, juice grooves, handles =====================
// A finish node (t = 4) wraps a piece with an ordered list of features. Every feature removes material
// either through the full thickness (plan features) or from the top / bottom (height features), so the
// finished solid is { d(x,y) >= 0, zb(x,y) <= z <= zt(x,y) }.
function sdRoundRect(x, y, cx, cy, hx, hy, r) {
  r = Math.max(0, Math.min(r, hx, hy));
  const qx = Math.abs(x - cx) - hx + r, qy = Math.abs(y - cy) - hy + r;
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;   // < 0 inside
}
function sdSegment(x, y, ax, ay, bx, by) {
  const px = x - ax, py = y - ay, dx = bx - ax, dy = by - ay;
  const L = dx * dx + dy * dy, t = L > 0 ? Math.max(0, Math.min(1, (px * dx + py * dy) / L)) : 0;
  return Math.hypot(px - t * dx, py - t * dy);
}
function finishNode(p, feats) {
  const base = p.t === 4 ? p.child : p, all = (p.t === 4 ? p.feats : []).concat(feats);
  const n = mk({ t: 4, dims: base.dims.slice(), child: base, feats: all, along: p.along });
  // precompute: for each feature, the plan features that came before it
  n.planBefore = all.map((f, i) => all.slice(0, i).filter(g => g.plan));
  n.planAll = all.filter(g => g.plan);
  return n;
}
// signed distance inside the plan shape (positive = inside material) for a given list of plan features
function planD(n, plan, x, y) {
  const [X, Y] = n.dims;
  let R = 0;
  for (const f of plan) if (f.kind === 'corner') R = f.r;
  let d = -sdRoundRect(x, y, X / 2, Y / 2, X / 2, Y / 2, R);
  for (const f of plan) {
    if (f.kind === 'slot') d = Math.min(d, sdSegment(x, y, f.ax, f.ay, f.bx, f.by) - f.w / 2);
    else if (f.kind === 'notch') {
      const qx = (x - f.cx) / f.a, qy = (y - f.cy) / f.b;
      d = Math.min(d, (Math.hypot(qx, qy) - 1) * Math.min(f.a, f.b));
    }
  }
  return d;
}
// evaluate the finished shape at (x,y): returns [d, zt, zb]
function finEval(n, x, y) {
  const [X, Y, Z] = n.dims;
  const d = planD(n, n.planAll, x, y);
  let zt = Z, zb = 0;
  n.feats.forEach((f, i) => {
    if (f.kind === 'edge') {
      const dk = planD(n, n.planBefore[i], x, y), r = f.r;
      if (dk >= r || r <= 0) return;
      const dd = Math.max(0, dk);
      const h = f.profile === 'chamfer' ? r - dd : r - Math.sqrt(Math.max(0, r * r - (r - dd) * (r - dd)));
      if (f.side === 'top') zt = Math.min(zt, Z - h); else zb = Math.max(zb, h);
    } else if (f.kind === 'groove') {
      const g = Math.abs(sdRoundRect(x, y, X / 2, Y / 2, X / 2 - f.inset, Y / 2 - f.inset, f.radius)), hw = f.w / 2;
      if (g >= hw) return;
      const u = g / hw;
      const dep = f.profile === 'v' ? f.depth * (1 - u) : f.profile === 'square' ? f.depth : f.depth * Math.sqrt(1 - u * u);
      zt = Math.min(zt, Z - dep);
    } else if (f.kind === 'grip') {
      // underside cove: elliptical in plan and in section
      for (const [e, along, mid] of f.edges.map(s => s === 'x0' ? [x, y, Y / 2] : s === 'x1' ? [X - x, y, Y / 2] : s === 'y0' ? [y, x, X / 2] : [Y - y, x, X / 2])) {
        const t = (along - mid) / (f.len / 2);
        if (Math.abs(t) >= 1) continue;
        const D = f.depth * Math.sqrt(1 - t * t);
        if (e >= D) continue;
        zb = Math.max(zb, f.height * Math.sqrt(1 - (e / D) * (e / D)));
      }
    }
  });
  return [d, zt, zb];
}
// does the finished piece keep material at local point p?
function finKeep(n, x, y, z) {
  const [d, zt, zb] = finEval(n, x, y);
  return d >= 0 && z <= zt && z >= zb;
}
// Build the feature list for a finishing step on a piece with dims [X,Y,Z]
function finishFeats(step, dims) {
  const [X, Y, Z] = dims;
  const D = (k, label) => { const v = parseDim(step[k], label); if (v < 0) throw new Error(`${label} can’t be negative`); return v; };
  const feats = [];
  if (step.type === 'edges') {
    const R = D('corner', 'Corner radius');
    if (R > Math.min(X, Y) / 2 + 1e-9) throw new Error(`Corner radius is bigger than half the board (${fmtNum(Math.min(X, Y) / 2)})`);
    if (R > 0) feats.push({ kind: 'corner', plan: true, r: R });
    for (const side of ['top', 'bottom']) {
      const prof = step[side + 'Profile'], r = D(side + 'Size', (side === 'top' ? 'Top' : 'Bottom') + ' edge size');
      if (prof && prof !== 'none' && r > 0) feats.push({ kind: 'edge', side, profile: prof, r });
    }
    const tot = feats.filter(f => f.kind === 'edge').reduce((s, f) => s + f.r, 0);
    if (tot > Z + 1e-9) throw new Error('Top and bottom edge profiles are taller than the board is thick');
  } else if (step.type === 'groove') {
    const inset = D('inset', 'Groove inset'), w = D('gwidth', 'Groove width'), depth = D('depth', 'Groove depth'), radius = D('radius', 'Groove corner radius');
    if (!(w > 0 && depth > 0)) throw new Error('Groove width and depth must be > 0');
    if (depth >= Z) throw new Error('The groove is deeper than the board');
    if (inset * 2 >= Math.min(X, Y)) throw new Error('Groove inset is too large for this board');
    feats.push({ kind: 'groove', inset, w, depth, radius, profile: step.profile || 'round' });
  } else if (step.type === 'handle') {
    const where = step.where || 'ends';
    // "ends" are the short edges of the board as it lies, whatever way its grain runs (an end-grain panel
    // can come out of the glue-up with its long axis along y)
    const longX = X >= Y;
    const E = longX ? ['x0', 'x1'] : ['y0', 'y1'], Sd = longX ? ['y0', 'y1'] : ['x0', 'x1'];
    const sides = where === 'ends' ? E : where === 'sides' ? Sd : where === 'end' ? [E[1]] : where === 'side' ? [Sd[0]] : ['x0', 'x1', 'y0', 'y1'];
    const kind = step.kind || 'grip';
    const len = D('len', 'Length');
    if (kind === 'grip') {
      const depth = D('hdepth', 'Grip depth'), height = D('height', 'Grip height');
      if (height >= Z) throw new Error('Grip height must be less than the board thickness');
      feats.push({ kind: 'grip', edges: sides, len, depth, height });
    } else {
      for (const s of sides) {
        const alongX = s === 'y0' || s === 'y1';                // the edge runs along x
        const edgeLen = alongX ? X : Y, mid = edgeLen / 2;
        if (len > edgeLen) throw new Error('The handle is longer than that edge');
        if (kind === 'notch') {
          const depth = D('hdepth', 'Notch depth');
          const cx = alongX ? mid : s === 'x0' ? 0 : X, cy = alongX ? (s === 'y0' ? 0 : Y) : mid;
          feats.push({ kind: 'notch', plan: true, cx, cy, a: alongX ? len / 2 : depth, b: alongX ? depth : len / 2 });
        } else {
          const w = kind === 'hole' ? len : D('swidth', 'Slot width');
          const inset = D('inset', 'Distance from the edge');
          if (inset - w / 2 <= 0) throw new Error('The hole would break through the edge — increase the distance from the edge');
          const half = kind === 'hole' ? 0 : Math.max(0, (len - w) / 2);
          if (alongX) { const yy = s === 'y0' ? inset : Y - inset; feats.push({ kind: 'slot', plan: true, ax: mid - half, ay: yy, bx: mid + half, by: yy, w }); }
          else { const xx = s === 'x0' ? inset : X - inset; feats.push({ kind: 'slot', plan: true, ax: xx, ay: mid - half, bx: xx, by: mid + half, w }); }
        }
      }
    }
  }
  return feats;
}
const fmtNum = v => (Math.round(v * 100) / 100).toString();


// ===================== WebGL renderer (optional) =====================
// 1) Flatten a piece into convex cells (polytopes), each with an exact affine map to its source board.
// 2) Triangulate the cells and draw them; the fragment shader runs the same wood model as the classic renderer.

// ---- mesh ----
const FLOATS_PER_VERT = 39;
function buildMesh(root) {
  const cells = flattenNode(root);
  let nv = 0, ne = 0;
  for (const c of cells) for (const f of c.poly) { nv += (f.length - 2) * 3; ne += f.length * 2; }
  const V = new Float32Array(nv * FLOATS_PER_VERT), E = new Float32Array(ne * 3);
  let vi = 0, ei = 0;
  for (const c of cells) {
    const { A, a, board: b } = c, sp = b.sp;
    const per = [b.py, b.pz, b.ty, b.tz, b.seed, b.tone, b.id, sp.hue || 0,
      sp.rgb[0] / 255, sp.rgb[1] / 255, sp.rgb[2] / 255, sp.ring, sp.drgb[0] / 255, sp.drgb[1] / 255, sp.drgb[2] / 255, sp.contrast,
      sp.wave, sp.pore || 0, sp.poreSize || 0.011, sp.ringPorous ? 1 : 0, sp.rays || 0, sp.raySp || 0.07, sp.rayLen || 0.25, sp.ribbon || 0];
    for (const f of c.poly) {
      // Newell normal
      let nx = 0, ny = 0, nz = 0;
      for (let i = 0; i < f.length; i++) { const p = f[i], q = f[(i + 1) % f.length]; nx += (p[1] - q[1]) * (p[2] + q[2]); ny += (p[2] - q[2]) * (p[0] + q[0]); nz += (p[0] - q[0]) * (p[1] + q[1]); }
      const L = Math.hypot(nx, ny, nz); if (L < 1e-10) continue;
      nx /= L; ny /= L; nz /= L;
      const bn = [A[0] * nx + A[1] * ny + A[2] * nz, A[3] * nx + A[4] * ny + A[5] * nz, A[6] * nx + A[7] * ny + A[8] * nz];
      const put = p => {
        const o = vi * FLOATS_PER_VERT;
        V[o] = p[0]; V[o + 1] = p[1]; V[o + 2] = p[2];
        V[o + 3] = A[0] * p[0] + A[1] * p[1] + A[2] * p[2] + a[0];
        V[o + 4] = A[3] * p[0] + A[4] * p[1] + A[5] * p[2] + a[1];
        V[o + 5] = A[6] * p[0] + A[7] * p[1] + A[8] * p[2] + a[2];
        V[o + 6] = nx; V[o + 7] = ny; V[o + 8] = nz;
        V[o + 9] = bn[0]; V[o + 10] = bn[1]; V[o + 11] = bn[2];
        for (let k = 0; k < 24; k++) V[o + 12 + k] = per[k];
        V[o + 36] = A[0]; V[o + 37] = A[1]; V[o + 38] = A[2];
        vi++;
      };
      for (let i = 1; i + 1 < f.length; i++) { put(f[0]); put(f[i]); put(f[i + 1]); }
      for (let i = 0; i < f.length; i++) { const p = f[i], q = f[(i + 1) % f.length]; E.set(p, ei); E.set(q, ei + 3); ei += 6; }
    }
  }
  return { V: V.subarray(0, vi * FLOATS_PER_VERT), vcount: vi, E: E.subarray(0, ei), ecount: ei / 3, cells: cells.length };
}

// ---- a scene: several parts, each moved by an offset (the board, offcuts beside it, an object for scale)
function makeScene(parts) {
  const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
  for (const p of parts) for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], p.o[k]); hi[k] = Math.max(hi[k], p.o[k] + p.node.dims[k]); }
  lo[2] = 0;
  const ps = parts.map(p => ({ node: p.node, o: [p.o[0] - lo[0], p.o[1] - lo[1], p.o[2]], edges: p.edges !== false }));
  return { scene: true, parts: ps, dims: [hi[0] - lo[0], hi[1] - lo[1], hi[2]], uid: 'scene:' + ps.map(p => p.node.uid + '@' + p.o.map(v => v.toFixed(3)).join(',')).join('|'), origin: lo };
}
function buildSceneMesh(sc) {
  const Ms = sc.parts.map(p => { const M = p.node.t === 4 ? buildFinishedMesh(p.node) : buildMesh(p.node); return { M, p }; });
  const nv = Ms.reduce((s, x) => s + x.M.vcount, 0), ne = Ms.reduce((s, x) => s + (x.p.edges ? x.M.ecount : 0), 0);
  const V = new Float32Array(nv * FLOATS_PER_VERT), E = new Float32Array(ne * 3);
  let vi = 0, ei = 0, cells = 0;
  for (const { M, p } of Ms) {
    V.set(M.V, vi * FLOATS_PER_VERT);
    for (let i = 0; i < M.vcount; i++) { const o = (vi + i) * FLOATS_PER_VERT; V[o] += p.o[0]; V[o + 1] += p.o[1]; V[o + 2] += p.o[2]; }
    vi += M.vcount; cells += M.cells;
    if (p.edges) { for (let i = 0; i < M.ecount; i++) { E[(ei + i) * 3] = M.E[i * 3] + p.o[0]; E[(ei + i) * 3 + 1] = M.E[i * 3 + 1] + p.o[1]; E[(ei + i) * 3 + 2] = M.E[i * 3 + 2] + p.o[2]; } ei += M.ecount; }
  }
  return { V, vcount: nv, E, ecount: ne, cells };
}

// ---- finished pieces: build the outer surface from the plan shape + top/bottom height fields ----
function boardAttrs(b) {
  const sp = b.sp;
  return [b.py, b.pz, b.ty, b.tz, b.seed, b.tone, b.id, sp.hue || 0,
    sp.rgb[0] / 255, sp.rgb[1] / 255, sp.rgb[2] / 255, sp.ring, sp.drgb[0] / 255, sp.drgb[1] / 255, sp.drgb[2] / 255, sp.contrast,
    sp.wave, sp.pore || 0, sp.poreSize || 0.011, sp.ringPorous ? 1 : 0, sp.rays || 0, sp.raySp || 0.07, sp.rayLen || 0.25, sp.ribbon || 0];
}
function buildFinishedMesh(fin) {
  const [X, Y, Z] = fin.dims;
  const cells = flattenNode(fin.child);
  // cell planes (outward normals) and xy bins
  for (const c of cells) {
    c.planes = [];
    c.bb = [Infinity, Infinity, -Infinity, -Infinity];
    for (const f of c.poly) {
      let nx = 0, ny = 0, nz = 0;
      for (let i = 0; i < f.length; i++) { const p = f[i], q = f[(i + 1) % f.length]; nx += (p[1] - q[1]) * (p[2] + q[2]); ny += (p[2] - q[2]) * (p[0] + q[0]); nz += (p[0] - q[0]) * (p[1] + q[1]); }
      const L = Math.hypot(nx, ny, nz); if (L < 1e-12) continue;
      const n = [nx / L, ny / L, nz / L], d = dot3(n, f[0]);
      // faces on the piece's outer box can't cut the surface; skipping them avoids round-off dropping walls
      const outer = [0, 1, 2].some(k => Math.abs(Math.abs(n[k]) - 1) < 1e-9 && (n[k] > 0 ? Math.abs(d - fin.dims[k]) < 2e-4 : Math.abs(d) < 2e-4));
      if (!outer) c.planes.push([n, d]);
      for (const p of f) { c.bb[0] = Math.min(c.bb[0], p[0]); c.bb[1] = Math.min(c.bb[1], p[1]); c.bb[2] = Math.max(c.bb[2], p[0]); c.bb[3] = Math.max(c.bb[3], p[1]); }
    }
    c.attrs = boardAttrs(c.board);
  }
  const NB = 64, bx = X / NB, by = Y / NB, bins = Array.from({ length: NB * NB }, () => []);
  cells.forEach((c, ci) => {
    const i0 = Math.max(0, Math.floor(c.bb[0] / bx - 1e-6)), i1 = Math.min(NB - 1, Math.floor(c.bb[2] / bx + 1e-6));
    const j0 = Math.max(0, Math.floor(c.bb[1] / by - 1e-6)), j1 = Math.min(NB - 1, Math.floor(c.bb[3] / by + 1e-6));
    for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) bins[j * NB + i].push(ci);
  });
  const ev = (x, y) => finEval(fin, x, y);
  const E = (x, y) => { const r = ev(x, y); return Math.min(r[0], r[1] - r[2]); };
  const hh = 0.004;
  const nTop = (x, y) => { const gx = (ev(x + hh, y)[1] - ev(x - hh, y)[1]) / (2 * hh), gy = (ev(x, y + hh)[1] - ev(x, y - hh)[1]) / (2 * hh); const L = Math.hypot(gx, gy, 1); return [-gx / L, -gy / L, 1 / L]; };
  const nBot = (x, y) => { const gx = (ev(x + hh, y)[2] - ev(x - hh, y)[2]) / (2 * hh), gy = (ev(x, y + hh)[2] - ev(x, y - hh)[2]) / (2 * hh); const L = Math.hypot(gx, gy, 1); return [gx / L, gy / L, -1 / L]; };
  const nWall = (x, y) => { const gx = (E(x + hh, y) - E(x - hh, y)) / (2 * hh), gy = (E(x, y + hh) - E(x, y - hh)) / (2 * hh); const L = Math.hypot(gx, gy) || 1; return [-gx / L, -gy / L, 0]; };
  // smallest feature → finest quad size
  let fs = 0.25;
  for (const f of fin.feats) for (const k of ['r', 'w', 'height', 'depth']) if (f[k] > 0) fs = Math.min(fs, f[k]);
  const minQ = Math.max(0.02, Math.min(0.1, fs / 4));
  const polys = [];   // { v: [{p, n}], kind }
  let budget = 90000;
  // walls on the outer boundary of the piece (where the outline coincides with the quad grid)
  const boundary = (x0, y0, x1, y1) => {
    const segs = [];
    if (x0 <= 1e-9) segs.push([[x0, y1], [x0, y0], [-1, 0, 0]]);
    if (x1 >= X - 1e-9) segs.push([[x1, y0], [x1, y1], [1, 0, 0]]);
    if (y0 <= 1e-9) segs.push([[x0, y0], [x1, y0], [0, -1, 0]]);
    if (y1 >= Y - 1e-9) segs.push([[x1, y1], [x0, y1], [0, 1, 0]]);
    for (let [a, b, n] of segs) {
      const ea = E(...a), eb = E(...b);
      if (ea < 0 && eb < 0) continue;
      if (ea < 0 || eb < 0) { const t = ea / (ea - eb); const m = [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]; if (ea < 0) a = m; else b = m; }
      const A2 = ev(...a), B2 = ev(...b);
      if (Math.max(A2[1] - A2[2], B2[1] - B2[2]) <= 1e-6) continue;
      polys.push([{ p: [a[0], a[1], A2[2]], n }, { p: [b[0], b[1], B2[2]], n }, { p: [b[0], b[1], B2[1]], n }, { p: [a[0], a[1], A2[1]], n }]);
    }
  };
  const emitFine = (x0, y0, x1, y1) => {
    boundary(x0, y0, x1, y1);
    const cs = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(([x, y]) => ({ x, y, e: E(x, y) }));
    const poly = [], cross = [];
    for (let i = 0; i < 4; i++) {
      const a = cs[i], b = cs[(i + 1) % 4];
      if (a.e >= 0) poly.push([a.x, a.y]);
      if ((a.e >= 0) !== (b.e >= 0)) { const t = a.e / (a.e - b.e); const p = [a.x + t * (b.x - a.x), a.y + t * (b.y - a.y)]; poly.push(p); cross.push(p); }
    }
    if (poly.length >= 3) {
      polys.push(poly.map(([x, y]) => ({ p: [x, y, ev(x, y)[1]], n: nTop(x, y) })));
      polys.push(poly.slice().reverse().map(([x, y]) => ({ p: [x, y, ev(x, y)[2]], n: nBot(x, y) })));
    }
    if (cross.length === 2) {
      let [a, b] = cross;
      const ea = ev(...a), eb = ev(...b);
      const na = nWall(...a), nb = nWall(...b);
      // order so the wall faces outward
      const tx = b[0] - a[0], ty = b[1] - a[1];
      if (tx * ((na[1] + nb[1]) / 2) - ty * ((na[0] + nb[0]) / 2) < 0) { [a, b] = [b, a]; }
      const A2 = ev(...a), B2 = ev(...b), nA = nWall(...a), nB = nWall(...b);
      void ea; void eb;
      if (Math.max(A2[1] - A2[2], B2[1] - B2[2]) > 1e-6)
        polys.push([{ p: [a[0], a[1], A2[2]], n: nA }, { p: [b[0], b[1], B2[2]], n: nB }, { p: [b[0], b[1], B2[1]], n: nB }, { p: [a[0], a[1], A2[1]], n: nA }]);
    }
  };
  const tol = 0.004;
  const quad = (x0, y0, x1, y1) => {
    if (budget-- <= 0) { emitFine(x0, y0, x1, y1); return; }
    const w = x1 - x0, h = y1 - y0, N = 4;
    const S = [];
    let anyIn = false, anyOut = false;
    for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) {
      const x = x0 + w * i / N, y = y0 + h * j / N, r = ev(x, y), e = Math.min(r[0], r[1] - r[2]);
      S.push([e, r[1], r[2]]);
      if (e >= 0) anyIn = true; else anyOut = true;
    }
    if (!anyIn && S.every(s => s[0] < -Math.hypot(w, h))) return;      // clearly outside
    // how far each field is from being linear along x and along y (second differences)
    let ex = 0, ey = 0;
    for (let k = 0; k < 3; k++) for (let j = 0; j <= N; j++) for (let i = 1; i < N; i++) {
      const s = q => S[j * (N + 1) + q][k];
      const sy = q => S[q * (N + 1) + j][k];
      ex = Math.max(ex, Math.abs(s(i) - (s(0) + (s(N) - s(0)) * i / N)));
      ey = Math.max(ey, Math.abs(sy(i) - (sy(0) + (sy(N) - sy(0)) * i / N)));
    }
    // the outline needs refinement only where the material boundary is (e near 0)
    const splitX = ex > tol && w > minQ * 1.0001, splitY = ey > tol && h > minQ * 1.0001;
    if (!splitX && !splitY) {
      if (!anyOut) {
        boundary(x0, y0, x1, y1);
        // whole quad is material: emit it directly (planar enough)
        const v = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
        polys.push(v.map(([x, y]) => ({ p: [x, y, ev(x, y)[1]], n: nTop(x, y) })));
        polys.push(v.slice().reverse().map(([x, y]) => ({ p: [x, y, ev(x, y)[2]], n: nBot(x, y) })));
      } else emitFine(x0, y0, x1, y1);
      return;
    }
    const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
    if (splitX && splitY) { quad(x0, y0, mx, my); quad(mx, y0, x1, my); quad(x0, my, mx, y1); quad(mx, my, x1, y1); }
    else if (splitX) { quad(x0, y0, mx, y1); quad(mx, y0, x1, y1); }
    else { quad(x0, y0, x1, my); quad(x0, my, x1, y1); }
  };
  const base = 1;
  const nx = Math.ceil(X / base), ny = Math.ceil(Y / base);
  for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) quad(i * X / nx, j * Y / ny, (i + 1) * X / nx, (j + 1) * Y / ny);
  // split every surface polygon among the wood cells it crosses
  const V = [], Ls = [];
  const clipA = (poly, n, d) => {
    const out = [];
    for (let i = 0; i < poly.length; i++) {
      const P = poly[i], Q = poly[(i + 1) % poly.length];
      const dp = dot3(n, P.p) - d, dq = dot3(n, Q.p) - d;
      const ip = dp <= 1e-6, iq = dq <= 1e-6;
      if (ip) out.push(P);
      if (ip !== iq) {
        const t = dp / (dp - dq);
        out.push({ p: [0, 1, 2].map(k => P.p[k] + t * (Q.p[k] - P.p[k])), n: [0, 1, 2].map(k => P.n[k] + t * (Q.n[k] - P.n[k])) });
      }
    }
    return out;
  };
  for (const poly of polys) {
    let mnx = Infinity, mny = Infinity, mxx = -Infinity, mxy = -Infinity;
    for (const v of poly) { mnx = Math.min(mnx, v.p[0]); mny = Math.min(mny, v.p[1]); mxx = Math.max(mxx, v.p[0]); mxy = Math.max(mxy, v.p[1]); }
    const cand = new Set();
    const cl = v => Math.max(0, Math.min(NB - 1, v));
    for (let j = cl(Math.floor(mny / by)); j <= cl(Math.floor(mxy / by)); j++)
      for (let i = cl(Math.floor(mnx / bx)); i <= cl(Math.floor(mxx / bx)); i++) for (const ci of bins[j * NB + i]) cand.add(ci);
    for (const ci of cand) {
      const c = cells[ci];
      // same slack as the outer-face test: pieces can sit a few millionths of an inch inside the outline
      if (mxx < c.bb[0] - 2e-4 || mnx > c.bb[2] + 2e-4 || mxy < c.bb[1] - 2e-4 || mny > c.bb[3] + 2e-4) continue;
      let p = poly;
      for (const [n, d] of c.planes) { p = clipA(p, n, d); if (p.length < 3) break; }
      if (p.length < 3) continue;
      const { A, a, attrs } = c;
      const put = v => {
        const q = v.p, nn = v.n, L = Math.hypot(...nn) || 1;
        const n0 = nn[0] / L, n1 = nn[1] / L, n2 = nn[2] / L;
        V.push(q[0], q[1], q[2],
          A[0] * q[0] + A[1] * q[1] + A[2] * q[2] + a[0], A[3] * q[0] + A[4] * q[1] + A[5] * q[2] + a[1], A[6] * q[0] + A[7] * q[1] + A[8] * q[2] + a[2],
          n0, n1, n2,
          A[0] * n0 + A[1] * n1 + A[2] * n2, A[3] * n0 + A[4] * n1 + A[5] * n2, A[6] * n0 + A[7] * n1 + A[8] * n2,
          ...attrs, A[0], A[1], A[2]);
      };
      for (let i = 1; i + 1 < p.length; i++) { put(p[0]); put(p[i]); put(p[i + 1]); }
      // glue lines: polygon edges that lie on a cell boundary plane crossing the surface
      for (let i = 0; i < p.length; i++) {
        const P = p[i], Q = p[(i + 1) % p.length];
        for (const [n, d] of c.planes) {
          if (Math.abs(dot3(n, P.p) - d) < 1e-6 && Math.abs(dot3(n, Q.p) - d) < 1e-6 && Math.abs(dot3(n, P.n)) < 0.95) {
            if (Math.abs(n[0] * X) < 1e-9 ? false : false) continue;
            // skip the piece's own outer faces (those coincide with the board outline)
            const onOuter = (Math.abs(n[0]) > 0.999 && (Math.abs(d) < 1e-6 || Math.abs(d - X * Math.sign(n[0])) < 1e-6)) || (Math.abs(n[1]) > 0.999 && (Math.abs(d) < 1e-6 || Math.abs(d - Y * Math.sign(n[1])) < 1e-6));
            if (!onOuter) Ls.push(...P.p, ...Q.p);
            break;
          }
        }
      }
    }
  }
  return { V: new Float32Array(V), vcount: V.length / FLOATS_PER_VERT, E: new Float32Array(Ls), ecount: Ls.length / 3, cells: cells.length };
}

// ---- shaders ----
const GL_VS = `#version 300 es
precision highp float;
layout(location=0) in vec3 aPos; layout(location=1) in vec3 aB; layout(location=2) in vec3 aN; layout(location=3) in vec3 aBN;
layout(location=4) in vec4 aB0; layout(location=5) in vec4 aB1; layout(location=6) in vec4 aS0; layout(location=7) in vec4 aS1; layout(location=8) in vec4 aS2; layout(location=9) in vec4 aS3;
layout(location=10) in vec3 aT;
uniform mat4 uMVP; uniform mat4 uLightMVP;
out vec3 vPos; out vec3 vB; out vec3 vN; out vec3 vBN; out vec4 vLS; flat out vec3 vT; flat out vec4 vB0; flat out vec4 vB1; flat out vec4 vS0; flat out vec4 vS1; flat out vec4 vS2; flat out vec4 vS3;
void main(){ vPos=aPos; vB=aB; vN=aN; vBN=aBN; vT=aT; vB0=aB0; vB1=aB1; vS0=aS0; vS1=aS1; vS2=aS2; vS3=aS3;
  vLS = uLightMVP*vec4(aPos,1.0); gl_Position=uMVP*vec4(aPos,1.0); }`;

const GL_SHADOW_FN = `
uniform highp sampler2DShadow uShadow; uniform float uShadowTexel; uniform float uShadowSoft;
const vec2 POISSON[12] = vec2[12](vec2(-0.326,-0.406), vec2(-0.840,-0.074), vec2(-0.696,0.457), vec2(-0.203,0.621), vec2(0.962,-0.195), vec2(0.473,-0.480),
  vec2(0.519,0.767), vec2(0.185,-0.893), vec2(0.507,0.064), vec2(0.896,0.412), vec2(-0.322,-0.933), vec2(-0.792,-0.598));
float shadowVis(vec4 ls, float bias){
  vec3 p = ls.xyz/ls.w*0.5+0.5;
  if (p.x<0.0||p.y<0.0||p.x>1.0||p.y>1.0||p.z>1.0) return 1.0;
  float s=0.0; float r=uShadowTexel*uShadowSoft;
  for (int i=0;i<12;i++) s+=texture(uShadow, vec3(p.xy+POISSON[i]*r, p.z-bias));
  return s/12.0;
}`;

const GL_FS = `#version 300 es
precision highp float; precision highp int;
in vec3 vPos; in vec3 vB; in vec3 vN; in vec3 vBN; in vec4 vLS; flat in vec3 vT; flat in vec4 vB0; flat in vec4 vB1; flat in vec4 vS0; flat in vec4 vS1; flat in vec4 vS2; flat in vec4 vS3;
uniform int uMode; uniform bool uOiled; uniform vec3 uEye; uniform vec3 uLight; uniform float uExposure;
out vec4 outColor;
` + GL_SHADOW_FN + `
float hash3(int a, int b, int c){
  uint h = uint(a)*374761393u + uint(b)*668265263u + uint(c)*2147483647u;
  h = (h ^ (h >> 13u)) * 1274126177u;
  h = h ^ (h >> 16u);
  return float(h) / 4294967296.0;
}
float vnoise(vec3 p){
  vec3 i = floor(p); vec3 f = p - i; f = f*f*(3.0-2.0*f); ivec3 q = ivec3(i);
  float a=hash3(q.x,q.y,q.z), b=hash3(q.x+1,q.y,q.z), c=hash3(q.x,q.y+1,q.z), d=hash3(q.x+1,q.y+1,q.z);
  float e=hash3(q.x,q.y,q.z+1), g=hash3(q.x+1,q.y,q.z+1), h=hash3(q.x,q.y+1,q.z+1), k=hash3(q.x+1,q.y+1,q.z+1);
  float x1=mix(a,b,f.x), x2=mix(c,d,f.x), x3=mix(e,g,f.x), x4=mix(h,k,f.x);
  return mix(mix(x1,x2,f.y), mix(x3,x4,f.y), f.z);
}
vec3 aces(vec3 x){ const float a=2.51, b=0.03, c=2.43, d=0.59, e=0.14; return clamp((x*(a*x+b))/(x*(c*x+d)+e),0.0,1.0); }
float D_GGX(float NoH, float r){ float a=r*r, a2=a*a; float d=NoH*NoH*(a2-1.0)+1.0; return a2/(3.14159*d*d); }
float V_Smith(float NoV, float NoL, float r){ float k=(r+1.0)*(r+1.0)/8.0; return 1.0/((NoV*(1.0-k)+k)*(NoL*(1.0-k)+k)); }
void main(){
  float SX=vB.x, SY=vB.y, SZ=vB.z;
  float py=vB0.x, pz=vB0.y, ty=vB0.z, tz=vB0.w, sd=vB1.x, tone0=vB1.y, hue=vB1.w; int bid=int(vB1.z+0.5);
  vec3 base=vS0.rgb; float ring=vS0.a; vec3 dark=vS1.rgb; float contrast=vS1.a;
  float wave=vS2.x, pore=vS2.y, poreSize=vS2.z, ringPorous=vS2.w;
  float rays=vS3.x, raySp=vS3.y, rayLen=vS3.z, ribbon=vS3.w;
  vec3 an = abs(vBN); int cls = (an.x>=an.y && an.x>=an.z) ? 0 : (an.y>=an.z ? 1 : 2);
  float PIX = max(max(length(dFdx(vB)), length(dFdy(vB))), 1e-4);
  float dy=SY-py-SX*ty, dz=SZ-pz-SX*tz;
  float n1=vnoise(vec3(SX*0.13+sd, SY*0.3+sd, SZ*0.3))-0.5;
  float n2=vnoise(vec3(SX*0.6+sd*2.0, SY*1.6, SZ*1.6+sd))-0.5;
  float r0=sqrt(dy*dy+dz*dz);
  float r=r0+wave*(0.9*n1+0.25*n2);
  float ph=r/ring+0.08*n2; float f=fract(ph);
  float late=0.35*smoothstep(0.3,0.8,f)+0.65*smoothstep(0.72,0.97,f);
  float aa=clamp((ring/PIX-1.5)/2.5,0.0,1.0);
  float t=contrast*(late*aa+0.28*(1.0-aa));
  float fib=0.0;
  if (PIX<0.05) { fib=vnoise(vec3(SX*1.2+sd, SY*45.0, SZ*45.0))-0.5; t+=0.07*fib; }
  if (ribbon>0.0 && cls!=0) t+=ribbon*sin(r0*9.0+2.0*n1)*(cls==1?1.0:0.4);
  float pit=0.0;            // relief: pores are tiny pits
  if (pore>0.0){
    float cs=poreSize;
    if (PIX<cs*2.0){
      float gy=SY/cs, gz=SZ/cs; int iy=int(floor(gy)), iz=int(floor(gz));
      int sx=int(floor(SX/(cs*14.0)+hash3(iy,iz,5)*9.0))*7919;
      float hp=hash3(iy,iz,sx ^ bid);
      float dens = ringPorous>0.5 ? (f<0.22?0.55:0.05) : 0.16;
      float qy=gy-float(iy)-0.5, qz=gz-float(iz)-0.5;
      float rr=qy*qy+qz*qz;
      if (hp<dens*pore && rr<0.12) { t+= cls==0?0.55:0.38; pit=1.0-rr/0.12; }
    } else t+=pore*(ringPorous>0.5?0.05:0.03);
  }
  if (rays>0.0 && PIX<raySp){
    float tang=r0*atan(dz,dy)+raySp*1.5*(vnoise(vec3(SX*0.5+sd, r0*1.5, 7.0))-0.5);
    float u=tang/raySp; float cf=floor(u); int cell=int(cf); float fr=u-cf;
    float sxx=SX/rayLen+hash3(cell,11,bid)*7.0; float sgf=floor(sxx); int seg=int(sgf); float sf=sxx-sgf;
    int seg2=int(floor(r0*2.5+hash3(cell,13,bid)*3.0));
    float wdt=(0.1+0.12*hash3(cell,seg,3))*sin(3.14159265*sf);
    if (hash3(cell,seg*131+seg2,bid ^ 23505)<0.55 && abs(fr-0.5)<wdt) t+=(cls==0?0.3:-0.32)*rays;
  }
  if (cls==0) t+=0.12;
  t=clamp(t,0.0,1.0);
  float drift=vnoise(vec3(SX*0.06+sd*3.0, SY*0.25, SZ*0.25+sd))-0.5;
  vec3 col=mix(base,dark,t)*tone0*(1.0+0.1*drift);
  float hv=hue*drift*2.0; col.r*=1.0+hv; col.b*=1.0-hv;
  if (uOiled && uMode==0){ float l=dot(col,vec3(0.3,0.59,0.11)); col.rg=(l+(col.rg-l)*1.08)*0.94; col.b=(l+(col.b-l)*1.08)*0.92; }
  if (uMode==1){
    float lum=dot(col,vec3(0.3,0.59,0.11));
    vec3 g = cls==0 ? vec3(214.,72.,22.)/255. : cls==1 ? vec3(28.,116.,214.)/255. : vec3(47.,158.,68.)/255.;
    col=g*(0.32+0.95*lum);
  }
  vec3 albedo = pow(clamp(col,0.0,1.0), vec3(2.2));     // to linear light

  // ---- surface relief (bump) from pores, latewood and fibres
  vec3 N = normalize(vN);
  float hgt = -0.0009*pit - 0.0005*late*aa + (cls==0 ? 0.0009*fib : 0.0003*fib);
  vec3 dpdx=dFdx(vPos), dpdy=dFdy(vPos); float dhx=dFdx(hgt), dhy=dFdy(hgt);
  vec3 r1=cross(dpdy,N), r2=cross(N,dpdx); float det=dot(dpdx,r1);
  if (abs(det)>1e-12) N = normalize(abs(det)*N - sign(det)*(dhx*r1+dhy*r2));
  if (!gl_FrontFacing) N = -N;

  vec3 L=normalize(uLight), V=normalize(uEye-vPos), H=normalize(L+V);
  float NoL=max(dot(N,L),0.0), NoV=max(dot(N,V),1e-3), NoH=max(dot(N,H),0.0), VoH=max(dot(V,H),0.0);
  float sh = shadowVis(vLS, 0.0009 + 0.002*(1.0-NoL));
  // oiled wood is satin; end grain stays rough
  float rough = uOiled ? (cls==0 ? 0.62 : 0.36) : (cls==0 ? 0.85 : 0.62);
  float F = 0.04 + 0.96*pow(1.0-VoH, 5.0);
  float spec = D_GGX(NoH, rough) * V_Smith(NoV, NoL, rough) * F / 4.0;
  // chatoyance: anisotropic sheen across the fibres (long grain only), shimmering with the fibre noise
  vec3 T = normalize(vT - N*dot(vT,N));
  float TH = dot(T,H); float sheen = pow(max(1.0-TH*TH,0.0), uOiled ? 60.0 : 25.0);
  float shimmer = 0.6+0.8*vnoise(vec3(SX*0.7, SY*14.0, SZ*14.0));
  float aniso = cls==0 ? 0.0 : (uOiled ? 0.22 : 0.07) * sheen * shimmer;
  vec3 keyC = vec3(1.0,0.95,0.87)*2.2;
  vec3 skyC = vec3(0.66,0.7,0.78)*0.5, groundC = vec3(0.5,0.42,0.33)*0.32;
  vec3 irr = mix(groundC, skyC, 0.5+0.5*N.z);
  vec3 diffuse = albedo * (keyC*NoL*sh + irr) / 3.14159 * 3.14159 * 0.31;
  vec3 R = reflect(-V,N);
  vec3 env = mix(groundC, skyC*1.6, smoothstep(-0.2,0.6,R.z));
  float Fv = 0.04 + 0.96*pow(1.0-NoV,5.0);
  float noPit = 1.0 - 0.85*pit;   // pores hold no sheen
  vec3 specular = (keyC*NoL*sh*(min(spec, 6.0) + aniso*albedo*2.0)*0.31 + env*Fv*(1.0-rough)*0.35) * noPit;
  vec3 c = (diffuse + specular) * uExposure;
  c = aces(c);
  outColor = vec4(pow(c, vec3(1.0/2.2)), 1.0);
}`;

const GL_DEPTH_VS = `#version 300 es
precision highp float; layout(location=0) in vec3 aPos; uniform mat4 uLightMVP; void main(){ gl_Position=uLightMVP*vec4(aPos,1.0); }`;
const GL_DEPTH_FS = `#version 300 es
precision highp float; out vec4 o; void main(){ o=vec4(1.0); }`;
const GL_GROUND_VS = `#version 300 es
precision highp float; layout(location=0) in vec2 aXY; uniform mat4 uMVP; uniform mat4 uLightMVP; uniform vec4 uArea; out vec3 vP; out vec4 vLS;
void main(){ vec2 p = mix(uArea.xy, uArea.zw, aXY); vP=vec3(p,0.0); vLS=uLightMVP*vec4(p,0.0,1.0); gl_Position=uMVP*vec4(p,0.0,1.0); }`;
const GL_GROUND_FS = `#version 300 es
precision highp float; in vec3 vP; in vec4 vLS; uniform float uBlur; uniform vec4 uArea; out vec4 o;
uniform highp sampler2DShadow uAO; uniform mat4 uAOMVP; uniform float uAORad;
` + GL_SHADOW_FN + `
float occ(vec2 xy, float rad){
  vec4 q = uAOMVP*vec4(xy,0.0,1.0); vec3 p = q.xyz/q.w*0.5+0.5;
  float s=0.0;
  for (int i=0;i<12;i++){ vec2 uv=p.xy+POISSON[i]*rad; s += (uv.x<0.0||uv.y<0.0||uv.x>1.0||uv.y>1.0) ? 1.0 : texture(uAO, vec3(uv, p.z-0.002)); }
  return 1.0 - s/12.0;      // fraction of nearby ground that has wood above it
}
void main(){
  float vis = shadowVis(vLS, 0.0015);
  // contact occlusion that follows the real footprint (rounded corners, gaps between strips)
  float ao = 0.34*occ(vP.xy, uAORad*0.35) + 0.22*occ(vP.xy, uAORad);
  vec2 ac=(uArea.xy+uArea.zw)*0.5, ah=(uArea.zw-uArea.xy)*0.5;
  float fade = 1.0-smoothstep(0.55,1.0,length((vP.xy-ac)/ah));    // fade the cast shadow out toward the edge of the table
  float a = clamp(max(ao, 0.5*(1.0-vis)*fade), 0.0, 0.85);
  o = vec4(vec3(0.20,0.13,0.07)*a, a);
}`;
const GL_LINE_VS = `#version 300 es
precision highp float; layout(location=0) in vec3 aPos; uniform mat4 uMVP; void main(){ gl_Position=uMVP*vec4(aPos,1.0); }`;
const GL_LINE_FS = `#version 300 es
precision highp float; uniform vec4 uColor; out vec4 o; void main(){ o=uColor; }`;
// ---- small matrix helpers (column-major) ----
function m4persp(fovy, aspect, near, far) { const f = 1 / Math.tan(fovy / 2), nf = 1 / (near - far); return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]; }
function m4look(e, c, up) {
  let z = [e[0] - c[0], e[1] - c[1], e[2] - c[2]]; const zl = Math.hypot(...z); z = z.map(v => v / zl);
  let x = [up[1] * z[2] - up[2] * z[1], up[2] * z[0] - up[0] * z[2], up[0] * z[1] - up[1] * z[0]]; const xl = Math.hypot(...x); x = x.map(v => v / xl);
  const y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]];
  return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot3(x, e), -dot3(y, e), -dot3(z, e), 1];
}
function m4ortho(l, r, b, t, n, f) { return [2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0, -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1]; }
function m4mul(a, b) { const o = new Array(16); for (let i = 0; i < 4; i++) for (let j = 0; j < 4; j++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + j] * b[i * 4 + k]; o[i * 4 + j] = s; } return o; }

// ---- renderer ----
function makeGL(interactive) { return {
  canvas: null, gl: null, ok: null, err: '',
  progs: {}, meshes: new Map(), node: null,
  cam: { yaw: -Math.PI / 4, pitch: 0.6155, zoom: 1, panX: 0, panY: 0 },
  opts: { mode: 'wood', oiled: true, edges: true },
  init() {
    if (this.ok !== null) return this.ok;
    const cv = document.createElement('canvas');
    cv.className = 'facecv glcv';
    const gl = cv.getContext('webgl2', { antialias: true, premultipliedAlpha: true, alpha: true, preserveDrawingBuffer: true });
    if (!gl) { this.ok = false; this.err = 'This browser can’t create a WebGL 2 context.'; return false; }
    const mk = (vs, fs) => {
      const p = gl.createProgram();
      for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
        const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
        if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
        gl.attachShader(p, s);
      }
      gl.linkProgram(p);
      if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
      return p;
    };
    try {
      this.progs.wood = mk(GL_VS, GL_FS);
      this.progs.line = mk(GL_LINE_VS, GL_LINE_FS);
      this.progs.depth = mk(GL_DEPTH_VS, GL_DEPTH_FS);
      this.progs.ground = mk(GL_GROUND_VS, GL_GROUND_FS);
    } catch (e) { this.ok = false; this.err = 'Shader error: ' + e.message; return false; }
    this.quad = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
    // shadow map
    this.SM = 2048;
    this.shadowTex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, this.shadowTex);
    gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, this.SM, this.SM);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
    this.AOS = 1024;
    this.aoTex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, this.aoTex);
    gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, this.AOS, this.AOS);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
    this.aoFB = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.aoFB);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.aoTex, 0);
    gl.drawBuffers([gl.NONE]); gl.readBuffer(gl.NONE);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    this.shadowFB = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFB);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, this.shadowTex, 0);
    gl.drawBuffers([gl.NONE]); gl.readBuffer(gl.NONE);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    this.canvas = cv; this.gl = gl; this.ok = true;
    if (interactive) this.bindInput();
    return true;
  },
  mesh(node) {
    let m = this.meshes.get(node.uid);
    if (m) return m;
    const gl = this.gl;
    const t0 = performance.now();
    const M = node.scene ? buildSceneMesh(node) : node.t === 4 ? buildFinishedMesh(node) : buildMesh(node);
    m = { vao: gl.createVertexArray(), vbo: gl.createBuffer(), ebo: gl.createBuffer(), lvao: gl.createVertexArray(), vcount: M.vcount, ecount: M.ecount, cells: M.cells, ms: performance.now() - t0 };
    gl.bindVertexArray(m.vao);
    gl.bindBuffer(gl.ARRAY_BUFFER, m.vbo); gl.bufferData(gl.ARRAY_BUFFER, M.V, gl.STATIC_DRAW);
    const stride = FLOATS_PER_VERT * 4;
    [[0, 3, 0], [1, 3, 3], [2, 3, 6], [3, 3, 9], [4, 4, 12], [5, 4, 16], [6, 4, 20], [7, 4, 24], [8, 4, 28], [9, 4, 32], [10, 3, 36]].forEach(([loc, size, off]) => {
      gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, stride, off * 4);
    });
    gl.bindVertexArray(m.lvao);
    gl.bindBuffer(gl.ARRAY_BUFFER, m.ebo); gl.bufferData(gl.ARRAY_BUFFER, M.E, gl.STATIC_DRAW);
    gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 12, 0);
    gl.bindVertexArray(null);
    this.meshes.set(node.uid, m);
    if (this.meshes.size > 8) {
      const [k, old] = this.meshes.entries().next().value;
      gl.deleteBuffer(old.vbo); gl.deleteBuffer(old.ebo); gl.deleteVertexArray(old.vao); gl.deleteVertexArray(old.lvao);
      this.meshes.delete(k);
    }
    return m;
  },
  show(stage, node, cssW, cssH, opts) {
    if (!this.init()) return false;
    if (this.node !== node) {
      const same = this.node && this.node.dims.every((v, i) => Math.abs(v - node.dims[i]) < 1e-6);
      if (!same) { this.cam.zoom = 1; this.cam.panX = 0; this.cam.panY = 0; }
      this.node = node;
    }
    Object.assign(this.opts, opts);
    const cv = this.canvas;
    cv.style.width = cssW + 'px'; cv.style.height = cssH + 'px';
    cv.width = Math.round(cssW * DPR); cv.height = Math.round(cssH * DPR);
    stage.append(cv);
    this.mesh(node);
    this.draw();
    return true;
  },
  draw() {
    const gl = this.gl, node = this.node, cv = this.canvas;
    if (!gl || !node) return;
    const m = this.mesh(node);
    const [X, Y, Z] = node.dims;
    const c = [X / 2, Y / 2, Z / 2];
    const rad = 0.5 * Math.hypot(X, Y, Z);
    const fov = 28 * Math.PI / 180, aspect = cv.width / cv.height;
    const fit = rad / Math.sin(fov / 2) / Math.min(1, aspect) * 1.02;
    const dist = fit / this.cam.zoom;
    const { yaw, pitch } = this.cam;
    const dir = [Math.cos(pitch) * Math.cos(yaw), Math.cos(pitch) * Math.sin(yaw), Math.sin(pitch)];
    // pan in the view plane
    const right = [-Math.sin(yaw), Math.cos(yaw), 0];
    const up = [-Math.sin(pitch) * Math.cos(yaw), -Math.sin(pitch) * Math.sin(yaw), Math.cos(pitch)];
    const ctr = [0, 1, 2].map(k => c[k] + right[k] * this.cam.panX * rad + up[k] * this.cam.panY * rad);
    const eye = [0, 1, 2].map(k => ctr[k] + dir[k] * dist);
    const P = m4persp(fov, aspect, Math.max(0.05, dist - rad * 3), dist + rad * 3);
    const Vw = m4look(eye, ctr, [0, 0, 1]);
    const MVP = m4mul(P, Vw);
    // ---- shadow pass from the key light
    const Ld = [-0.4, -0.5, 0.77], Ll = Math.hypot(...Ld), L = Ld.map(v => v / Ll);
    const sr = rad * 1.6;
    const leye = [0, 1, 2].map(k => c[k] + L[k] * rad * 4);
    const LV = m4look(leye, c, [0, 0, 1]);
    const LP = m4ortho(-sr * 1.4, sr * 1.4, -sr * 1.4, sr * 1.4, rad * 0.5, rad * 8);
    const LMVP = m4mul(LP, LV);
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.shadowFB);
    gl.viewport(0, 0, this.SM, this.SM);
    gl.clear(gl.DEPTH_BUFFER_BIT);
    gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LESS);
    gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(2, 4);
    const dp = this.progs.depth; gl.useProgram(dp);
    gl.uniformMatrix4fv(gl.getUniformLocation(dp, 'uLightMVP'), false, LMVP);
    gl.bindVertexArray(m.vao); gl.drawArrays(gl.TRIANGLES, 0, m.vcount);
    // top-down depth map (for contact occlusion on the table)
    const ext = rad * 2.6;
    const AOV = m4look([c[0], c[1], Z + rad * 2], [c[0], c[1], 0], [0, 1, 0]);
    const AOP = m4ortho(-ext, ext, -ext, ext, rad * 0.5, Z + rad * 3);
    const AOMVP = m4mul(AOP, AOV);
    gl.bindFramebuffer(gl.FRAMEBUFFER, this.aoFB);
    gl.viewport(0, 0, this.AOS, this.AOS);
    gl.clear(gl.DEPTH_BUFFER_BIT);
    gl.uniformMatrix4fv(gl.getUniformLocation(dp, 'uLightMVP'), false, AOMVP);
    gl.drawArrays(gl.TRIANGLES, 0, m.vcount);
    gl.disable(gl.POLYGON_OFFSET_FILL);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    const texel = 1 / this.SM;
    // ---- main pass
    gl.viewport(0, 0, cv.width, cv.height);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.shadowTex);
    // table: only the shadows are drawn (the page backdrop shows through)
    if (dir[2] > 0) {
      gl.disable(gl.DEPTH_TEST);
      gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
      const gp = this.progs.ground; gl.useProgram(gp);
      gl.bindVertexArray(null);
      gl.bindBuffer(gl.ARRAY_BUFFER, this.quad); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 8, 0);
      gl.uniformMatrix4fv(gl.getUniformLocation(gp, 'uMVP'), false, MVP);
      gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.aoTex); gl.activeTexture(gl.TEXTURE0);
      gl.uniform1i(gl.getUniformLocation(gp, 'uAO'), 1);
      gl.uniformMatrix4fv(gl.getUniformLocation(gp, 'uAOMVP'), false, AOMVP);
      gl.uniform1f(gl.getUniformLocation(gp, 'uAORad'), Math.max(0.3, Z * 1.6) / (2 * ext));
      gl.uniformMatrix4fv(gl.getUniformLocation(gp, 'uLightMVP'), false, LMVP);
      gl.uniform4f(gl.getUniformLocation(gp, 'uArea'), c[0] - ext, c[1] - ext, c[0] + ext, c[1] + ext);
      gl.uniform1f(gl.getUniformLocation(gp, 'uBlur'), Math.max(0.25, Z * 0.9));
      gl.uniform1i(gl.getUniformLocation(gp, 'uShadow'), 0);
      gl.uniform1f(gl.getUniformLocation(gp, 'uShadowTexel'), texel);
      gl.uniform1f(gl.getUniformLocation(gp, 'uShadowSoft'), 7.0);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      gl.disableVertexAttribArray(0);
      gl.disable(gl.BLEND);
    }
    // wood
    gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL);
    gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1, 1);
    const wp = this.progs.wood; gl.useProgram(wp);
    gl.uniformMatrix4fv(gl.getUniformLocation(wp, 'uMVP'), false, MVP);
    gl.uniformMatrix4fv(gl.getUniformLocation(wp, 'uLightMVP'), false, LMVP);
    gl.uniform1i(gl.getUniformLocation(wp, 'uMode'), this.opts.mode === 'grain' ? 1 : 0);
    gl.uniform1i(gl.getUniformLocation(wp, 'uOiled'), this.opts.oiled ? 1 : 0);
    gl.uniform3f(gl.getUniformLocation(wp, 'uEye'), ...eye);
    gl.uniform3f(gl.getUniformLocation(wp, 'uLight'), ...L);
    gl.uniform1f(gl.getUniformLocation(wp, 'uExposure'), this.opts.mode === 'grain' ? 1.0 : 0.92);
    gl.uniform1i(gl.getUniformLocation(wp, 'uShadow'), 0);
    gl.uniform1f(gl.getUniformLocation(wp, 'uShadowTexel'), texel);
    gl.uniform1f(gl.getUniformLocation(wp, 'uShadowSoft'), 1.6);
    gl.bindVertexArray(m.vao);
    gl.drawArrays(gl.TRIANGLES, 0, m.vcount);
    gl.disable(gl.POLYGON_OFFSET_FILL);
    // glue lines
    if (this.opts.edges && m.ecount) {
      gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      const lp = this.progs.line; gl.useProgram(lp);
      gl.uniformMatrix4fv(gl.getUniformLocation(lp, 'uMVP'), false, MVP);
      gl.uniform4f(gl.getUniformLocation(lp, 'uColor'), 0.1, 0.06, 0.03, 0.3);
      gl.bindVertexArray(m.lvao);
      gl.drawArrays(gl.LINES, 0, m.ecount);
      gl.disable(gl.BLEND);
    }
    gl.bindVertexArray(null);
  },
  reset() { this.cam = { yaw: -Math.PI / 4, pitch: 0.6155, zoom: 1, panX: 0, panY: 0 }; this.draw(); },
  preset(name) {
    const P = { iso: [-Math.PI / 4, 0.6155], top: [-Math.PI / 2, 1.5607], front: [-Math.PI / 2, 0.05], end: [0, 0.05], under: [-Math.PI / 4, -0.6155] }[name];
    if (!P) return;
    this.cam.yaw = P[0]; this.cam.pitch = P[1]; this.cam.panX = 0; this.cam.panY = 0; this.draw();
  },
  bindInput() {
    const cv = this.canvas, pts = new Map();
    let raf = 0;
    const redraw = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; this.draw(); }); };
    cv.style.touchAction = 'none';
    cv.addEventListener('pointerdown', e => { cv.setPointerCapture(e.pointerId); pts.set(e.pointerId, { x: e.clientX, y: e.clientY, btn: e.button, shift: e.shiftKey }); });
    const end = e => pts.delete(e.pointerId);
    cv.addEventListener('pointerup', end); cv.addEventListener('pointercancel', end);
    cv.addEventListener('pointermove', e => {
      const p = pts.get(e.pointerId); if (!p) return;
      const dx = e.clientX - p.x, dy = e.clientY - p.y;
      if (pts.size === 2) {
        const [a, b] = [...pts.values()];
        const d0 = Math.hypot(a.x - b.x, a.y - b.y);
        p.x = e.clientX; p.y = e.clientY;
        const d1 = Math.hypot(a.x - b.x, a.y - b.y);
        if (d0 > 0) this.cam.zoom = Math.min(20, Math.max(0.4, this.cam.zoom * d1 / d0));
      } else if (p.btn === 2 || p.shift) {
        p.x = e.clientX; p.y = e.clientY;
        const s = 2 / (this.cam.zoom * cv.clientHeight);
        this.cam.panX -= dx * s; this.cam.panY += dy * s;
      } else {
        p.x = e.clientX; p.y = e.clientY;
        this.cam.yaw -= dx * 0.008;
        this.cam.pitch = Math.max(-1.5, Math.min(1.5607, this.cam.pitch + dy * 0.008));
      }
      redraw();
    });
    cv.addEventListener('wheel', e => { if (this.wheelNeedsModifier && !e.ctrlKey && !e.metaKey) return; e.preventDefault(); this.cam.zoom = Math.min(20, Math.max(0.4, this.cam.zoom * Math.exp(-e.deltaY * 0.0015))); redraw(); }, { passive: false });
    cv.addEventListener('contextmenu', e => e.preventDefault());
    cv.addEventListener('dblclick', () => this.reset());
  },
  // render one piece to a standalone 2D canvas (used for 3D thumbnails)
  snapshot(node, cssW, cssH, opts) {
    if (!this.init()) return null;
    this.node = node;
    this.cam = { yaw: -Math.PI / 4, pitch: 0.6155, zoom: 1.15, panX: 0, panY: 0 };
    Object.assign(this.opts, opts);
    const cv = this.canvas;
    cv.width = Math.max(8, Math.round(cssW * DPR)); cv.height = Math.max(8, Math.round(cssH * DPR));
    this.draw();
    const out = document.createElement('canvas');
    out.width = cv.width; out.height = cv.height;
    out.getContext('2d').drawImage(cv, 0, 0);
    out.style.width = cssW + 'px'; out.style.height = cssH + 'px';
    return out;
  },
}; }
const GLR = makeGL(true), GLT = makeGL(false);
// lay several pieces out on the table, like strips on a bench (for the WebGL "All pieces" view)
const LAYOUTS = new WeakMap();
function layoutPieces(pieces) {
  let L = LAYOUTS.get(pieces);
  if (L !== undefined) return L;
  if (pieces.some(p => p.t === 4)) { LAYOUTS.set(pieces, null); return null; }
  const maxX = Math.max(...pieces.map(p => p.dims[0]));
  const area = pieces.reduce((s, p) => s + p.dims[0] * p.dims[1], 0);
  const avgW = pieces.reduce((s, p) => s + p.dims[1], 0) / pieces.length;
  const gap = Math.max(0.25, Math.min(1.5, avgW * 0.35));
  const colLen = Math.max(maxX * 0.9, Math.sqrt(area * 1.6), avgW * 2);
  let x = 0, y = 0, colW = 0;
  const kids = [];
  for (const p of pieces) {
    const [X, Y] = p.dims;
    if (y > 0 && y + Y > colLen) { x += colW + gap * 2; y = 0; colW = 0; }
    kids.push({ node: p, o: [x, y, 0], lo: y, hi: y + Y });
    y += Y + gap; colW = Math.max(colW, X);
  }
  const dims = [0, 1, 2].map(k => Math.max(...kids.map(q => q.o[k] + q.node.dims[k])));
  L = mk({ t: 2, dims, axis: 1, kids, nest: true });
  LAYOUTS.set(pieces, L);
  return L;
}

const EXAMPLES = {"checker":{"name":"End-grain checkerboard","group":"Classics","units":"in","seed":1,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple 8/4 board","species":"maple","count":1,"length":"20","width":"5","thickness":"1 1/2","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut 8/4 board","species":"walnut","count":1,"length":"20","width":"5","thickness":"1 1/2","sawing":"flat"},{"id":"C","type":"cut","note":"Rip 3 maple sticks","input":"A*","angle":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":3,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"D","type":"cut","note":"Rip 3 walnut sticks","input":"B*","angle":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":3,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"E","type":"glue","note":"First glue-up: alternating sticks","input":"(C D)*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"F","type":"cut","note":"Crosscut into slices","input":"E*","angle":90,"widths":"1 1/2","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"G","type":"transform","note":"End grain up, flip every other slice","input":"F*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"even","list":"","kind":"spin","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"H","type":"glue","note":"Second glue-up","input":"G*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"I","type":"trim","note":"Flatten & square","input":"H*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"1/16","z1":"1/16"},{"id":"J","type":"handle","note":"Finger grips under both ends","input":"I*","kind":"grip","where":"ends","len":"5","hdepth":"3/4","height":"1/2","swidth":"1","inset":"1 1/2"},{"id":"K","type":"edges","note":"Ease the corners and edges","input":"J*","corner":"1/2","topProfile":"roundover","topSize":"3/16","bottomProfile":"chamfer","bottomSize":"1/8"}]},"checkerbands":{"name":"Maple, cherry & walnut checker bands (12x12)","group":"Classics","units":"in","seed":7,"species":[],"steps":[{"count":2,"id":"A","length":"16","note":"Maple 1×8, 16″ long (two boards)","sawing":"flat","species":"maple","thickness":"3/4","type":"stock","width":"7 1/2"},{"count":2,"id":"B","length":"16","note":"Walnut 1×4, 16″ long (two boards)","sawing":"flat","species":"walnut","thickness":"3/4","type":"stock","width":"3 1/2"},{"count":2,"id":"C","length":"16","note":"Cherry 1×4, 16″ long (two boards) for the wide bands","sawing":"flat","species":"cherry","thickness":"3/4","type":"stock","width":"3 1/2"},{"angle":0,"id":"D","input":"A*","keepRem":true,"kerf":"1/8","max":3,"note":"Fence at 1 1/2″: rip 3 wide strips from each maple board, keep the rest","offset":"0","type":"cut","widths":"1 1/2"},{"angle":0,"id":"E","input":"D4 D8","kerf":"1/8","max":3,"note":"Fence at 3/4″: rip 3 narrow maple strips from each leftover","offset":"0","type":"cut","widths":"3/4"},{"angle":0,"id":"F","input":"B*","kerf":"1/8","max":3,"note":"Same 3/4″ fence: rip 3 walnut strips from each board","offset":"0","type":"cut","widths":"3/4"},{"angle":0,"id":"G","input":"C*","kerf":"1/8","max":2,"note":"Fence at 1 1/2″: rip 2 cherry strips from each board","offset":"0","type":"cut","widths":"1 1/2"},{"align":"start","axis":"y","groupSize":"11","id":"H","input":"E1 F1 D1 G1 E2 F2 D2 G2 E3 F3 D3 E4 F4 D5 G3 E5 F5 D6 G4 E6 F6 D7","note":"Two identical panels, 11 strips each: maple 3/4, walnut, maple 1 1/2, cherry, maple 3/4, walnut, maple 1 1/2, cherry, maple 3/4, walnut, maple 1 1/2","type":"glue"},{"angle":90,"id":"I","input":"H*","kerf":"1/8","max":8,"note":"Square one end, crosscut 8 slices at 1 3/4″ from each panel (stop block)","offset":"1/4","type":"cut","widths":"1 3/4"},{"id":"J","input":"I*","note":"Stand slices end grain up; turn every other slice end for end — that makes the checker","ops":[{"angle":90,"kind":"roll","target":"all"},{"angle":180,"kind":"spin","target":"even"}],"type":"transform"},{"align":"start","axis":"y","id":"K","input":"J*","note":"Final glue-up: 16 slices side by side","type":"glue"},{"id":"L","input":"K*","note":"Flatten both faces to 1 1/2″ and square the edges","planeFrom":"both","planeTo":"1 1/2","square":"top","type":"trim"},{"bottomProfile":"chamfer","bottomSize":"3/4","corner":"1/4","id":"M","input":"L*","note":"Ease the edges: 1/4″ corners, 1/8″ roundover top and bottom","topProfile":"roundover","topSize":"1/8","type":"edges"}]},"stripes":{"name":"Edge-grain stripes","group":"Classics","units":"in","seed":2,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple board","species":"maple","count":1,"length":"18","width":"7","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut board","species":"walnut","count":1,"length":"18","width":"5","thickness":"3/4","sawing":"flat"},{"id":"C","type":"cut","note":"Rip maple","input":"A*","angle":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":0,"keepRem":false},{"id":"D","type":"cut","note":"Rip walnut","input":"B*","angle":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":0,"keepRem":false},{"id":"E","type":"transform","note":"Interleave and roll edges up","input":"(C D)* C","reverse":false,"ops":[{"target":"all","list":"","kind":"roll","angle":90}]},{"id":"F","type":"glue","note":"Glue-up","input":"E*","axis":"y","align":"start","stagger":"0"}]},"chevron":{"name":"Chevron (45° cuts)","group":"Angles & inlays","units":"in","seed":3,"species":[],"steps":[{"id":"A","type":"stock","species":"maple","note":"Maple 1×4 (3 1/2″ actual)","count":1,"length":"29 1/2","width":"3 1/2","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","species":"walnut","note":"Walnut 1×4 (3 1/2″ actual)","count":1,"length":"29 1/2","width":"3 1/2","thickness":"3/4","sawing":"flat"},{"id":"C","type":"stock","species":"cherry","note":"Cherry 1×4 (3 1/2″ actual)","count":1,"length":"29 1/2","width":"3 1/2","thickness":"3/4","sawing":"flat"},{"id":"D","type":"cut","note":"Rip maple into three 1″ strips","input":"A*","angle":0,"widths":"1","kerf":"1/8","offset":"0","max":0,"keepRem":false},{"id":"E","type":"cut","note":"Rip walnut into three 1″ strips","input":"B*","angle":0,"widths":"1","kerf":"1/8","offset":"0","max":0,"keepRem":false},{"id":"F","type":"cut","note":"Rip cherry into three 1″ strips","input":"C*","angle":0,"widths":"1","kerf":"1/8","offset":"0","max":0,"keepRem":false},{"id":"G","type":"glue","note":"Striped panel: maple, walnut, cherry, repeated","input":"(D E F)*","axis":"y","align":"start","stagger":"0"},{"id":"H","type":"cut","note":"45° cuts across the stripes (the first three are short offcuts)","input":"G*","angle":45,"widths":"1 1/2","kerf":"1/8","offset":"1.5","max":12,"keepRem":false},{"id":"I","type":"transform","note":"Keep the nine full-length strips; flip every other one over","input":"H4-12","reverse":false,"ops":[{"target":"even","list":"","kind":"roll","angle":180}]},{"id":"J","type":"glue","note":"Chevron glue-up","input":"I*","axis":"y","align":"center","stagger":"0"},{"id":"K","type":"trim","note":"Square the ends","input":"J*","square":true,"x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"}]},"diamond":{"name":"End-grain diamonds (harlequin)","group":"Stripes & logs","units":"in","seed":5,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple ¾″ boards","species":"maple","count":2,"length":"30","width":"3 1/2","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut ¾″ boards","species":"walnut","count":2,"length":"30","width":"3 1/2","thickness":"3/4","sawing":"flat"},{"id":"C","type":"glue","note":"Laminate a striped block, 3″ thick","input":"(A B)*","axis":"z","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"D","type":"cut","note":"Rip the block into 4 sticks","input":"C*","angle":0,"widths":"3/4","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"E","type":"transform","note":"Flip every other stick over → checkerboard end","input":"D*","ops":[{"target":"even","list":"","kind":"roll","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"F","type":"glue","note":"Checkerboard log (long grain), 3″ square","input":"E*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"G","type":"cut","note":"Blade at 45°: bevel-rip two opposite corners off the log","input":"F*","angle":0,"widths":"2.1213","kerf":"1/8","offset":"1.0607","max":1,"keepRem":false,"taper":false,"widthsEnd":"","bevel":45},{"id":"H","type":"transform","note":"Lay it down on a fresh bevel-cut face","input":"G*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"I","type":"cut","note":"Blade square: rip the other two corners off — a diamond log","input":"H*","angle":0,"widths":"2.1213","kerf":"1/8","offset":"1.0607","max":1,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0},{"id":"J","type":"cut","note":"Crosscut 25 tiles","input":"I*","angle":90,"widths":"1","kerf":"1/8","offset":"0","max":25,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"K","type":"transform","note":"End grain up","input":"J*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"L","type":"glue","note":"One glue-up: 5 tiles per row","input":"K*","axis":"grid","align":"start","stagger":"0","nest":false,"perRow":5,"offTarget":"none","offList":"","offAmount":"0"}]},"blank":{"name":"Blank project","group":"Start fresh","units":"in","seed":4,"species":[],"steps":[{"id":"A","type":"stock","note":"","species":"maple","count":1,"length":"18","width":"6","thickness":"3/4","sawing":"flat"}]},"pinwheel":{"name":"Half-square triangles: pinwheels","group":"Quilt tiles","units":"in","seed":4,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple 8/4 board","species":"maple","count":1,"length":"18 1/2","width":"3 1/2","thickness":"1 1/2","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut 8/4 board","species":"walnut","count":1,"length":"18 1/2","width":"3 1/2","thickness":"1 1/2","sawing":"flat"},{"type":"cut","kerf":"1/8","offset":"0","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"C","note":"Rip 2 square maple sticks","input":"A*","widths":"1 1/2"},{"type":"cut","kerf":"1/8","offset":"0","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"D","note":"Rip 2 square walnut sticks","input":"B*","widths":"1 1/2"},{"type":"cut","kerf":"1/8","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":"","bevel":45,"angle":0,"id":"E","note":"Blade at 45°: rip each maple stick corner to corner","input":"C*","widths":"0.9982, 9"},{"type":"cut","kerf":"1/8","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":"","bevel":45,"angle":0,"id":"F","note":"Blade at 45°: rip each walnut stick corner to corner","input":"D*","widths":"0.9982, 9"},{"id":"G","type":"glue","note":"Three HST logs: a maple half and a walnut half each (one maple and one walnut half are spare)","input":"E1 F2 F1 E2 E3 F4 F3 E4","axis":"y","align":"start","stagger":"0","nest":true,"perRow":4,"offTarget":"none","offList":"","offAmount":"0","groupSize":"2"},{"id":"H","type":"transform","note":"Lay the logs flat on a square face","input":"G*","ops":[{"target":"all","list":"","kind":"roll","angle":-45}],"reverse":false,"shuffle":false,"seed":1},{"id":"I","type":"trim","note":"Square the logs","input":"H*","square":"tight","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"type":"cut","kerf":"1/8","offset":"1/4","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":90,"id":"J","note":"Crosscut each log into two 8 3/4\" lengths (stop block)","input":"I*","widths":"8 3/4"},{"id":"K","type":"transform","note":"Turn each piece on its long axis: walnut halves top-right, then bottom-right — the top half of a row of pinwheels","input":"J*","ops":[{"target":"list","list":"3,7","kind":"tip","angle":90},{"target":"list","list":"2,6","kind":"tip","angle":180},{"target":"list","list":"1,5","kind":"tip","angle":270}],"reverse":false,"shuffle":false,"seed":1},{"id":"L","type":"glue","note":"The row log: six pieces edge to edge","input":"K*","axis":"x","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"type":"cut","kerf":"1/8","offset":"1/4","max":6,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"M","note":"Square one end, then crosscut six 1 1/4\" rows","input":"L*","widths":"1 1/4"},{"id":"N","type":"transform","note":"Stand the rows end grain up, turning every other row end for end — that makes the other half of each block","input":"M*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"odd","list":"","kind":"spin","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"O","type":"glue","note":"Glue the six rows together","input":"N*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"P","type":"trim","note":"Flatten both faces","input":"O*","square":"none","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0","planeTo":"1 1/8","planeFrom":"both"}]},"hstdiamond":{"name":"Half-square triangles: diamonds","group":"Quilt tiles","units":"in","seed":4,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple 8/4 board","species":"maple","count":1,"length":"18 1/2","width":"3 1/2","thickness":"1 1/2","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut 8/4 board","species":"walnut","count":1,"length":"18 1/2","width":"3 1/2","thickness":"1 1/2","sawing":"flat"},{"type":"cut","kerf":"1/8","offset":"0","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"C","note":"Rip 2 square maple sticks","input":"A*","widths":"1 1/2"},{"type":"cut","kerf":"1/8","offset":"0","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"D","note":"Rip 2 square walnut sticks","input":"B*","widths":"1 1/2"},{"type":"cut","kerf":"1/8","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":"","bevel":45,"angle":0,"id":"E","note":"Blade at 45°: rip each maple stick corner to corner","input":"C*","widths":"0.9982, 9"},{"type":"cut","kerf":"1/8","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":"","bevel":45,"angle":0,"id":"F","note":"Blade at 45°: rip each walnut stick corner to corner","input":"D*","widths":"0.9982, 9"},{"id":"G","type":"glue","note":"Three HST logs: a maple half and a walnut half each (one maple and one walnut half are spare)","input":"E1 F2 F1 E2 E3 F4 F3 E4","axis":"y","align":"start","stagger":"0","nest":true,"perRow":4,"offTarget":"none","offList":"","offAmount":"0","groupSize":"2"},{"id":"H","type":"transform","note":"Lay the logs flat on a square face","input":"G*","ops":[{"target":"all","list":"","kind":"roll","angle":-45}],"reverse":false,"shuffle":false,"seed":1},{"id":"I","type":"trim","note":"Square the logs","input":"H*","square":"end","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"type":"cut","kerf":"1/8","offset":"1/4","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":90,"id":"J","note":"Crosscut each log into two 8 3/4\" lengths (stop block)","input":"I*","widths":"8 3/4"},{"id":"K","type":"transform","note":"Turn each piece on its long axis so pairs of walnut halves meet along the bottom — the top half of a row of diamonds","input":"J*","ops":[{"target":"list","list":"2,6","kind":"tip","angle":90},{"target":"list","list":"1,5","kind":"tip","angle":180},{"target":"list","list":"4,8","kind":"tip","angle":270}],"reverse":false,"shuffle":false,"seed":1},{"id":"L","type":"glue","note":"The row log: six pieces edge to edge","input":"K*","axis":"x","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"type":"cut","kerf":"1/8","offset":"1/4","max":6,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"M","note":"Square one end, then crosscut six 1 1/4\" rows","input":"L*","widths":"1 1/4"},{"id":"N","type":"transform","note":"Stand the rows end grain up, turning every other row end for end — that makes the other half of each block","input":"M*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"odd","list":"","kind":"spin","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"O","type":"glue","note":"Glue the six rows together","input":"N*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"P","type":"trim","note":"Flatten both faces","input":"O*","square":"none","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0","planeTo":"1 1/8","planeFrom":"both"}]},"geese":{"name":"Half-square triangles: flying geese","group":"Quilt tiles","units":"in","seed":4,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple 8/4 board","species":"maple","count":1,"length":"18 1/2","width":"3 1/2","thickness":"1 1/2","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut 8/4 board","species":"walnut","count":1,"length":"18 1/2","width":"3 1/2","thickness":"1 1/2","sawing":"flat"},{"type":"cut","kerf":"1/8","offset":"0","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"C","note":"Rip 2 square maple sticks","input":"A*","widths":"1 1/2"},{"type":"cut","kerf":"1/8","offset":"0","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"D","note":"Rip 2 square walnut sticks","input":"B*","widths":"1 1/2"},{"type":"cut","kerf":"1/8","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":"","bevel":45,"angle":0,"id":"E","note":"Blade at 45°: rip each maple stick corner to corner","input":"C*","widths":"0.9982, 9"},{"type":"cut","kerf":"1/8","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":"","bevel":45,"angle":0,"id":"F","note":"Blade at 45°: rip each walnut stick corner to corner","input":"D*","widths":"0.9982, 9"},{"id":"G","type":"glue","note":"Three HST logs: a maple half and a walnut half each (one maple and one walnut half are spare)","input":"E1 F2 F1 E2 E3 F4 F3 E4","axis":"y","align":"start","stagger":"0","nest":true,"perRow":4,"offTarget":"none","offList":"","offAmount":"0","groupSize":"2"},{"id":"H","type":"transform","note":"Lay the logs flat on a square face","input":"G*","ops":[{"target":"all","list":"","kind":"roll","angle":-45}],"reverse":false,"shuffle":false,"seed":1},{"id":"I","type":"trim","note":"Square the logs","input":"H*","square":"end","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"type":"cut","kerf":"1/8","offset":"1/4","max":2,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":90,"id":"J","note":"Crosscut each log into two 8 3/4\" lengths (stop block)","input":"I*","widths":"8 3/4"},{"id":"K","type":"transform","note":"Turn each piece on its long axis so every pair makes a goose pointing the same way","input":"J*","ops":[{"target":"list","list":"2,6","kind":"tip","angle":90},{"target":"list","list":"1,5","kind":"tip","angle":180},{"target":"list","list":"4,8","kind":"tip","angle":270}],"reverse":false,"shuffle":false,"seed":1},{"id":"L","type":"glue","note":"The row log: six pieces edge to edge","input":"K*","axis":"x","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"type":"cut","kerf":"1/8","offset":"1/4","max":6,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":0,"id":"M","note":"Square one end, then crosscut six 1 1/4\" rows","input":"L*","widths":"1 1/4"},{"id":"N","type":"transform","note":"Stand the rows end grain up","input":"M*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"O","type":"glue","note":"Glue the six rows together","input":"N*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"P","type":"trim","note":"Flatten both faces","input":"O*","square":"none","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0","planeTo":"1 1/8","planeFrom":"both"}]},"basket":{"name":"End-grain basket weave","group":"Quilt tiles","units":"in","seed":4,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple 4/4 boards, 3/4\" — six of them","species":"maple","count":"6","length":"20","width":"5","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Cherry boards, 1/2\" — the accent layer","species":"cherry","count":"3","length":"20","width":"5","thickness":"1/2","sawing":"flat"},{"id":"C","type":"cut","note":"Rip each maple board into 2 1/8\" strips","input":"A*","angle":0,"bevel":0,"widths":"2 1/8","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"D","type":"cut","note":"Rip each cherry board into 2 1/8\" strips","input":"B*","angle":0,"bevel":0,"widths":"2 1/8","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"E","type":"glue","note":"Six sandwiches: maple, cherry, maple — face to face","input":"(C D C)6","axis":"z","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0","groupSize":"3"},{"id":"Q","type":"transform","note":"Stand the sandwiches on edge","input":"E*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"P","type":"trim","note":"Plane the sandwiches to 2\" (takes the saw marks off one edge)","input":"Q*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0","planeTo":"2","planeFrom":"top"},{"id":"K","type":"transform","note":"Turn every other sandwich a quarter turn, then mix up the order","input":"P*","ops":[{"target":"odd","list":"","kind":"roll","angle":90}],"reverse":true,"shuffle":true,"seed":1},{"id":"L","type":"glue","note":"Glue the sandwiches edge to edge into a panel","input":"K*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"M","type":"cut","note":"Crosscut the panel into 2\" strips","input":"L*","angle":90,"bevel":0,"widths":"2","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"N","type":"transform","note":"Stand the strips end grain up, turning every other one end for end","input":"M*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"list":"","target":"even","kind":"spin","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"O","type":"glue","note":"Glue the strips together — the basket weave","input":"N*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"}]},"brick":{"name":"End-grain brick (running bond)","group":"Quilt tiles","units":"in","seed":12,"species":[],"steps":[{"id":"A","type":"stock","note":"Padauk ¾″ boards, 7″ wide","species":"padauk","count":2,"length":"20","width":"7","thickness":"3/4","sawing":"flat"},{"id":"Q","type":"stock","note":"Padauk ¾″ board, 4″ wide","species":"padauk","count":1,"length":"20","width":"4","thickness":"3/4","sawing":"flat"},{"id":"B","type":"cut","note":"Rip brick strips, 1 1/2″ wide (four per board)","input":"A*","angle":0,"bevel":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"F","type":"cut","note":"Rip two more brick strips; keep what's left","input":"Q1","angle":0,"bevel":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":2,"keepRem":true,"taper":false,"widthsEnd":"1/2"},{"id":"G","type":"cut","note":"Rip a 3/4″ half-brick strip from the leftover","input":"F3","angle":0,"bevel":0,"widths":"3/4","kerf":"1/8","offset":"0","max":1,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"C","type":"stock","note":"Maple ¾″ board for the vertical mortar lines","species":"maple","count":1,"length":"20","width":"4","thickness":"3/4","sawing":"flat"},{"id":"D","type":"cut","note":"Rip ten 1/4″ mortar strips","input":"C*","angle":0,"bevel":0,"widths":"1/4","kerf":"1/8","offset":"0","max":10,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"E","type":"glue","note":"Panel: brick, mortar, brick, mortar … ending with the half-brick strip","input":"(B D)* (F D)* G","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"J","type":"stock","note":"Maple 1/4″ boards for the horizontal mortar lines","species":"maple","count":3,"length":"18","width":"6 1/8","thickness":"1/4","sawing":"flat"},{"id":"K","type":"glue","note":"Edge-glue the 1/4″ maple into one thin sheet","input":"J*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"H","type":"cut","note":"Crosscut eleven 1 1/2″ slices from the maple sheet — the horizontal mortar lines","input":"K*","angle":90,"bevel":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":11,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"I","type":"transform","note":"Stand the mortar slices up, end grain up","input":"H*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"L","type":"cut","note":"Crosscut twelve 1 1/2″ slices from the brick panel — each becomes a row of bricks","input":"E*","angle":90,"bevel":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":12,"keepRem":false,"taper":false,"widthsEnd":"1/2"},{"id":"O","type":"transform","note":"End grain up; turn every other row end for end to offset the bricks","input":"L*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"list":"","target":"even","kind":"spin","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"M","type":"glue","note":"Glue up: twelve brick rows with a mortar slice between each pair","input":"O(I O)*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"N","type":"trim","note":"Square up the ends","input":"M*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"}]},"celtic":{"name":"Celtic knot inlay","group":"Angles & inlays","units":"in","seed":8,"species":[],"steps":[{"id":"A","type":"stock","note":"Walnut 4/4 board","species":"walnut","count":1,"length":"14","width":"8","thickness":"1","sawing":"flat"},{"id":"B","type":"stock","note":"Maple 4/4 offcut","species":"maple","count":1,"length":"24","width":"2","thickness":"1","sawing":"flat"},{"id":"C","type":"cut","note":"Rip 4 inlay strips","input":"B*","angle":0,"widths":"1/4","kerf":"1/8","offset":"0","max":4,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"D","type":"cut","note":"First cut at 35°","input":"A*","angle":35,"widths":"5 7/16, 99","kerf":"0","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":""},{"id":"E","type":"glue","note":"Glue an inlay strip into the cut","input":"D1 C D2","axis":"y","align":"keep","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"F","type":"transform","note":"Turn back square","input":"E*","ops":[{"target":"all","list":"","kind":"spin","angle":-35}],"reverse":false,"shuffle":false,"seed":1},{"id":"G","type":"trim","note":"Trim the inlay flush","input":"F*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"id":"H","type":"cut","note":"Second parallel cut","input":"G*","angle":35,"widths":"8 15/16, 99","kerf":"0","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":""},{"id":"I","type":"glue","note":"Second inlay","input":"H1 C H2","axis":"y","align":"keep","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"J","type":"transform","note":"Turn back square","input":"I*","ops":[{"target":"all","list":"","kind":"spin","angle":-35}],"reverse":false,"shuffle":false,"seed":1},{"id":"K","type":"trim","note":"Trim flush","input":"J*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"id":"L","type":"cut","note":"Cut the other way (−35°)","input":"K*","angle":-35,"widths":"5 7/16, 99","kerf":"0","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":""},{"id":"M","type":"glue","note":"Third inlay — crosses the first two","input":"L1 C L2","axis":"y","align":"keep","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"N","type":"transform","note":"Turn back square","input":"M*","ops":[{"target":"all","list":"","kind":"spin","angle":35}],"reverse":false,"shuffle":false,"seed":1},{"id":"O","type":"trim","note":"Trim flush","input":"N*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"id":"P","type":"cut","note":"Fourth cut","input":"O*","angle":-35,"widths":"8 15/16, 99","kerf":"0","offset":"0","max":0,"keepRem":true,"taper":false,"widthsEnd":""},{"id":"Q","type":"glue","note":"Fourth inlay","input":"P1 C P2","axis":"y","align":"keep","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"R","type":"transform","note":"Turn back square","input":"Q*","ops":[{"target":"all","list":"","kind":"spin","angle":35}],"reverse":false,"shuffle":false,"seed":1},{"id":"S","type":"trim","note":"Trim flush","input":"R*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"}]},"herringbone":{"name":"End-grain herringbone","group":"Angles & inlays","units":"in","seed":9,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple ¾″ board, 7″ wide","species":"maple","count":1,"length":"20 3/4","width":"7","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut ¾″ board, 7″ wide","species":"walnut","count":1,"length":"20 3/4","width":"7","thickness":"3/4","sawing":"flat"},{"type":"cut","kerf":"1/8","offset":"1/4","max":18,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":90,"id":"C","note":"Square one end, then crosscut eighteen 1″ maple slices","input":"A*","widths":"1"},{"type":"cut","kerf":"1/8","offset":"1/4","max":18,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":90,"id":"D","note":"Square one end, then crosscut eighteen 1″ walnut slices","input":"B*","widths":"1"},{"id":"E","type":"transform","note":"Maple slices end grain up","input":"C*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"all","list":"","kind":"spin","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"F","type":"transform","note":"Walnut slices end grain up","input":"D*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"all","list":"","kind":"spin","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"G","type":"glue","note":"Three end-grain striped panels, 12 slices each (alternating maple and walnut)","input":"(E F)*","axis":"x","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0","groupSize":"12"},{"id":"H","type":"glue","note":"Join the three panels into the striped slab","input":"G*","axis":"x","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"type":"cut","kerf":"1/8","offset":"1","max":11,"keepRem":false,"taper":false,"widthsEnd":"","bevel":0,"angle":45,"id":"I","note":"45° cuts across the stripes (the first three are offcuts)","input":"H*","widths":"1 1/2"},{"id":"J","type":"transform","note":"Keep the eight full-length strips; flip every other one over","input":"I4-11","ops":[{"target":"even","list":"","kind":"roll","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"K","type":"glue","note":"Herringbone glue-up","input":"J*","axis":"y","align":"center","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"L","type":"trim","note":"Square up","input":"K*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"}]},"taper3d":{"name":"End-grain 3D (tapered strips)","group":"Angles & inlays","units":"in","seed":15,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple","species":"maple","count":1,"length":"18","width":"4","thickness":"1 1/2","sawing":"flat"},{"id":"B","type":"stock","note":"Cherry","species":"cherry","count":1,"length":"18","width":"4 1/2","thickness":"1 1/2","sawing":"flat"},{"id":"C","type":"stock","note":"Walnut","species":"walnut","count":1,"length":"18","width":"4","thickness":"1 1/2","sawing":"flat"},{"id":"D","type":"cut","note":"Taper-rip 3 maple wedges","input":"A*","angle":0,"widths":"1 3/4, 1/4","kerf":"1/8","offset":"0","max":3,"keepRem":false,"taper":true,"widthsEnd":"1/4, 1 3/4"},{"id":"E","type":"cut","note":"Taper-rip 4 cherry wedges (the first is an offcut)","input":"B*","angle":0,"widths":"1 3/4, 1/4","kerf":"1/8","offset":"0","max":4,"keepRem":false,"taper":true,"widthsEnd":"1/4, 1 3/4"},{"id":"F","type":"cut","note":"Taper-rip 3 walnut wedges","input":"C*","angle":0,"widths":"1 3/4, 1/4","kerf":"1/8","offset":"0","max":3,"keepRem":false,"taper":true,"widthsEnd":"1/4, 1 3/4"},{"id":"G","type":"glue","note":"Maple, cherry, walnut wedges, alternating direction, nested","input":"D1 E2 F1 D2 E3 F2 D3 E4 F3","axis":"y","align":"start","stagger":"0","nest":true,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"H","type":"trim","note":"Square up","input":"G*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"id":"I","type":"cut","note":"Crosscut slices","input":"H*","angle":90,"widths":"1","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"J","type":"transform","note":"End grain up; flip every other slice end-for-end","input":"I*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"even","list":"","kind":"spin","angle":180}],"reverse":false,"shuffle":false,"seed":1},{"id":"K","type":"glue","note":"3D glue-up","input":"J*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"L","type":"trim","note":"Square up","input":"K*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"}]},"plaid":{"name":"End-grain plaid","group":"Stripes & logs","units":"in","seed":10,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple 8/4 board","species":"maple","count":1,"length":"6","width":"7 1/2","thickness":"1 1/2","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut 8/4 offcut","species":"walnut","count":1,"length":"6","width":"2 1/2","thickness":"1 1/2","sawing":"flat"},{"id":"C","type":"stock","note":"Maple ¾″ board","species":"maple","count":1,"length":"6","width":"7 1/2","thickness":"3/4","sawing":"flat"},{"id":"D","type":"stock","note":"Walnut ¾″ offcut","species":"walnut","count":1,"length":"6","width":"2 1/2","thickness":"3/4","sawing":"flat"},{"id":"E","type":"stock","note":"Cherry ¾″ board","species":"cherry","count":1,"length":"12","width":"7 1/2","thickness":"3/4","sawing":"flat"},{"id":"F","type":"stock","note":"Walnut ¾″ offcut","species":"walnut","count":1,"length":"12","width":"2 1/2","thickness":"3/4","sawing":"flat"},{"id":"G","type":"cut","note":"Rip maple: wide, narrow, wide…","input":"A*","angle":0,"widths":"1 1/4, 1/2","kerf":"1/8","offset":"0","max":7,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"H","type":"cut","note":"Rip 6 thin walnut lines","input":"B*","angle":0,"widths":"1/4","kerf":"1/8","offset":"0","max":6,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"I","type":"cut","note":"Same widths","input":"C*","angle":0,"widths":"1 1/4, 1/2","kerf":"1/8","offset":"0","max":7,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"J","type":"cut","note":"Rip 6 thin walnut lines","input":"D*","angle":0,"widths":"1/4","kerf":"1/8","offset":"0","max":6,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"K","type":"cut","note":"Same widths in cherry","input":"E*","angle":0,"widths":"1 1/4, 1/2","kerf":"1/8","offset":"0","max":7,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"L","type":"cut","note":"Rip 6 thin walnut lines","input":"F*","angle":0,"widths":"1/4","kerf":"1/8","offset":"0","max":6,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"M","type":"glue","note":"Wide-row panel (1½″ thick)","input":"(G H)6 G","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"N","type":"glue","note":"Narrow-row panel (¾″ thick)","input":"(I J)6 I","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"O","type":"glue","note":"Line panel (cherry)","input":"(K L)6 K","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"P","type":"trim","note":"Plane the line panel down to 5/16″","input":"O*","square":"none","x0":"0","x1":"0","y0":"0","y1":"0","z0":"7/16","z1":"0"},{"id":"Q","type":"cut","note":"Crosscut 5 wide rows","input":"M*","angle":90,"widths":"1","kerf":"1/8","offset":"0","max":5,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"R","type":"cut","note":"Crosscut 5 narrow rows","input":"N*","angle":90,"widths":"1","kerf":"1/8","offset":"0","max":5,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"S","type":"cut","note":"Crosscut 10 line rows","input":"P*","angle":90,"widths":"1","kerf":"1/8","offset":"0","max":10,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"T","type":"transform","note":"Interleave wide, line, narrow, line — end grain up","input":"(Q S R S)*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"U","type":"glue","note":"Plaid glue-up","input":"T*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"}]},"chaos":{"name":"End-grain chaos board","group":"Stripes & logs","units":"in","seed":14,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple","species":"maple","count":1,"length":"24","width":"6","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut","species":"walnut","count":1,"length":"24","width":"6","thickness":"3/4","sawing":"flat"},{"id":"C","type":"stock","note":"Cherry","species":"cherry","count":1,"length":"24","width":"6","thickness":"3/4","sawing":"flat"},{"id":"D","type":"stock","note":"Padauk","species":"padauk","count":1,"length":"24","width":"4","thickness":"3/4","sawing":"flat"},{"id":"E","type":"cut","note":"Rip every board into mixed widths","input":"A* B* C* D*","angle":0,"widths":"3/4, 1/2, 1, 5/8","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"F","type":"transform","note":"Shuffle all the strips","input":"E*","ops":[],"reverse":false,"shuffle":true,"seed":7},{"id":"G","type":"glue","note":"Random striped panel","input":"F*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"H","type":"trim","note":"Square the ends","input":"G*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"id":"I","type":"cut","note":"Crosscut slices (this becomes the board thickness)","input":"H*","angle":90,"widths":"1 1/4","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"J","type":"transform","note":"Shuffle, end grain up, random flips","input":"I*","ops":[{"target":"all","list":"","kind":"roll","angle":90},{"target":"random","list":"","kind":"spin","angle":180},{"target":"random","list":"","kind":"roll","angle":180}],"reverse":false,"shuffle":true,"seed":3},{"id":"K","type":"glue","note":"Chaos glue-up","input":"J*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"L","type":"trim","note":"Square up","input":"K*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"}]},"fin_eg":{"name":"Finished edge-grain board (groove + grips)","group":"Finished boards","units":"in","seed":20,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple ¾″","species":"maple","count":2,"length":"18","width":"7","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Walnut ¾″","species":"walnut","count":1,"length":"18","width":"7","thickness":"3/4","sawing":"flat"},{"id":"C","type":"stock","note":"Cherry ¾″","species":"cherry","count":1,"length":"18","width":"7","thickness":"3/4","sawing":"flat"},{"id":"D","type":"cut","note":"Rip everything 1½″","input":"A* B* C*","angle":0,"widths":"1 1/2","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"E","type":"transform","note":"Arrange maple / walnut / maple / cherry; edges up","input":"D1 D9 D2 D13 D3 D10 D4 D14 D5 D11 D6 D15 D7 D12 D8 D16","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"F","type":"glue","note":"Edge-grain glue-up","input":"E*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"G","type":"trim","note":"Flatten","input":"F*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"1/16","z1":"1/16"},{"id":"H","type":"edges","note":"Round the corners and ease the edges","input":"G*","corner":"3/4","topProfile":"roundover","topSize":"1/4","bottomProfile":"chamfer","bottomSize":"1/8"},{"id":"I","type":"groove","note":"Rout a juice groove","input":"H*","inset":"3/4","gwidth":"3/8","depth":"3/16","radius":"1 1/4","profile":"round"},{"id":"J","type":"handle","note":"Finger grips under both ends","input":"I*","kind":"grip","where":"ends","len":"5","hdepth":"3/4","height":"1/2","swidth":"1","inset":"1 1/2"}]},"fin_serve":{"name":"Serving board with slot handle","group":"Finished boards","units":"in","seed":21,"species":[],"steps":[{"id":"A","type":"stock","note":"Walnut","species":"walnut","count":1,"length":"26","width":"6","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Maple","species":"maple","count":1,"length":"26","width":"5","thickness":"3/4","sawing":"flat"},{"id":"C","type":"cut","note":"Rip walnut","input":"A*","angle":0,"widths":"1 1/4","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"D","type":"cut","note":"Rip maple","input":"B*","angle":0,"widths":"1 1/4","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"E","type":"transform","note":"Alternate; edges up","input":"(C D)* C","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"F","type":"glue","note":"Glue-up","input":"E*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"G","type":"trim","note":"Square & flatten","input":"F*","square":"top","x0":"1/2","x1":"1/2","y0":"0","y1":"0","z0":"1/16","z1":"1/16"},{"id":"H","type":"handle","note":"Through slot at one end","input":"G*","kind":"slot","where":"end","len":"4","hdepth":"3/4","height":"1/2","swidth":"1","inset":"1 3/4"},{"id":"I","type":"edges","note":"Big corners, soft roundover — also rounds the slot","input":"H*","corner":"1 1/2","topProfile":"roundover","topSize":"3/8","bottomProfile":"roundover","bottomSize":"1/8"}]},"ombre":{"name":"End-grain ombre (maple → cherry → walnut)","group":"Classics","units":"in","seed":22,"species":[],"steps":[{"id":"A","type":"stock","note":"Maple ¾″","species":"maple","count":1,"length":"20","width":"4 1/2","thickness":"3/4","sawing":"flat"},{"id":"B","type":"stock","note":"Cherry ¾″","species":"cherry","count":1,"length":"20","width":"7 1/2","thickness":"3/4","sawing":"flat"},{"id":"C","type":"stock","note":"Walnut ¾″","species":"walnut","count":1,"length":"20","width":"4 1/2","thickness":"3/4","sawing":"flat"},{"id":"D","type":"cut","note":"Rip maple: full width, then shrinking","input":"A*","angle":0,"widths":"1, 1, 3/4, 1/2, 1/4","kerf":"1/8","offset":"0","max":5,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"E","type":"cut","note":"Rip cherry: growing, full, shrinking","input":"B*","angle":0,"widths":"1/4, 1/2, 3/4, 1, 1, 1, 3/4, 1/2, 1/4","kerf":"1/8","offset":"0","max":9,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"F","type":"cut","note":"Rip walnut: growing, then full","input":"C*","angle":0,"widths":"1/4, 1/2, 3/4, 1, 1","kerf":"1/8","offset":"0","max":5,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"G","type":"glue","note":"Ombre panel: each wood takes over from the last","input":"D1 D2 E1 D3 E2 D4 E3 D5 E4 E5 E6 F1 E7 F2 E8 F3 E9 F4 F5","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"none","offList":"","offAmount":"0"},{"id":"H","type":"cut","note":"Crosscut slices (board thickness)","input":"G*","angle":90,"widths":"1","kerf":"1/8","offset":"0","max":0,"keepRem":false,"taper":false,"widthsEnd":""},{"id":"I","type":"transform","note":"End grain up","input":"H*","ops":[{"target":"all","list":"","kind":"roll","angle":90}],"reverse":false,"shuffle":false,"seed":1},{"id":"J","type":"glue","note":"Offset every other row half a step to blend the fade","input":"I*","axis":"y","align":"start","stagger":"0","nest":false,"perRow":4,"offTarget":"even","offList":"","offAmount":"5/8"},{"id":"K","type":"trim","note":"Square up","input":"J*","square":"top","x0":"0","x1":"0","y0":"0","y1":"0","z0":"0","z1":"0"},{"id":"L","type":"edges","note":"Round the corners and ease the edges","input":"K*","corner":"3/4","topProfile":"roundover","topSize":"1/4","bottomProfile":"chamfer","bottomSize":"1/8"}]}};

// ===================== UI =====================
const $ = (s, el = document) => el.querySelector(s);
function h(tag, attrs, ...kids) {
  const el = document.createElement(tag);
  if (attrs) for (const [k, v] of Object.entries(attrs)) {
    if (v == null || v === false) continue;
    if (k === 'class') el.className = v;
    else if (k === 'style') el.style.cssText = v;
    else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
    else if (k === 'value') el.value = v;
    else if (k === 'checked') el.checked = !!v;
    else el.setAttribute(k, v === true ? '' : v);
  }
  for (const c of kids.flat()) if (c != null && c !== false) el.append(c.nodeType ? c : document.createTextNode(String(c)));
  return el;
}
const clone = o => JSON.parse(JSON.stringify(o));
const DPR = Math.min(2, window.devicePixelRatio || 1);
const STORE_KEY = 'cbd-project-v1';

let project = loadProject();
let selected = { step: null, piece: 0 };
// the preview panel always shows the final result; each card shows its own step in 3D
let view = { face: '3d', mode: 'wood', glue: true, thumb: 'top', oiled: true, renderer: 'webgl', glAll: true, target: 'final', waste: false, scale: 'none' };
try { const s = localStorage.getItem('cbd-scale'); if (s === 'ruler' || s === 'banana' || s === 'none') view.scale = s; } catch (e) {}
try { const r = localStorage.getItem('cbd-renderer-v2'); if (r === 'webgl' || r === 'classic') view.renderer = r; } catch (e) {}
const FINE_POINTER = !!(window.matchMedia && matchMedia('(hover: hover) and (pointer: fine)').matches);
const GLS = makeGL(true); GLS.wheelNeedsModifier = true;   // the selected card's live view — a second WebGL context beside the preview's
let pipe = null;
let hoverMap = null;

function loadProject() {
  try { const s = localStorage.getItem(STORE_KEY); if (s) { const p = JSON.parse(s); if (p && Array.isArray(p.steps)) return p; } } catch (e) {}
  return clone(EXAMPLES.checker);
}
function save() { try { localStorage.setItem(STORE_KEY, JSON.stringify(project)); } catch (e) {} }

// line icons (24px grid, drawn with currentColor)
const ICON_PATHS = {
  up: '<path d="M12 19V5M6 11l6-6 6 6"/>',
  down: '<path d="M12 5v14M18 13l-6 6-6-6"/>',
  copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V6a2 2 0 0 1 2-2h8"/>',
  x: '<path d="M18 6 6 18M6 6l12 12"/>',
  chevDown: '<path d="M6 9l6 6 6-6"/>',
  chevRight: '<path d="M9 6l6 6-6 6"/>',
};
function ic(name) {
  const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  el.setAttribute('viewBox', '0 0 24 24'); el.setAttribute('class', 'i'); el.setAttribute('aria-hidden', 'true');
  el.innerHTML = ICON_PATHS[name];
  return el;
}
const TYPE_INFO = {
  stock: { label: 'Stock', cls: 't-stock' },
  cut: { label: 'Cut into strips', cls: 't-cut' },
  transform: { label: 'Rotate / flip', cls: 't-transform' },
  glue: { label: 'Glue up', cls: 't-glue' },
  trim: { label: 'Trim / plane', cls: 't-trim' },
  edges: { label: 'Edges & corners', cls: 't-finish' },
  groove: { label: 'Juice groove', cls: 't-finish' },
  handle: { label: 'Handle cutout', cls: 't-finish' },
};
const DIM_KEYS = { stock: ['length', 'width', 'thickness'], cut: ['kerf', 'offset'], glue: ['stagger', 'offAmount'], trim: ['x0', 'x1', 'y0', 'y1', 'z0', 'z1'], transform: [], edges: ['corner', 'topSize', 'bottomSize'], groove: ['inset', 'gwidth', 'depth', 'radius'], handle: ['len', 'hdepth', 'height', 'swidth', 'inset'] };

// ---------- units / formatting ----------
const U = () => project.units === 'mm' ? 'mm' : 'in';
function fmt(v) {
  if (U() === 'mm') return (Math.round(v * 10) / 10).toString();
  const neg = v < 0; v = Math.abs(v);
  const whole = Math.floor(v + 1e-9), frac = v - whole;
  const s16 = Math.round(frac * 16);
  if (Math.abs(frac * 16 - s16) < 0.06) {
    let n = s16, d = 16;
    if (n === 16) return (neg ? '-' : '') + (whole + 1);
    if (n === 0) return (neg ? '-' : '') + whole;
    while (n % 2 === 0) { n /= 2; d /= 2; }
    return (neg ? '-' : '') + (whole ? whole + ' ' : '') + n + '/' + d;
  }
  return (neg ? '-' : '') + (Math.round(v * 100) / 100).toString();
}
const fmtU = v => fmt(v) + (U() === 'mm' ? ' mm' : '″');
const fmtDims = d => `${fmt(d[0])} × ${fmt(d[1])} × ${fmt(d[2])}${U() === 'mm' ? ' mm' : '″'}`;
function convertUnits(to) {
  if (project.units === to) return;
  const f = to === 'mm' ? 25.4 : 1 / 25.4;
  const prev = project.units; project.units = to;
  const conv = s => { try { const v = parseDim(s) * f; return U() === 'mm' ? String(Math.round(v * 10) / 10) : fmt(v); } catch (e) { return s; } };
  for (const st of project.steps) {
    for (const k of DIM_KEYS[st.type] || []) st[k] = conv(st[k]);
    if (st.type === 'cut') for (const k of ['widths', 'widthsEnd']) if (st[k]) st[k] = String(st[k]).split(/[,;]/).map(x => conv(x.trim())).join(', ');
  }
  void prev;
  save(); renderSteps(); recompute();
}

// ---------- step creation ----------
function nextId() {
  const used = new Set(project.steps.map(s => s.id));
  for (let n = 0; n < 702; n++) {
    const id = n < 26 ? String.fromCharCode(65 + n) : String.fromCharCode(65 + Math.floor(n / 26) - 1) + String.fromCharCode(65 + (n % 26));
    if (!used.has(id)) return id;
  }
  return 'ZZ';
}
function defaults(type) {
  const last = project.steps[project.steps.length - 1];
  const input = last ? last.id + '*' : '';
  const mm = U() === 'mm';
  const d = (inch, mmv) => mm ? mmv : inch;
  switch (type) {
    case 'stock': return { species: 'maple', count: 1, length: d('18', '450'), width: d('6', '150'), thickness: d('3/4', '19'), sawing: 'flat' };
    case 'cut': return { input, angle: 0, bevel: 0, widths: d('1 1/2', '38'), kerf: d('1/8', '3'), offset: '0', max: 0, keepRem: false, taper: false, widthsEnd: d('1/2', '13') };
    case 'transform': return { input, ops: [{ target: 'all', list: '', kind: 'roll', angle: 90 }], reverse: false, shuffle: false, seed: 1 };
    case 'glue': return { input, axis: 'y', align: 'start', stagger: '0', nest: false, perRow: 4, offTarget: 'none', offList: '', offAmount: '0' };
    case 'trim': return { input, square: 'top', x0: '0', x1: '0', y0: '0', y1: '0', z0: '0', z1: '0' };
    case 'edges': return { input, corner: d('1', '25'), topProfile: 'roundover', topSize: d('1/4', '6'), bottomProfile: 'chamfer', bottomSize: d('1/8', '3') };
    case 'groove': return { input, inset: d('3/4', '19'), gwidth: d('3/8', '10'), depth: d('3/16', '5'), radius: d('1', '25'), profile: 'round' };
    case 'handle': return { input, kind: 'grip', where: 'ends', len: d('4', '100'), hdepth: d('3/4', '19'), height: d('1/2', '13'), swidth: d('1', '25'), inset: d('1 1/2', '38') };
  }
}
function addStep(type) {
  track('add_step', { step_type: type });
  const st = Object.assign({ id: nextId(), type, note: '' }, defaults(type));
  project.steps.push(st);
  selected = { step: st.id, piece: 0 };
  save(); renderSteps(); recompute();
  const card = document.querySelector(`.card[data-id="${st.id}"]`);
  if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

let recomputeTimer = 0;
function changed() { save(); clearTimeout(recomputeTimer); recomputeTimer = setTimeout(recompute, 140); }

// ---------- field builders ----------
function fld(label, input, hint, cls) {
  return h('label', { class: 'fld ' + (cls || '') }, h('span', { class: 'fl' }, label), input, hint ? h('span', { class: 'hint' }, hint) : null);
}
function txt(st, key, ph, cls) {
  return h('input', { type: 'text', class: cls || '', value: st[key] ?? '', placeholder: ph || '', spellcheck: 'false', autocomplete: 'off',
    oninput: e => { st[key] = e.target.value; changed(); } });
}
function num(st, key, step) {
  return h('input', { type: 'number', value: st[key] ?? 0, step: step || 'any', inputmode: 'decimal',
    oninput: e => { st[key] = e.target.value; changed(); } });
}
function sel(st, key, options, onch) {
  const s = h('select', { onchange: e => { st[key] = e.target.value; changed(); if (onch) onch(e.target.value); } },
    options.map(([v, l]) => h('option', { value: v }, l)));
  s.value = st[key];
  return s;
}
function chk(st, key, label, onch) {
  return h('label', { class: 'chk' }, h('input', { type: 'checkbox', checked: !!st[key], onchange: e => { st[key] = e.target.checked; changed(); if (onch) onch(e.target.checked); } }), h('span', null, label));
}
function specField(st) {
  return fld('Use pieces from', txt(st, 'input', 'e.g. A*  or  (A B)*', 'mono'),
    h('span', null, 'Step letters: ', h('code', null, 'A*'), ' all unused · ', h('code', null, 'A'), ' next unused · ', h('code', null, 'A3'), ' · ', h('code', null, 'A2-5'), ' · ', h('code', null, '(A B)*'), ' alternate'), 'wide');
}

const add = (el, ...kids) => el.append(...kids.filter(k => k != null && k !== false));
function buildBody(st) {
  const u = U();
  const b = h('div', { class: 'body' });
  if (st.type === 'stock') {
    const spMap = speciesMap(project.species);
    const sw = h('span', { class: 'swatch' });
    const setSw = v => { const s = spMap[v]; sw.style.background = s ? `linear-gradient(90deg, ${s.base}, ${s.dark})` : '#ccc'; };
    const opts = Object.values(spMap).map(s => [s.id, s.name]);
    const speciesSel = sel(st, 'species', opts, setSw);
    setSw(st.species);
    add(b, 
      fld('Wood', h('div', { class: 'row' }, sw, speciesSel), null, 'wide2'),
      fld('Count', num(st, 'count', 1)),
      fld(`Length (along grain, ${u})`, txt(st, 'length')),
      fld(`Width (${u})`, txt(st, 'width')),
      fld(`Thickness (${u})`, txt(st, 'thickness')),
      fld('Sawing', sel(st, 'sawing', [['flat', 'Flat-sawn'], ['quarter', 'Quarter-sawn'], ['rift', 'Rift-sawn']])),
    );
  } else if (st.type === 'cut') {
    const angIn = num(st, 'angle', 1);
    const chips = h('div', { class: 'chips' }, [0, 30, 45, 60, 90, -45, -30].map(a =>
      h('button', { type: 'button', class: 'chip', onclick: () => { st.angle = a; angIn.value = a; changed(); } }, a + '°')));
    add(b, 
      specField(st),
      fld('Cut angle (°, seen from the top)', h('div', null, angIn, chips), '0 = rip along the length · 90 = crosscut · anything in between', 'wide'),
      fld(st.taper ? `Width at start (${u})` : `Strip width(s) (${u})`, txt(st, 'widths', '1 1/2  or  1, 1/2, 1'), 'Comma list repeats in order', 'wide2'),
      fld(`Kerf (${u})`, txt(st, 'kerf')),
      fld('Blade bevel (°)', num(st, 'bevel', 1), '0 = square · tilt the blade up to 45°'),
      chk(st, 'taper', 'Taper cut — each strip changes width along its length (wedges)', () => rebuildCard(st)),
      st.taper ? fld(`Width at end (${u})`, txt(st, 'widthsEnd', '1/2  or  1/2, 1 1/2'), 'Pairs with the start widths in order', 'wide2') : null,
      fld(`Start offset (${u})`, txt(st, 'offset')),
      fld('Max strips (0 = all)', num(st, 'max', 1)),
      chk(st, 'keepRem', 'Keep the leftover (the partial strip, or everything past the last strip when there’s a strip count)'),
    );
  } else if (st.type === 'transform') {
    const opsBox = h('div', { class: 'ops' });
    const drawOps = () => {
      opsBox.innerHTML = '';
      (st.ops || []).forEach((op, i) => {
        const listIn = txt(op, 'list', '1,3,5-7', 'mono small');
        listIn.style.display = op.target === 'list' ? '' : 'none';
        opsBox.append(h('div', { class: 'op' },
          sel(op, 'target', [['all', 'All pieces'], ['odd', 'Odd (1,3,5…)'], ['even', 'Even (2,4,6…)'], ['list', 'Pieces…'], ['random', 'Random half']], v => { listIn.style.display = v === 'list' ? '' : 'none'; }),
          listIn,
          sel(op, 'kind', [['roll', 'Roll about length'], ['spin', 'Spin flat (in plane)'], ['tip', 'Tip up on end']]),
          h('span', { class: 'by' }, 'by'),
          (() => { const s = h('select', { onchange: e => { op.angle = +e.target.value; changed(); } }, [90, 180, 270, 45, 135, -45].map(a => h('option', { value: a }, a + '°'))); s.value = String(op.angle); if (s.value !== String(op.angle)) { s.append(h('option', { value: op.angle }, op.angle + '°')); s.value = String(op.angle); } return s; })(),
          h('button', { type: 'button', class: 'icon', title: 'Remove', 'aria-label': 'Remove', onclick: () => { st.ops.splice(i, 1); drawOps(); changed(); } }, ic('x')),
        ));
      });
      if (!st.ops || !st.ops.length) opsBox.append(h('div', { class: 'hint' }, 'No rotations — add one below.'));
    };
    drawOps();
    const preset = (label, op) => h('button', { type: 'button', class: 'chip', onclick: () => { (st.ops = st.ops || []).push(Object.assign({ list: '' }, op)); drawOps(); changed(); } }, label);
    add(b, 
      specField(st),
      h('div', { class: 'fld wide' }, h('span', { class: 'fl' }, 'Rotations (applied in order)'), opsBox,
        h('div', { class: 'chips' },
          preset('+ Turn cut face up (roll 90°)', { target: 'all', kind: 'roll', angle: 90 }),
          preset('+ Flip alternate end-for-end', { target: 'even', kind: 'spin', angle: 180 }),
          preset('+ Flip alternate over', { target: 'even', kind: 'roll', angle: 180 }),
          preset('+ Stand on end', { target: 'all', kind: 'tip', angle: 90 }),
          preset('+ Turn 90° flat', { target: 'all', kind: 'spin', angle: 90 }),
        )),
      chk(st, 'reverse', 'Reverse the order of the pieces'),
      chk(st, 'shuffle', 'Shuffle the order (random, repeatable)'),
      fld('Random seed', num(st, 'seed', 1), 'Used by shuffle and “Random half”'),
    );
  } else if (st.type === 'glue') {
    add(b, 
      specField(st),
      fld('Glue direction', sel(st, 'axis', [['y', 'Edge to edge (side by side)'], ['z', 'Face to face (stack)'], ['x', 'End to end'], ['grid', 'Grid — rows of tiles']], () => rebuildCard(st)), null, 'wide2'),
      fld('Line up', sel(st, 'align', [['start', 'Start'], ['center', 'Centre'], ['end', 'End'], ['keep', 'As cut (rejoin)']])),
      st.axis === 'grid' ? fld('Pieces per row', num(st, 'perRow', 1), 'Each row is glued end to end, then rows edge to edge') : null,
      fld('Pieces per glue-up', txt(st, 'groupSize', 'all'), 'Blank = one glue-up of everything. 3 with input “(C D C)6” makes 6 identical sandwiches'),
      fld(`Stagger each piece by (${u})`, txt(st, 'stagger'), st.axis === 'grid' ? 'Shift each next row along its length' : 'Shift each next piece along its length'),
      fld(st.axis === 'grid' ? 'Offset rows' : 'Offset pieces', h('div', { class: 'row' },
        sel(st, 'offTarget', [['none', 'None'], ['odd', 'Odd'], ['even', 'Even'], ['list', 'List…'], ['random', 'Random']], () => rebuildCard(st)),
        st.offTarget === 'list' ? txt(st, 'offList', '2,4,6', 'mono') : null), 'For brick / running-bond layouts'),
      fld(`Offset by (${u})`, txt(st, 'offAmount')),
      chk(st, 'nest', 'Nest shaped pieces — slide each piece until it touches (wedges, angled strips, hexagons)'),
    );
  } else if (st.type === 'edges') {
    const prof = [['none', 'None'], ['roundover', 'Roundover'], ['chamfer', 'Chamfer (bevel)']];
    add(b, 
      specField(st),
      fld(`Corner radius (${u})`, txt(st, 'corner'), 'Rounds the corners seen from the top; 0 = square'),
      fld('Top edge', sel(st, 'topProfile', prof)), fld(`Top size (${u})`, txt(st, 'topSize')),
      h('span'),
      fld('Bottom edge', sel(st, 'bottomProfile', prof)), fld(`Bottom size (${u})`, txt(st, 'bottomSize')),
      h('div', { class: 'hint wide' }, 'Applies to every edge cut so far, including handle holes and notches — put this step after them to soften those too.'),
    );
  } else if (st.type === 'groove') {
    add(b, 
      specField(st),
      fld(`Distance from edge (${u})`, txt(st, 'inset')), fld(`Width (${u})`, txt(st, 'gwidth')), fld(`Depth (${u})`, txt(st, 'depth')),
      fld(`Corner radius (${u})`, txt(st, 'radius')),
      fld('Bit profile', sel(st, 'profile', [['round', 'Round nose (core box)'], ['v', 'V-groove'], ['square', 'Straight (flat bottom)']]), null, 'wide2'),
    );
  } else if (st.type === 'handle') {
    const k = st.kind || 'grip';
    add(b, 
      specField(st),
      fld('Handle', sel(st, 'kind', [['grip', 'Finger grip (routed under the edge)'], ['slot', 'Through slot'], ['hole', 'Round hole'], ['notch', 'Scoop / notch in the edge']], () => rebuildCard(st)), null, 'wide2'),
      fld('Where', sel(st, 'where', [['ends', 'Both ends (short edges)'], ['end', 'One end'], ['sides', 'Both sides (long edges)'], ['side', 'One side'], ['all', 'All four edges']])),
      fld(k === 'hole' ? `Diameter (${u})` : `Length along the edge (${u})`, txt(st, 'len')),
      k === 'grip' || k === 'notch' ? fld(`Depth into the board (${u})`, txt(st, 'hdepth')) : null,
      k === 'grip' ? fld(`Height of the grip (${u})`, txt(st, 'height'), 'How far up from the bottom') : null,
      k === 'slot' ? fld(`Slot width (${u})`, txt(st, 'swidth')) : null,
      k === 'slot' || k === 'hole' ? fld(`Centre from the edge (${u})`, txt(st, 'inset')) : null,
    );
  } else if (st.type === 'trim') {
    add(b, 
      specField(st),
      (() => { const s = sel(st, 'square', [['none', 'Off'], ['top', 'Square up (seen from the top)'], ['end', 'Square up the end (cross-section)'], ['both', 'Square up top and end'], ['tight', 'Shrink to the material']]); if (st.square === true) s.value = 'top'; else if (!st.square) s.value = 'none'; return fld('Cut off ragged edges', s, 'Square-up finds the largest solid rectangle', 'wide'); })(),
      fld(`Trim start (${u})`, txt(st, 'x0')), fld(`Trim end (${u})`, txt(st, 'x1')),
      fld(`Trim near side (${u})`, txt(st, 'y0')), fld(`Trim far side (${u})`, txt(st, 'y1')),
      fld(`Plane to thickness (${u})`, (() => { const i = txt(st, 'planeTo', 'blank = by amount'); i.onchange = () => setTimeout(() => rebuildCard(st), 0); return i; })(), 'The finished thickness — the planer takes off whatever it needs'),
      String(st.planeTo ?? '').trim() !== ''
        ? fld('Take it off', sel(st, 'planeFrom', [['both', 'Both faces, evenly'], ['top', 'Top face only'], ['bottom', 'Bottom face only']]))
        : null,
      String(st.planeTo ?? '').trim() === '' ? fld(`Plane top (${u})`, txt(st, 'z1')) : null,
      String(st.planeTo ?? '').trim() === '' ? fld(`Plane bottom (${u})`, txt(st, 'z0')) : null,
    );
  }
  return b;
}

function buildCard(st, i) {
  const info = TYPE_INFO[st.type];
  const move = dir => { const j = i + dir; if (j < 0 || j >= project.steps.length) return; const a = project.steps; [a[i], a[j]] = [a[j], a[i]]; save(); renderSteps(); recompute(); };
  const card = h('div', { class: 'card' + (selected.step === st.id ? ' sel' : ''), 'data-id': st.id },
    h('div', { class: 'head', onclick: e => { if (e.target.closest('button,input')) return; selectStep(st.id, 0); } },
      h('span', { class: 'badge ' + info.cls }, st.id),
      h('span', { class: 'ctype' }, info.label),
      h('input', { class: 'note', value: st.note || '', placeholder: 'Add a note…', oninput: e => { st.note = e.target.value; save(); } }),
      h('span', { class: 'cbtns' },
        h('button', { class: 'icon', title: 'Move up', 'aria-label': 'Move up', onclick: () => move(-1) }, ic('up')),
        h('button', { class: 'icon', title: 'Move down', 'aria-label': 'Move down', onclick: () => move(1) }, ic('down')),
        h('button', { class: 'icon', title: 'Duplicate', 'aria-label': 'Duplicate', onclick: () => { const c = clone(st); c.id = nextId(); project.steps.splice(i + 1, 0, c); save(); renderSteps(); recompute(); } }, ic('copy')),
        h('button', { class: 'icon danger', title: 'Delete', 'aria-label': 'Delete step', onclick: () => { project.steps.splice(i, 1); if (selected.step === st.id) selected.step = null; save(); renderSteps(); recompute(); } }, ic('x')),
      )),
    buildBody(st),
    h('div', { class: 'out' }, h('div', { class: 'summary' }), h('div', { class: 'msg' }), h('div', { class: 'inline3d' }), h('div', { class: 'thumbs' })),
  );
  return card;
}
function rebuildCard(st) {
  const old = document.querySelector(`.card[data-id="${st.id}"]`);
  if (!old) return;
  const i = project.steps.indexOf(st);
  const card = buildCard(st, i);
  old.replaceWith(card);
  if (pipe) updateOutputs();
}
function renderSteps() {
  const list = $('#steps');
  list.innerHTML = '';
  project.steps.forEach((s, i) => list.append(buildCard(s, i)));
  $('#projname').value = project.name || '';
  document.querySelectorAll('[data-units]').forEach(b => b.classList.toggle('on', b.dataset.units === U()));
}

// ---------- rendering helpers ----------
const FC_CACHE = new Map();
const OUTLINE_CACHE = new Map();
// a cached canvas can only sit in one place on the page: copy it if it's already shown
function freshCanvas(cv) {
  if (!cv.isConnected) return cv;
  const c = document.createElement('canvas');
  c.width = cv.width; c.height = cv.height; c.style.width = cv.style.width; c.style.height = cv.style.height;
  c.getContext('2d').drawImage(cv, 0, 0);
  return c;
}
function faceCanvasCached(node, face, scale, opts) {
  const key = `${node.uid}|${face}|${scale.toFixed(3)}|${opts.mode}|${opts.glueLines}|${opts.oiled}|${opts.bright || 1}`;
  let v = FC_CACHE.get(key);
  if (!v) {
    v = faceCanvas(node, face, scale, opts);
    FC_CACHE.set(key, v);
    if (FC_CACHE.size > 800) FC_CACHE.delete(FC_CACHE.keys().next().value);
  }
  return v;
}
function faceCanvas(node, face, scale, opts) {
  const maxPx = 1.1e6;
  const F = faceDims(node, face);
  if (F[0] * F[1] * scale * scale > maxPx) scale = Math.sqrt(maxPx / (F[0] * F[1]));
  const r = renderFace(node, face, scale, opts);
  const c = document.createElement('canvas');
  c.width = r.w; c.height = r.h;
  c.getContext('2d').putImageData(new ImageData(r.buf, r.w, r.h), 0, 0);
  return { canvas: c, stats: r, scale };
}
function faceDims(node, face) {
  const [X, Y, Z] = node.dims;
  return face === 'front' ? [X, Z] : face === 'end' ? [Y, Z] : [X, Y];
}
const renderOpts = (extra) => Object.assign({ mode: view.mode, glueLines: view.glue, oiled: view.oiled }, extra || {});

// ---------- classic images from the server ----------
// The classic (non-WebGL) pictures can take a while to paint in some browsers, so they come from
// GET /api/render (the same engine, in Rust). Everything that changes the picture is in the URL,
// so a CDN can cache them by URL. If the server can't be reached they are painted here as before.
const RENDER_V = 1;                 // bump when the server's drawing changes, so cached URLs don't go stale
const RENDER_MAX_URL = 7600;        // CDNs and servers commonly cap request lines near 8 KB
let SERVER_RENDER = /^https?:$/.test(location.protocol) && typeof CompressionStream === 'function';
let RENDER_FAILS = 0;
let PACKED = { json: null, promise: null };
// the design as the engine needs it: geometry only (notes and names don't change a picture)
function leanDesign() {
  return JSON.stringify({ seed: project.seed || 0, species: project.species || [], steps: project.steps.map(s => { const c = Object.assign({}, s); delete c.note; return c; }) });
}
// …deflated and base64url'd for the server's URL
function packedDesign() {
  const json = leanDesign();
  if (PACKED.json === json) return PACKED.promise;
  const promise = new Response(new Blob([json]).stream().pipeThrough(new CompressionStream('deflate-raw'))).arrayBuffer().then(buf => {
    const b = new Uint8Array(buf); let s = '';
    for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode.apply(null, b.subarray(i, i + 0x8000));
    return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  });
  PACKED = { json, promise };
  return promise;
}
// a CSS colour as rrggbb (computed oklch() values included), for the JPEG background
const HEX_CACHE = new Map();
function cssHex(varName) {
  if (HEX_CACHE.has(varName)) return HEX_CACHE.get(varName);
  const c = document.createElement('canvas'); c.width = c.height = 1;
  const x = c.getContext('2d');
  x.fillStyle = getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#f3f1ee';
  x.fillRect(0, 0, 1, 1);
  const d = x.getImageData(0, 0, 1, 1).data, hex = [d[0], d[1], d[2]].map(v => v.toString(16).padStart(2, '0')).join('');
  HEX_CACHE.set(varName, hex);
  return hex;
}
// ---- the same engine in the browser (wasm/ → static/engine.wasm), for when the server can't be reached.
// Loaded only on the first failure. The JS renderer in core.js stays as the last resort.
let WASM = null, WASM_LOADING = null;
function loadWasm() {
  if (!WASM_LOADING) WASM_LOADING = (async () => {
    const url = 'engine.wasm?v=' + RENDER_V;
    let r;
    try { r = await WebAssembly.instantiateStreaming(fetch(url), {}); }
    catch (e) { const res = await fetch(url); if (!res.ok) throw new Error('engine.wasm ' + res.status); r = await WebAssembly.instantiate(await res.arrayBuffer(), {}); }
    WASM = r.instance.exports;
  })().catch(e => { WASM = false; console.warn('Browser engine unavailable:', e.message || e); });
  return WASM_LOADING;
}
const WENC = new TextEncoder();
// draw a picture with the Wasm engine into a canvas (CSS-sized for dpr); null if it isn't loaded or can't draw this
function wasmCanvas(params) {
  if (!WASM) return null;
  const dpr = params.dpr || DPR;
  const q = new URLSearchParams({ v: params.v, s: params.s, p: String(params.p), m: view.mode, o: view.oiled ? '1' : '0', g: view.glue ? '1' : '0', dpr: String(dpr) });
  for (const k of ['w', 'h', 'k']) if (params[k] != null) q.set(k, String(params[k]));
  try {
    const X = WASM, d = WENC.encode(leanDesign()), qs = WENC.encode(q.toString());
    const dp = X.cbd_alloc(d.length), qp = X.cbd_alloc(qs.length);
    new Uint8Array(X.memory.buffer, dp, d.length).set(d);
    new Uint8Array(X.memory.buffer, qp, qs.length).set(qs);
    const status = X.cbd_render(dp, d.length, qp, qs.length);
    X.cbd_free(dp, d.length); X.cbd_free(qp, qs.length);
    const out = new Uint8ClampedArray(X.memory.buffer, X.cbd_out_ptr(), X.cbd_out_len());
    if (status) { console.warn('Browser engine:', status, new TextDecoder().decode(out)); return null; }
    const w = X.cbd_out_width(), hh = X.cbd_out_height();
    const c = document.createElement('canvas');
    c.width = w; c.height = hh; c.style.width = w / dpr + 'px'; c.style.height = hh / dpr + 'px';
    c.getContext('2d').putImageData(new ImageData(new Uint8ClampedArray(out), w, hh), 0, 0);
    return c;
  } catch (e) { WASM = false; console.warn('Browser engine failed:', e); return null; }   // a trap leaves the instance unusable
}
// An <img> drawn by the server, sized in CSS px from its pixel size. If the server isn't there, the URL would be
// too long or the request fails, the browser engine draws it instead, and failing that `local()` (core.js) does.
// `decorate(el)` is applied to whichever element ends up on the page; `onImage(img)` runs when a server image arrives.
// params.wasm === false: the caller's local() does its own drawing (the cut plan paints into its canvas).
function serverImage(params, local, decorate, onImage) {
  const done = el => { if (decorate) decorate(el); return el; };
  const fallback = () => (params.wasm !== false && wasmCanvas(params)) || local();
  if (!SERVER_RENDER) { if (WASM == null) loadWasm(); return done(fallback()); }
  const img = h('img', { alt: '', decoding: 'async', draggable: 'false' });
  const dpr = params.dpr || DPR;
  if (params.cssW) img.style.width = params.cssW + 'px';
  if (params.cssH) img.style.height = params.cssH + 'px';
  const swap = async () => { await loadWasm(); const c = fallback(); if (img.parentNode) img.replaceWith(done(c)); };
  img.onerror = () => { if (++RENDER_FAILS >= 3) SERVER_RENDER = false; swap(); };
  img.onload = () => { img.style.width = img.naturalWidth / dpr + 'px'; img.style.height = img.naturalHeight / dpr + 'px'; if (onImage) onImage(img); };
  packedDesign().then(d => {
    const q = new URLSearchParams({ v: params.v, s: params.s, p: String(params.p), m: view.mode, o: view.oiled ? '1' : '0', g: view.glue ? '1' : '0', dpr: String(dpr), r: String(RENDER_V) });
    for (const k of ['w', 'h', 'k', 'f', 'bg']) if (params[k] != null) q.set(k, String(params[k]));
    const url = '/api/render?' + q + '&d=' + d;
    if (url.length > RENDER_MAX_URL) { swap(); return; }
    img.src = url;
  }, swap);
  return done(img);
}
// device-px scale a face is drawn at (faceCanvas caps it near a megapixel; the server does the same)
function faceScale(node, face, scale) {
  const F = faceDims(node, face);
  return F[0] * F[1] * scale * scale > 1.1e6 ? Math.sqrt(1.1e6 / (F[0] * F[1])) : scale;
}

function summarize(res) {
  const n = res.pieces.length;
  if (!n) return '';
  const groups = new Map();
  for (const p of res.pieces) { const k = fmtDims(p.dims); groups.set(k, (groups.get(k) || 0) + 1); }
  const parts = [...groups.entries()];
  if (parts.length === 1) return `${n} piece${n > 1 ? 's' : ''} · ${parts[0][0]}`;
  if (parts.length <= 3) return `${n} pieces · ` + parts.map(([k, c]) => `${c}× ${k}`).join(' · ');
  return `${n} pieces · ${parts.length} different sizes`;
}

function updateOutputs() {
  for (const st of project.steps) {
    const card = document.querySelector(`.card[data-id="${st.id}"]`);
    if (!card) continue;
    const res = pipe.results[st.id];
    card.querySelector('.summary').textContent = summarize(res);
    const msg = card.querySelector('.msg');
    msg.innerHTML = '';
    if (res.error) msg.append(h('div', { class: 'err' }, res.error));
    if (res.warn) msg.append(h('div', { class: 'warn' }, res.warn));
    const th = card.querySelector('.thumbs');
    holdHeight(th);
    th.innerHTML = '';
    const pcs = res.pieces;
    if (!pcs.length) continue;
    const face = view.thumb === '3d' ? 'top' : view.thumb;
    const gl3d = view.thumb === '3d' && GLT.init();
    const single = pcs.length === 1;
    const maxW = single ? 300 : 150, maxH = single ? 150 : 64;
    let mw = 0, mh = 0;
    for (const p of pcs) { const f = faceDims(p, face); mw = Math.max(mw, f[0]); mh = Math.max(mh, f[1]); }
    const sc = Math.min(maxW / mw, maxH / mh, 40);
    const limit = Math.min(pcs.length, 60);
    for (let k = 0; k < limit; k++) {
      const p = pcs[k];
      let fc;
      if (gl3d) {
        // isometric footprint of the piece at the step's common scale
        const [X, Y, Z] = p.dims, k = sc * 0.62;
        const w = Math.max(28, Math.round((X + Y) * 0.866 * k)), hh = Math.max(22, Math.round(((X + Y) * 0.5 + Z) * k));
        const key = `gl|${p.uid}|${w}|${hh}|${view.mode}|${view.oiled}|${view.glue}`;
        let cv = FC_CACHE.get(key);
        if (!cv) {
          try { cv = GLT.snapshot(p, w, hh, { mode: view.mode, oiled: view.oiled, edges: view.glue }); } catch (e) { cv = null; }
          if (cv) { FC_CACHE.set(key, cv); if (FC_CACHE.size > 800) FC_CACHE.delete(FC_CACHE.keys().next().value); }
        }
        fc = cv ? { canvas: freshCanvas(cv) } : null;
      }
      let pic = fc && fc.canvas;
      if (!pic) {
        const F = faceDims(p, face), ks = faceScale(p, face, sc * DPR);
        const local = () => {
          const c = faceCanvasCached(p, face, sc * DPR, renderOpts());
          const cv = freshCanvas(c.canvas);
          cv.style.width = (cv.width / DPR) + 'px'; cv.style.height = (cv.height / DPR) + 'px';
          return cv;
        };
        pic = serverImage({ v: face, s: st.id, p: k, k: sc * DPR, cssW: Math.max(1, Math.round(F[0] * ks)) / DPR, cssH: Math.max(1, Math.round(F[1] * ks)) / DPR }, local);
        if (pic.tagName === 'IMG') pic.loading = 'lazy';
      }
      th.append(h('button', { class: 'thumb' + (selected.step === st.id && selected.piece === k ? ' on' : ''), title: `${st.id}${k + 1} · ${fmtDims(p.dims)}`,
        onclick: () => { selectStep(st.id, k); } },
        pic, h('span', { class: 'tn' }, st.id + (k + 1))));
    }
    if (pcs.length > limit) th.append(h('span', { class: 'more' }, `+${pcs.length - limit} more`));
  }
  updateMaterials();
  scheduleInline();
}

// wood volume of a finished piece (the solid itself, not its bounding box); finished edges/handles are ignored
const FIN_VOL = new WeakMap();
function finishedVolume(p) {
  if (FIN_VOL.has(p)) return FIN_VOL.get(p);
  const v = wasteVolume(p.t === 4 ? p.child : p);
  FIN_VOL.set(p, v); return v;
}
// Summary below the steps: shop work for the selected step, then the stock list grouped by wood
function updateMaterials() {
  const spMap = pipe.spMap;
  const box = $('#materials');
  holdHeight(box);
  box.innerHTML = '';
  const bfOf = d => { const f = U() === 'mm' ? 1 / 25.4 : 1; return d[0] * f * d[1] * f * d[2] * f / 144; };
  // which stock steps feed something (directly or not)
  const used = new Set();
  for (const st of project.steps) for (const r of (pipe.results[st.id].inputs || [])) used.add(r.id + r.k);
  const groups = {};
  for (const st of project.steps) {
    if (st.type !== 'stock') continue;
    const res = pipe.results[st.id];
    if (!res.pieces.length) continue;
    const p = res.pieces[0], spId = p.sp.id;
    const g = groups[spId] || (groups[spId] = { n: 0, bf: 0, rows: [] });
    const unused = res.pieces.filter((_, i) => !used.has(st.id + (i + 1))).length;
    g.n += res.pieces.length; g.bf += bfOf(p.dims) * res.pieces.length;
    g.rows.push({ st, n: res.pieces.length, dims: p.dims, bf: bfOf(p.dims) * res.pieces.length, unused, sawing: st.sawing });
  }
  const sel = selected.step && pipe.results[selected.step] ? selected.step : null;
  box.append(h('div', { class: 'mt' }, 'Summary'));
  // finished size: the last step that produces something
  const fin = finalStepId();
  if (fin) {
    const ps = pipe.results[fin].pieces;
    const plan = d => { const [a, b] = d[0] >= d[1] ? [d[0], d[1]] : [d[1], d[0]]; return [a, b, d[2]]; };
    const same = ps.every(p => ps[0].dims.every((v, k) => Math.abs(v - p.dims[k]) < 1e-3));
    box.append(h('div', { class: 'mfinal', title: 'Show this step', onclick: () => selectStep(fin, 0) },
      h('span', { class: 'mfl' }, ps.length > 1 ? `Finished pieces (${ps.length})` : 'Finished board'),
      h('b', { class: 'mono' }, ps.length > 1 && !same ? ps.slice(0, 3).map(p => fmtDims(plan(p.dims))).join(', ') + (ps.length > 3 ? ', …' : '') : (ps.length > 1 ? `${ps.length} × ` : '') + fmtDims(plan(ps[0].dims))),
      h('span', { class: 'mnote' }, `length × width × thickness · after step ${fin}`)));
    // waste: how much of the stock doesn't end up in the finished board (offcuts, saw kerf, planer shavings)
    const k = U() === 'mm' ? 1 / Math.pow(25.4, 3) : 1;
    const stockBf = Object.values(groups).reduce((s, g) => s + g.bf, 0);
    const finBf = ps.reduce((s, p) => s + finishedVolume(p), 0) * k / 144;
    if (stockBf > 1e-6) {
      const wasteBf = Math.max(0, stockBf - finBf), pct = Math.round(100 * wasteBf / stockBf);
      let detail = `${wasteBf.toFixed(2)} of ${stockBf.toFixed(2)} bd ft of stock doesn’t end up in the board`;
      if (view.waste) {
        const ws = wasteFor(fin), offBf = ws.reduce((s, w) => s + (w.wasteVol || 0), 0) * k / 144;
        detail += ws.length ? ` · offcuts ${offBf.toFixed(2)} bd ft (${ws.length} piece${ws.length > 1 ? 's' : ''}), saw kerf and shavings ${Math.max(0, wasteBf - offBf).toFixed(2)}` : ' · no offcuts: it’s all saw kerf and shavings';
      } else detail += ' (offcuts, saw kerf and shavings)';
      box.append(h('div', { class: 'mfinal mwaste' }, h('span', { class: 'mfl' }, 'Waste'), h('b', { class: 'mono' }, `${pct}%`), h('span', { class: 'mnote' }, detail)));
    }
  }
  if (sel) {
    box.append(h('div', { class: 'msub' }, `Shop work to make step ${sel}`));
    showShop(box, sel);
  }
  const ents = Object.entries(groups);
  if (!ents.length) return;
  const totBf = ents.reduce((s, [, g]) => s + g.bf, 0), totN = ents.reduce((s, [, g]) => s + g.n, 0);
  box.append(h('div', { class: 'msub' }, `Stock list — ${totN} piece${totN > 1 ? 's' : ''}, ${totBf.toFixed(2)} bd ft`));
  const sawName = { flat: 'flat-sawn', quarter: 'quartersawn', rift: 'rift-sawn' };
  for (const [id, g] of ents) {
    const sp = spMap[id];
    box.append(h('div', { class: 'mgroup' },
      h('div', { class: 'mrow mhead' }, h('span', { class: 'swatch sm', style: `background:linear-gradient(90deg,${sp.base},${sp.dark})` }), h('b', null, sp.name), h('span', { class: 'mono' }, `${g.n} pc · ${g.bf.toFixed(2)} bd ft`)),
      ...g.rows.map(r => h('div', { class: 'mitem', onclick: () => selectStep(r.st.id, 0) },
        h('span', { class: 'mono mq' }, `${r.n} ×`),
        h('span', null, h('span', { class: 'mono' }, fmtDims(r.dims)), h('span', { class: 'mnote' }, [r.st.note || '', sawName[r.sawing] || ''].filter(Boolean).join(' · ')),
          r.unused ? h('span', { class: 'munused' }, r.unused === r.n ? 'not used' : `${r.unused} not used`) : null),
        h('span', { class: 'mono mbf' }, `step ${r.st.id} · ${r.bf.toFixed(2)} bd ft`)))));
  }
}

// keep an element's height while its contents are rebuilt, so the page doesn't shrink and jump
function holdHeight(el) {
  const hgt = el.offsetHeight;
  if (!hgt) return;
  el.style.minHeight = hgt + 'px';
  clearTimeout(el._hold);
  el._hold = setTimeout(() => { el.style.minHeight = ''; }, 250);
}
// ---------- 3D inside each card ----------
// The selected step's card gets the live, orbitable view (GLS). On touch screens it waits for a tap so the
// page still scrolls; other cards show cached snapshots (GLT, or the classic renderer).
let INLINE_LIVE = null;     // touch screens: step id whose card was tapped into a live view
const INLINE_CACHE = new Map();
let inlineObs = null;
function inlineNode(res) {
  if (!res.pieces.length) return null;
  return res.pieces.length > 1 ? (layoutPieces(res.pieces) || res.pieces[0]) : res.pieces[0];
}
const cardIsLive = id => view.renderer === 'webgl' && id === selected.step && (FINE_POINTER || INLINE_LIVE === id);
function renderInline(card) {
  const id = card.dataset.id, res = pipe && pipe.results[id], box = card.querySelector('.inline3d');
  if (!res || !box) return;
  const node = inlineNode(res);
  if (!node) { box.innerHTML = ''; box.dataset.key = ''; box.style.minHeight = ''; return; }
  const w = Math.max(200, box.clientWidth || card.clientWidth - 24), hgt = Math.round(Math.min(300, Math.max(150, w * 0.55)));
  const opts = { mode: view.mode, oiled: view.oiled, edges: view.glue };
  if (cardIsLive(id) && GLS.init()) {
    if (box.dataset.key === 'live' && GLS.node === node && GLS.canvas.parentNode === box) { Object.assign(GLS.opts, opts); GLS.draw(); return; }
    box.innerHTML = ''; box.dataset.key = 'live';
    GLS.show(box, node, w, hgt, opts);
    box.append(h('div', { class: 'ihint' }, FINE_POINTER ? 'Drag to orbit · ⌘/Ctrl + scroll or pinch to zoom · shift-drag to pan · double-click to reset' : 'Drag to orbit · pinch to zoom'));
    if (!FINE_POINTER) box.append(h('button', { class: 'iclose', title: 'Done', 'aria-label': 'Done', onclick: ev => { ev.stopPropagation(); INLINE_LIVE = null; renderInline(card); } }, ic('x')));
    return;
  }
  const key = `${node.uid}|${w}|${hgt}|${view.mode}|${view.oiled}|${view.glue}|${view.renderer}`;
  if (box.dataset.key === key && box.firstChild) return;
  const decorate = img => {
    img.classList.add('icv');
    img.title = view.renderer === 'webgl' ? (FINE_POINTER ? 'Select this step to orbit it' : 'Tap to orbit') : 'Select this step';
    img.onclick = () => { if (!FINE_POINTER && view.renderer === 'webgl') INLINE_LIVE = id; selectStep(id, selected.step === id ? selected.piece : 0); };
    return img;
  };
  // classic renderer (or no WebGL): every piece of the step, laid out side by side
  const classic = () => { const tmp = h('div'); draw3DMany(tmp, res.pieces, w, hgt); return tmp.querySelector('canvas'); };
  let cv = INLINE_CACHE.get(key);
  if (!cv && view.renderer === 'webgl') {
    try { cv = GLT.snapshot(node, w, hgt, opts); } catch (err) { cv = null; }
    if (cv) { INLINE_CACHE.set(key, cv); if (INLINE_CACHE.size > 60) INLINE_CACHE.delete(INLINE_CACHE.keys().next().value); }
  }
  box.innerHTML = '';
  box.dataset.key = key;
  if (cv) { box.append(decorate(freshCanvas(cv))); return; }
  box.append(serverImage({ v: '3d', s: id, p: 'all', w, h: hgt, f: 'jpg', bg: cssHex('--sunk') }, classic, decorate));
}
function scheduleInline() {
  if (!inlineObs) inlineObs = new IntersectionObserver(ents => { for (const en of ents) if (en.isIntersecting) renderInline(en.target); }, { rootMargin: '300px 0px' });
  document.querySelectorAll('.card').forEach(c => {
    const box = c.querySelector('.inline3d');
    if (box && !box.firstChild) box.style.minHeight = '150px';
    inlineObs.unobserve(c); inlineObs.observe(c);
  });
}
function refreshInline() {   // renderer or selection changed: redraw the cards that are on screen
  document.querySelectorAll('.inline3d').forEach(b => { b.dataset.key = ''; });
  scheduleInline();
}
function selectStep(id, piece) {
  const prev = selected.step;
  selected = { step: id, piece: piece || 0 };
  if (prev !== id && INLINE_LIVE && INLINE_LIVE !== id) INLINE_LIVE = null;
  for (const sid of new Set([prev, id])) { const c = sid && document.querySelector(`.card[data-id="${sid}"]`); if (c) renderInline(c); }
  document.querySelectorAll('.card').forEach(c => c.classList.toggle('sel', c.dataset.id === id));
  document.querySelectorAll('.thumb').forEach(t => t.classList.remove('on'));
  if (pipe) updateMaterials();
  const card = document.querySelector(`.card[data-id="${id}"]`);
  if (card) { const t = card.querySelectorAll('.thumb')[selected.piece]; if (t) t.classList.add('on'); }
  if (view.target === 'step') drawPreview();
}

function recompute() {
  pipe = runPipeline(project);
  if (!selected.step || !pipe.results[selected.step]) {
    // default: last step with output
    const withOut = project.steps.filter(s => pipe.results[s.id].pieces.length);
    selected = { step: withOut.length ? withOut[withOut.length - 1].id : (project.steps[0] || {}).id, piece: 0 };
    document.querySelectorAll('.card').forEach(c => c.classList.toggle('sel', c.dataset.id === selected.step));
  }
  updateOutputs();
  drawPreview();
}

// ---------- preview ----------
// offcuts behind a step: what its own and earlier steps cut away, plus pieces nothing used
function wasteFor(sid) {
  const seen = new Set(), stack = [sid];
  while (stack.length) { const id = stack.pop(); if (seen.has(id) || !pipe.results[id]) continue; seen.add(id); for (const r of pipe.results[id].inputs || []) stack.push(r.id); }
  const used = new Set();
  for (const s of project.steps) for (const r of (pipe.results[s.id].inputs || [])) used.add(r.id + r.k);
  const out = [];
  for (const s of project.steps) {
    if (!seen.has(s.id)) continue;
    const res = pipe.results[s.id];
    for (const w of stepWaste(res)) out.push(w);
    if (s.id !== sid) res.pieces.forEach((p, i) => { if (!used.has(s.id + (i + 1))) { if (p.wasteVol == null) p.wasteVol = p.t === 4 ? p.dims[0] * p.dims[1] * p.dims[2] : wasteVolume(p); out.push(p); } });
  }
  return out;
}
function finalStepId() {
  const withOut = project.steps.filter(s => pipe.results[s.id] && pipe.results[s.id].pieces.length);
  return withOut.length ? withOut[withOut.length - 1].id : null;
}
function drawPreview() {
  const stage = $('#stage'), title = $('#ptitle'), dimsEl = $('#pdims'), stats = $('#pstats');
  holdHeight(stage); holdHeight(stats);
  stage.innerHTML = ''; stats.innerHTML = ''; hoverMap = null; $('#readout').textContent = '';
  const final = view.target === 'final';
  const sid = final ? finalStepId() : selected.step;
  const st = project.steps.find(s => s.id === sid);
  const res = st && pipe.results[st.id];
  const hasPlan = !!(res && res.plan);
  $('#tab-plan').style.display = hasPlan ? '' : 'none';
  if (view.face === 'plan' && !hasPlan) view.face = '3d';
  document.querySelectorAll('[data-face]').forEach(b => b.classList.toggle('on', b.dataset.face === view.face));
  if (!st) { title.textContent = 'Add a step to start'; dimsEl.textContent = ''; return; }
  if (!res.pieces.length) { title.textContent = `Step ${st.id} · ${TYPE_INFO[st.type].label}`; dimsEl.textContent = ''; stage.append(h('div', { class: 'empty' }, res.error || 'No output')); return; }
  const k = final ? 0 : Math.min(selected.piece, res.pieces.length - 1);
  const node = res.pieces[k];
  title.textContent = final ? (res.pieces.length > 1 ? `Finished pieces (${res.pieces.length})` : 'Finished board') : `Step ${st.id} · ${TYPE_INFO[st.type].label}` + (res.pieces.length > 1 ? ` — piece ${st.id}${k + 1} of ${res.pieces.length}` : '');
  title.append(h('span', { class: 'pnote' }, final ? ` · after step ${st.id}` : (st.note ? ` · ${st.note}` : '')));
  dimsEl.textContent = fmtDims(node.dims);
  dimsEl.title = 'length × width × thickness';

  const cssW = Math.max(200, (stage.clientWidth || 600) - 20);
  const cssH = Math.max(180, Math.min(window.innerHeight * (window.innerWidth < 900 ? 0.4 : 0.56), 560));
  let statsFace = view.face;
  $('#glctl').style.display = view.face === '3d' && view.renderer === 'webgl' ? '' : 'none';
  if (view.face === '3d' && view.renderer === 'webgl') {
    let ok = false;
    const many = res.pieces.length > 1;
    $('#glall').style.display = many && !final ? '' : 'none';
    document.querySelectorAll('[data-glall]').forEach(b => b.classList.toggle('on', (b.dataset.glall === 'all') === view.glAll));
    let shown = many && (view.glAll || final) ? (layoutPieces(res.pieces) || node) : node;
    const extraNote = [];
    if (view.waste || view.scale !== 'none') {
      const parts = [{ node: shown, o: [0, 0, 0] }];
      const [BX, BY] = shown.dims, gap = Math.max(0.5, Math.min(2, Math.max(BX, BY) * 0.06));
      if (view.waste) {
        const all = wasteFor(st.id), MAXW = 80;
        const ws = all.slice().sort((p, q) => q.dims[0] * q.dims[1] - p.dims[0] * p.dims[1]).slice(0, MAXW);
        let x = BX + gap * 2, y = 0, colW = 0; const colH = Math.max(BY, 6);
        for (const w of ws) { if (y > 0 && y + w.dims[1] > colH) { x += colW + gap; y = 0; colW = 0; } parts.push({ node: w, o: [x, y, 0] }); y += w.dims[1] + gap; colW = Math.max(colW, w.dims[0]); }
        extraNote.push(all.length ? `Offcuts on the right: ${all.length} piece${all.length > 1 ? 's' : ''}${all.length > MAXW ? ` (largest ${MAXW} shown)` : ''}` : 'No offcuts');
      }
      if (view.scale !== 'none') {
        const pr = scaleProp(view.scale, U());
        if (pr) { parts.push({ node: pr, o: [(BX - pr.dims[0]) / 2, -(pr.dims[1] + gap * 1.5), 0], edges: false }); extraNote.push(`${pr.label} in front for scale`); }
      }
      if (parts.length > 1) shown = makeScene(parts);
    }
    try { ok = GLR.show(stage, shown, cssW, Math.round(cssH * 1.05), { mode: view.mode, oiled: view.oiled, edges: view.glue }); } catch (e) { GLR.err = e.message; }
    if (ok) {
      const m = GLR.mesh(node);
      $('#readout').textContent = (extraNote.length ? extraNote.join(' · ') + ' — ' : '') + (FINE_POINTER ? 'Drag to orbit · scroll to zoom · shift- or right-drag to pan · double-click to reset.' : 'Drag to orbit · pinch to zoom.');
    } else {
      preview3D(stage, res, st.id, k, false, cssW, cssH);
      $('#readout').textContent = 'WebGL isn’t available here (' + GLR.err + ') — showing the classic renderer.';
    }
    statsFace = 'top';
  } else if (view.face === '3d') { preview3D(stage, res, st.id, k, final && res.pieces.length > 1, cssW, cssH); statsFace = 'top'; }
  else if (view.face === 'plan') { drawPlan(stage, res.plan, cssW, cssH, res.inputs && res.inputs[0]); statsFace = null; }
  else {
    const F = faceDims(node, view.face);
    const s = Math.min(cssW / F[0], cssH / F[1]);
    const ks = faceScale(node, view.face, s * DPR), sc = ks / DPR;
    const local = () => {
      const fc = faceCanvas(node, view.face, s * DPR, renderOpts());
      fc.canvas.style.width = fc.canvas.width / DPR + 'px';
      fc.canvas.style.height = fc.canvas.height / DPR + 'px';
      return fc.canvas;
    };
    const pic = serverImage({ v: view.face, s: st.id, p: k, k: s * DPR, f: 'jpg', bg: cssHex('--sunk'),
      cssW: Math.max(1, Math.round(F[0] * ks)) / DPR, cssH: Math.max(1, Math.round(F[1] * ks)) / DPR }, local,
      el => { el.classList.add('facecv'); hoverMap = { node, face: view.face, sc, canvas: el }; bindHover(el); });
    stage.append(pic);
  }
  if (statsFace) {
    const r = renderFace(node, statsFace, Math.min(60, 160 / Math.max(...faceDims(node, statsFace))), { glueLines: false });
    showStats(stats, r, statsFace);
  }
}

// shop work needed to make the selected step's output: only the steps it depends on
function shopStats(stepId) {
  const need = new Set(), stack = [stepId];
  while (stack.length) {
    const id = stack.pop();
    if (need.has(id) || !pipe.results[id]) continue;
    need.add(id);
    for (const r of pipe.results[id].inputs || []) stack.push(r.id);
  }
  const s = { rip: 0, cross: 0, angled: 0, taper: 0, trim: 0, plane: 0, glueups: 0, joints: 0, router: 0, boards: 0 };
  for (const st of project.steps) {
    if (!need.has(st.id)) continue;
    const res = pipe.results[st.id], sh = res.shop;
    if (st.type === 'stock') s.boards += res.pieces.length;
    if (!sh) continue;
    if (sh.saw) for (const k of ['rip', 'cross', 'angled', 'taper']) s[k] += sh.saw[k];
    for (const k of ['trim', 'plane', 'glueups', 'joints', 'router']) s[k] += sh[k] || 0;
  }
  return s;
}
function showShop(box, stepId) {
  const s = shopStats(stepId);
  const saw = s.rip + s.cross + s.angled + s.taper;
  const parts = [['rip', 'rip'], ['cross', 'crosscut'], ['angled', 'angled'], ['taper', 'taper']].filter(([k]) => s[k]).map(([k, l]) => `${s[k]} ${l}${s[k] > 1 ? 's' : ''}`);
  const row = (n, label, sub) => [h('span', { class: 'n' }, String(n)), h('span', { class: 'l' }, label), h('span', { class: 'd' }, sub || '')];
  box.append(h('div', { class: 'shop' },
    ...row(saw + s.trim, 'saw cuts', [parts.join(' · '), s.trim ? `${s.trim} trim cut${s.trim > 1 ? 's' : ''}` : ''].filter(Boolean).join(' · ') || null),
    ...row(s.glueups, 'glue-ups', s.joints ? `${s.joints} glue joint${s.joints > 1 ? 's' : ''}` : null),
    ...(s.plane ? row(s.plane, 'faces planed', null) : []),
    ...(s.router ? row(s.router, 'shaping operations', 'corners, edge profiles, grooves, handles') : []),
    ...row(s.boards, 'stock pieces', null)));
}
function showStats(box, r, face) {
  const tot = r.surf || 1;
  const names = { top: 'Top face', front: 'Front edge', end: 'End', bottom: 'Bottom face' };
  const bar = h('div', { class: 'bar' }, r.counts.map((c, i) => c ? h('span', { class: 'g' + i, style: `flex:${c}`, title: `${GRAIN_NAMES[i]} ${Math.round(100 * c / tot)}%` }, c / tot > 0.08 ? Math.round(100 * c / tot) + '%' : '') : null));
  const spMap = pipe.spMap;
  const sp = Object.entries(r.spc).sort((a, b) => b[1] - a[1]);
  box.append(
    h('div', { class: 'srow' }, h('span', { class: 'sl' }, names[face] + ' grain'), bar),
    h('div', { class: 'legend' }, GRAIN_NAMES.map((n, i) => h('span', null, h('i', { class: 'g' + i }), `${n} ${Math.round(100 * r.counts[i] / tot)}%`))),
    h('div', { class: 'legend' }, sp.map(([id, c]) => h('span', null, h('i', { style: `background:${spMap[id].base}` }), `${spMap[id].name} ${Math.round(100 * c / tot)}%`))),
  );
}

function bindHover(cv) {
  const fn = e => {
    if (!hoverMap) return;
    const rect = cv.getBoundingClientRect();
    const u = (e.clientX - rect.left) / hoverMap.sc, w = (e.clientY - rect.top) / hoverMap.sc;
    const p = probe(hoverMap.node, hoverMap.face, u, w);
    if (!p) { $('#readout').textContent = ''; return; }
    $('#readout').innerHTML = '';
    $('#readout').append(h('i', { class: 'g' + p.cls }), h('b', null, p.b.sp.name), ` · ${GRAIN_NAMES[p.cls]} · grain at ${Math.round(p.ang)}° to the surface` + (p.depth > 0.001 ? ' · recessed' : ''));
  };
  cv.addEventListener('pointermove', fn);
  cv.addEventListener('pointerdown', fn);
}

// the preview's classic 3D picture: one piece, or (final view) every finished piece side by side
function preview3D(stage, res, sid, k, many, cssW, cssH) {
  const local = () => { const tmp = h('div'); if (many) draw3DMany(tmp, res.pieces, cssW, cssH); else draw3D(tmp, res.pieces[k], cssW, cssH); return tmp.querySelector('canvas'); };
  stage.append(serverImage({ v: '3d', s: sid, p: many ? 'all' : k, w: Math.round(cssW), h: Math.round(cssH), f: 'jpg', bg: cssHex('--sunk') }, local, el => el.classList.add('facecv')));
  $('#readout').textContent = many ? `${res.pieces.length} pieces laid out side by side. Switch to Top, Front or End to read the grain of one piece.` : 'Tip: switch to Top, Front or End and hover/tap to read the grain at any point.';
}
function draw3D(stage, node, cssW, cssH) {
  const [X, Y, Z] = node.dims;
  // orthographic view from the front-right corner. Not a true 45° isometric: there, 45° patterns
  // (herringbone, chevrons) run straight into the view and read as stair steps.
  // turn so the board's long side faces the viewer
  const yaw = X > Y ? -Math.PI / 3 : -Math.PI / 6, pitch = 0.6155;
  const ax = -Math.sin(yaw), bx = Math.cos(yaw);                        // screen x per unit of x, y
  const ay = Math.sin(pitch) * Math.cos(yaw), by = Math.sin(pitch) * Math.sin(yaw), cz = Math.cos(pitch);   // screen y (down)
  const vdx = Math.cos(yaw), vdy = Math.sin(yaw);                       // towards the viewer, on the table
  const pts = [];
  for (const x of [0, X]) for (const y of [0, Y]) for (const z of [0, Z]) pts.push([ax * x + bx * y, ay * x + by * y - cz * z]);
  const mnx = Math.min(...pts.map(p => p[0])), mxx = Math.max(...pts.map(p => p[0]));
  const mny = Math.min(...pts.map(p => p[1])), mxy = Math.max(...pts.map(p => p[1]));
  const pad = 26;
  const k = Math.min((cssW - 2 * pad) / (mxx - mnx), (cssH - 2 * pad) / (mxy - mny));
  const W = Math.ceil((mxx - mnx) * k + 2 * pad), H = Math.ceil((mxy - mny) * k + 2 * pad);
  const cv = h('canvas', { class: 'facecv' });
  cv.width = W * DPR; cv.height = H * DPR; cv.style.width = W + 'px'; cv.style.height = H + 'px';
  const ctx = cv.getContext('2d');
  const kd = k * DPR;
  const Ox = (pad - mnx * k) * DPR, Oy = (pad - mny * k) * DPR;
  const S = (x, y, z) => [Ox + kd * (ax * x + bx * y), Oy + kd * (ay * x + by * y - cz * z)];
  const poly = ps => { ctx.beginPath(); ps.forEach((p, i) => i ? ctx.lineTo(...p) : ctx.moveTo(...p)); ctx.closePath(); };
  const top = faceCanvas(node, 'top', kd, renderOpts({ bright: 1 }));
  // finished pieces: only show the side surfaces that are really there (corners and roundovers curve away)
  let sideOpt = {}, cornerR = 0;
  if (node.t === 4) {
    const edgeMax = Math.max(0, ...node.feats.filter(f => f.kind === 'edge').map(f => f.r));
    for (const f of node.feats) if (f.kind === 'corner') cornerR = f.r;
    sideOpt = { maxDepth: edgeMax + 0.03 };
  }
  const front = node.t === 4 ? null : faceCanvas(node, 'front', kd, renderOpts(Object.assign({ bright: 0.8 }, sideOpt)));
  const end = node.t === 4 ? null : faceCanvas(node, 'end', kd, renderOpts(Object.assign({ bright: 0.64 }, sideOpt)));
  // soft contact shadow: the footprint, hidden behind the board, casts a blurred shadow
  ctx.save();
  ctx.shadowColor = 'rgba(60,38,18,0.42)'; ctx.shadowBlur = 22 * DPR; ctx.shadowOffsetX = 6 * DPR; ctx.shadowOffsetY = 12 * DPR;
  ctx.fillStyle = 'rgba(60,38,18,0.5)';
  if (cornerR > 0) {
    // rounded footprint
    const pts = [], R = Math.min(cornerR, X / 2, Y / 2);
    for (const [cx, cy, a0] of [[X - R, R, -Math.PI / 2], [X - R, Y - R, 0], [R, Y - R, Math.PI / 2], [R, R, Math.PI]])
      for (let k = 0; k <= 8; k++) { const t = a0 + (k / 8) * Math.PI / 2; pts.push(S(cx + R * Math.cos(t), cy + R * Math.sin(t), 0)); }
    poly(pts);
  } else poly([S(0, 0, 0), S(X, 0, 0), S(X, Y, 0), S(0, Y, 0)]);
  ctx.fill();
  ctx.restore();
  ctx.imageSmoothingEnabled = true;
  const draw = (fc, a, b, c, d, e, f) => { const s = kd / fc.scale; ctx.setTransform(a * s, b * s, c * s, d * s, e, f); ctx.drawImage(fc.canvas, 0, 0); };
  if (node.t === 4) {
    // finished piece: draw the real walls along the outline (holes included), back to front, then the top
    let segs = OUTLINE_CACHE.get(node.uid);
    if (!segs) { segs = finOutline(node, Math.max(0.03, Math.min(0.12, Math.max(X, Y) / 300))); OUTLINE_CACHE.set(node.uid, segs); if (OUTLINE_CACHE.size > 20) OUTLINE_CACHE.delete(OUTLINE_CACHE.keys().next().value); }
    const vis = segs.filter(s => s.n[0] * vdx + s.n[1] * vdy > 0.015).sort((s, t) => ((s.p[0] + s.q[0]) * vdx + (s.p[1] + s.q[1]) * vdy) - ((t.p[0] + t.q[0]) * vdx + (t.p[1] + t.q[1]) * vdy));
    const po = renderOpts({ pix: 1 / kd });
    for (const s of vis) {
      const mx = (s.p[0] + s.q[0]) / 2, my = (s.p[1] + s.q[1]) / 2;
      const e0 = finEval(node, mx, my), zt = e0[1], zb = e0[2];
      const lit = 0.64 + 0.16 * Math.max(0, Math.min(1, -s.n[1] / (Math.abs(s.n[0]) + Math.abs(s.n[1]) || 1) + 0.0));
      if (zb > 0.02 && !s.inner) {
        // undercut (finger grip): the opening below the wall, seen as a shaded recess
        const col = pointColor(node, mx - s.n[0] * 0.3, my - s.n[1] * 0.3, zb + 1e-3, 0, 0, -1, po) || [120, 100, 80];
        ctx.fillStyle = `rgb(${col[0] * 0.38 | 0},${col[1] * 0.38 | 0},${col[2] * 0.38 | 0})`;
        poly([S(s.p[0], s.p[1], 0), S(s.q[0], s.q[1], 0), S(s.q[0], s.q[1], zb), S(s.p[0], s.p[1], zb)]);
        ctx.fill(); ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 0.8; ctx.stroke();
      }
      const hPx = (Z - zb) * kd, nz = Math.max(2, Math.min(24, Math.round(hPx / 3)));
      const ix = mx - s.n[0] * 0.004, iy = my - s.n[1] * 0.004;
      for (let k = 0; k < nz; k++) {
        const za = zb + (Z - zb) * k / nz, zc = zb + (Z - zb) * (k + 1) / nz, zm = Math.min(zt - 1e-4, (za + zc) / 2);
        const col = pointColor(node, ix, iy, Math.max(zb + 1e-4, zm), s.n[0], s.n[1], 0, po) || [120, 100, 80];
        const f = lit * ((za + zc) / 2 > zt ? 0.82 : 1) * (s.inner ? 0.55 + 0.3 * (k / nz) : 1);   // holes: darker inside           // above the roundover: shade as the curve turning away
        ctx.fillStyle = `rgb(${col[0] * f | 0},${col[1] * f | 0},${col[2] * f | 0})`;
        poly([S(s.p[0], s.p[1], za), S(s.q[0], s.q[1], za), S(s.q[0], s.q[1], zc), S(s.p[0], s.p[1], zc)]);
        ctx.fill();
        ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 0.8; ctx.stroke();       // hide hairline seams between strips
      }
    }
    draw(top, ax, ay, -bx, -by, ...S(0, Y, Z));
    ctx.setTransform(1, 0, 0, 1, 0, 0);
  } else {
  draw(front, ax, ay, 0, cz, ...S(0, 0, Z));
  draw(end, bx, by, 0, cz, ...S(X, 0, Z));
  draw(top, ax, ay, -bx, -by, ...S(0, Y, Z));
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  }
  // lighting, painted only where the board is (source-atop)
  ctx.save();
  ctx.globalCompositeOperation = 'source-atop';
  // broad sheen across the top, lit from the back left
  poly([S(0, 0, Z), S(X, 0, Z), S(X, Y, Z), S(0, Y, Z)]);
  ctx.save(); ctx.clip();
  const g = ctx.createLinearGradient(...S(0, Y, Z), ...S(X, 0, Z));
  g.addColorStop(0, 'rgba(255,248,235,0.16)'); g.addColorStop(0.55, 'rgba(255,248,235,0.03)'); g.addColorStop(1, 'rgba(40,20,0,0.08)');
  ctx.fillStyle = g; ctx.fillRect(0, 0, cv.width, cv.height);
  ctx.restore();
  // sides darken toward the bottom (ambient occlusion near the table)
  const sideShade = (ps, a, b) => { poly(ps); ctx.save(); ctx.clip(); const gg = ctx.createLinearGradient(...a, ...b); gg.addColorStop(0, 'rgba(0,0,0,0)'); gg.addColorStop(1, 'rgba(30,15,0,0.22)'); ctx.fillStyle = gg; ctx.fillRect(0, 0, cv.width, cv.height); ctx.restore(); };
  if (node.t !== 4) {
  sideShade([S(0, 0, Z), S(X, 0, Z), S(X, 0, 0), S(0, 0, 0)], S(0, 0, Z), S(0, 0, 0));
  sideShade([S(X, 0, Z), S(X, Y, Z), S(X, Y, 0), S(X, 0, 0)], S(X, 0, Z), S(X, 0, 0));
  // eased arrises: a soft highlight along the top edges facing the light
  ctx.lineCap = 'round';
  ctx.strokeStyle = 'rgba(255,250,240,0.55)'; ctx.lineWidth = 1.6 * DPR;
  ctx.beginPath(); ctx.moveTo(...S(0, 0, Z)); ctx.lineTo(...S(X, 0, Z)); ctx.lineTo(...S(X, Y, Z)); ctx.stroke();
  ctx.strokeStyle = 'rgba(255,250,240,0.25)'; ctx.lineWidth = 1 * DPR;
  ctx.beginPath(); ctx.moveTo(...S(X, 0, Z)); ctx.lineTo(...S(X, 0, 0)); ctx.stroke();
  }
  ctx.restore();
  stage.append(cv);
  $('#readout').textContent = 'Tip: switch to Top, Front or End and hover/tap to read the grain at any point.';
}

// classic renderer, several pieces: lay them out like the WebGL "all pieces" view and paint each box back to front
function draw3DMany(stage, pieces, cssW, cssH) {
  const L = pieces.length > 1 ? layoutPieces(pieces) : null;
  if (!L) return draw3D(stage, pieces[0], cssW, cssH);
  const parts = L.kids.map(k => ({ node: k.node, o: k.o }));
  const [X, Y, Z] = L.dims;
  const yaw = X > Y ? -Math.PI / 3 : -Math.PI / 6, pitch = 0.6155;
  const ax = -Math.sin(yaw), bx = Math.cos(yaw);
  const ay = Math.sin(pitch) * Math.cos(yaw), by = Math.sin(pitch) * Math.sin(yaw), cz = Math.cos(pitch);
  const pts = [];
  for (const q of parts) { const [px, py, pz] = q.node.dims;
    for (const x of [0, px]) for (const y of [0, py]) for (const z of [0, pz]) pts.push([ax * (x + q.o[0]) + bx * (y + q.o[1]), ay * (x + q.o[0]) + by * (y + q.o[1]) - cz * z]); }
  const mnx = Math.min(...pts.map(p => p[0])), mxx = Math.max(...pts.map(p => p[0]));
  const mny = Math.min(...pts.map(p => p[1])), mxy = Math.max(...pts.map(p => p[1]));
  const pad = 22;
  const k = Math.min((cssW - 2 * pad) / (mxx - mnx), (cssH - 2 * pad) / (mxy - mny));
  const W = Math.ceil((mxx - mnx) * k + 2 * pad), H = Math.ceil((mxy - mny) * k + 2 * pad);
  const cv = h('canvas', { class: 'facecv' });
  cv.width = W * DPR; cv.height = H * DPR; cv.style.width = W + 'px'; cv.style.height = H + 'px';
  const ctx = cv.getContext('2d');
  const kd = k * DPR, Ox = (pad - mnx * k) * DPR, Oy = (pad - mny * k) * DPR;
  const S = (x, y, z) => [Ox + kd * (ax * x + bx * y), Oy + kd * (ay * x + by * y - cz * z)];
  const poly = ps => { ctx.beginPath(); ps.forEach((p, i) => i ? ctx.lineTo(...p) : ctx.moveTo(...p)); ctx.closePath(); };
  // painter's order for boxes on a table: a box is behind another if it's entirely past it away from the viewer
  const behind = (a, b) => a.o[0] + a.node.dims[0] <= b.o[0] + 1e-6 || a.o[1] >= b.o[1] + b.node.dims[1] - 1e-6;
  const order = [], left = parts.slice();
  while (left.length) {
    let i = left.findIndex(a => !left.some(b => b !== a && behind(b, a)));
    if (i < 0) i = 0;
    order.push(left.splice(i, 1)[0]);
  }
  // contact shadows first, so a piece's shadow never darkens one drawn before it
  ctx.save();
  ctx.shadowColor = 'rgba(60,38,18,0.35)'; ctx.shadowBlur = 14 * DPR; ctx.shadowOffsetX = 4 * DPR; ctx.shadowOffsetY = 8 * DPR;
  ctx.fillStyle = 'rgba(60,38,18,0.45)';
  for (const q of parts) { const [px, py] = q.node.dims, [x0, y0] = q.o; poly([S(x0, y0, 0), S(x0 + px, y0, 0), S(x0 + px, y0 + py, 0), S(x0, y0 + py, 0)]); ctx.fill(); }
  ctx.restore();
  // re-clear the footprints so only the soft shadow outside each piece remains
  ctx.save(); ctx.globalCompositeOperation = 'destination-out';
  for (const q of parts) { const [px, py] = q.node.dims, [x0, y0] = q.o; poly([S(x0, y0, 0), S(x0 + px, y0, 0), S(x0 + px, y0 + py, 0), S(x0, y0 + py, 0)]); ctx.fill(); }
  ctx.restore();
  ctx.imageSmoothingEnabled = true;
  const draw = (fc, a, b, c, d, e, f) => { const sc = kd / fc.scale; ctx.setTransform(a * sc, b * sc, c * sc, d * sc, e, f); ctx.drawImage(fc.canvas, 0, 0); };
  for (const q of order) {
    const n = q.node, [px, py, pz] = n.dims, [x0, y0] = q.o;
    const top = faceCanvas(n, 'top', kd, renderOpts({ bright: 1 }));
    const front = faceCanvas(n, 'front', kd, renderOpts({ bright: 0.8 }));
    const end = faceCanvas(n, 'end', kd, renderOpts({ bright: 0.64 }));
    draw(front, ax, ay, 0, cz, ...S(x0, y0, pz));
    draw(end, bx, by, 0, cz, ...S(x0 + px, y0, pz));
    draw(top, ax, ay, -bx, -by, ...S(x0, y0 + py, pz));
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    // a crisp edge along the lit top arrises keeps neighbouring pieces apart
    ctx.lineCap = 'round'; ctx.strokeStyle = 'rgba(255,250,240,0.5)'; ctx.lineWidth = 1.2 * DPR;
    ctx.beginPath(); ctx.moveTo(...S(x0, y0, pz)); ctx.lineTo(...S(x0 + px, y0, pz)); ctx.lineTo(...S(x0 + px, y0 + py, pz)); ctx.stroke();
  }
  stage.append(cv);
  $('#readout').textContent = `${pieces.length} pieces laid out side by side. Switch to Top, Front or End to read the grain of one piece.`;
}

function clipSeg(p0, p1, X, Y) {
  // Liang–Barsky clip of segment p0→p1 to [0,X]x[0,Y]
  let t0 = 0, t1 = 1; const dx = p1[0] - p0[0], dy = p1[1] - p0[1];
  for (const [p, q] of [[-dx, p0[0]], [dx, X - p0[0]], [-dy, p0[1]], [dy, Y - p0[1]]]) {
    if (Math.abs(p) < 1e-12) { if (q < 0) return null; continue; }
    const r = q / p;
    if (p < 0) t0 = Math.max(t0, r); else t1 = Math.min(t1, r);
  }
  if (t1 <= t0) return null;
  return [[p0[0] + t0 * dx, p0[1] + t0 * dy], [p0[0] + t1 * dx, p0[1] + t1 * dy]];
}
function drawPlan(stage, plan, cssW, cssH, src) {
  const node = plan.node; const [X, Y] = node.dims;
  const s = Math.min(cssW / X, cssH / Y);
  const sd = faceScale(node, 'top', s * DPR);
  const cv = h('canvas', { class: 'facecv' });
  cv.width = Math.max(1, Math.round(X * sd)); cv.height = Math.max(1, Math.round(Y * sd));
  cv.style.width = cv.width / DPR + 'px'; cv.style.height = cv.height / DPR + 'px';
  const ctx = cv.getContext('2d');
  // the wood comes from the server when it can; the cut lines are drawn on top here
  const lines = () => drawPlanLines(ctx, plan, X, Y, sd);
  const wp = src && { v: 'top', s: src.id, p: src.k - 1, k: s * DPR };
  const local = () => { ctx.drawImage((wp && wasmCanvas(wp)) || faceCanvas(node, 'top', s * DPR, renderOpts()).canvas, 0, 0); lines(); return cv; };
  stage.append(cv);
  // the image itself never joins the page: it is painted into the canvas (or, failing that, local() paints it)
  if (src) serverImage(Object.assign({ f: 'jpg', bg: cssHex('--sunk'), wasm: false }, wp), local, null,
    im => { ctx.drawImage(im, 0, 0, cv.width, cv.height); lines(); });
  else local();
  $('#readout').textContent = `Cut plan on the first input piece (${fmtDims(node.dims)}). Numbers are the strip numbers in this step's output.`;
}
function drawPlanLines(ctx, plan, X, Y, sd) {
  const P = p => [p[0] * sd, (Y - p[1]) * sd];
  ctx.lineWidth = Math.max(1.5, DPR * 1.5);
  for (const [a, b] of plan.lines) {
    const seg = clipSeg(a, b, X, Y);
    if (!seg) continue;
    const p0 = P(seg[0]), p1 = P(seg[1]);
    ctx.strokeStyle = 'rgba(255,255,255,.95)'; ctx.setLineDash([]);
    ctx.beginPath(); ctx.moveTo(...p0); ctx.lineTo(...p1); ctx.stroke();
    ctx.strokeStyle = 'rgba(20,10,0,.9)'; ctx.setLineDash([6 * DPR, 4 * DPR]);
    ctx.beginPath(); ctx.moveTo(...p0); ctx.lineTo(...p1); ctx.stroke();
  }
  ctx.setLineDash([]);
  ctx.font = `600 ${11 * DPR}px "IBM Plex Mono", monospace`;
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  plan.labels.forEach((pt, i) => {
    if (pt[0] < 0 || pt[1] < 0 || pt[0] > X || pt[1] > Y) return;
    const m = P(pt), label = String(i + 1);
    const tw = ctx.measureText(label).width + 6 * DPR;
    ctx.fillStyle = 'rgba(255,250,240,.92)'; ctx.fillRect(m[0] - tw / 2, m[1] - 8 * DPR, tw, 16 * DPR);
    ctx.fillStyle = '#2b2118'; ctx.fillText(label, m[0], m[1]);
  });
}

// ---------- dialogs ----------
function dialog(title, body, actions) {
  const ov = h('div', { class: 'overlay', onclick: e => { if (e.target === ov) ov.remove(); } },
    h('div', { class: 'dlg' }, h('div', { class: 'dh' }, h('b', null, title), h('button', { class: 'icon', 'aria-label': 'Close', onclick: () => ov.remove() }, ic('x'))), body,
      actions ? h('div', { class: 'da' }, actions) : null));
  document.body.append(ov);
  return ov;
}
function woodDialog() {
  const list = h('div', { class: 'woods' });
  const draw = () => {
    list.innerHTML = '';
    const spMap = speciesMap(project.species);
    for (const s of Object.values(spMap)) {
      const custom = (project.species || []).some(c => c.id === s.id);
      list.append(h('div', { class: 'wrow' },
        h('span', { class: 'swatch', style: `background:linear-gradient(90deg,${s.base},${s.dark})` }), h('span', null, s.name),
        custom ? h('button', { class: 'icon danger', title: 'Remove', onclick: () => { project.species = project.species.filter(c => c.id !== s.id); save(); draw(); renderSteps(); recompute(); } }, ic('x')) : h('span', { class: 'hint' }, 'built-in')));
    }
  };
  draw();
  const nm = h('input', { type: 'text', placeholder: 'Name, e.g. Osage Orange' });
  const col = h('input', { type: 'color', value: '#d9a441' });
  const fig = h('select', null, h('option', { value: 'mild' }, 'Mild figure'), h('option', { value: 'bold' }, 'Bold figure'), h('option', { value: 'open' }, 'Open pores (oak-like)'));
  const add = () => {
    const name = nm.value.trim(); if (!name) { nm.focus(); return; }
    const id = 'c_' + name.toLowerCase().replace(/[^a-z0-9]+/g, '_') + '_' + Date.now().toString(36);
    const f = fig.value;
    (project.species = project.species || []).push({ id, name, base: col.value, contrast: f === 'bold' ? 0.75 : 0.4, pore: f === 'open' ? 0.6 : 0.1, ring: f === 'open' ? 0.18 : 0.13 });
    nm.value = ''; save(); draw(); renderSteps(); recompute();
  };
  dialog('Wood library', h('div', null, list, h('div', { class: 'addwood' }, nm, col, fig, h('button', { class: 'btn', onclick: add }, 'Add wood'))));
}
// ---------- accounts and online save / load (Spin key-value API on the same origin) ----------
// Signed in: projects belong to your account, are private unless you make them public, and are listed and
// searchable in Save / load. Signed out: projects get an unguessable ID and an edit key that stays in this
// browser's localStorage (never in the project JSON). Nothing lists anyone else's projects.
const API = 'api/projects';
const CLOUD_KEY = 'cbd-cloud-v1';
let ACCOUNT = null;   // { username } while signed in
function cloudState() {
  try { const s = JSON.parse(localStorage.getItem(CLOUD_KEY) || 'null'); if (s && typeof s === 'object' && s.saved) return s; } catch (e) {}
  return { current: null, saved: {} };
}
function setCloud(fn) { const s = cloudState(); fn(s); try { localStorage.setItem(CLOUD_KEY, JSON.stringify(s)); } catch (e) {} updateCloudChip(); return s; }
const shareLink = id => location.origin + location.pathname + '#p=' + id;
const editLink = (id, key) => shareLink(id) + '&k=' + key;
function parseCloudRef(s) {
  s = (s || '').trim();
  const m = /[#?&]p=([A-Za-z0-9_-]{16})(?:.*?[&]k=([A-Za-z0-9_-]{20,64}))?/.exec(s) || /^([A-Za-z0-9_-]{16})$/.exec(s);
  return m ? { id: m[1], key: m[2] || null } : null;
}
async function api(method, path, body, key) {
  let r;
  try {
    r = await fetch(path, { method, credentials: 'same-origin', headers: { 'x-cbd-request': '1', ...(body ? { 'content-type': 'application/json' } : {}), ...(key ? { authorization: 'Bearer ' + key } : {}) }, body: body ? JSON.stringify(body) : undefined });
  } catch (e) { throw new Error('Can’t reach the save server — online saving works on the hosted app'); }
  if (r.status === 204) return {};
  const ct = r.headers.get('content-type') || '';
  if (!ct.includes('application/json')) throw new Error(r.ok ? 'Unexpected reply from the server' : `Server error (${r.status}) — online saving works on the hosted app`);
  const j = await r.json();
  if (!r.ok) { const e = new Error(j.error || `Server error (${r.status})`); e.status = r.status; throw e; }
  return j;
}
// can this browser save changes to the current online project?
function editable(entry) { return !!entry && (!!entry.key || (!!entry.owned && !!ACCOUNT && entry.owner === ACCOUNT.username)); }
async function cloudSave(asNew, visibility) {
  const s = cloudState(), cur = s.current && s.saved[s.current];
  track('save_design', { kind: !asNew && editable(cur) ? 'update' : 'new', signed_in: !!ACCOUNT, steps: project.steps.length });
  const name = project.name || 'Untitled design';
  if (!asNew && editable(cur)) {
    const j = await api('PUT', API + '/' + s.current, { project }, cur.owned ? null : cur.key);
    setCloud(t => { t.saved[t.current] = { ...t.saved[t.current], name, updated: j.updated, visibility: j.visibility }; });
    return { id: s.current, updated: true };
  }
  const j = await api('POST', API, { project, ...(ACCOUNT ? { visibility: visibility || 'private' } : {}) });
  setCloud(t => { t.current = j.id; t.saved[j.id] = j.owner ? { owned: true, owner: j.owner, name, updated: j.updated, visibility: j.visibility } : { key: j.key, name, updated: j.updated, visibility: 'public' }; });
  return { id: j.id, updated: false };
}
async function cloudOpen(id, key) {
  const j = await api('GET', API + '/' + id);
  track('open_design', { steps: Array.isArray(j.project && j.project.steps) ? j.project.steps.length : 0 });
  if (!j.project || !Array.isArray(j.project.steps)) throw new Error('That saved project is damaged');
  project = j.project; selected = { step: null, piece: 0 }; save();
  setCloud(t => {
    t.current = id;
    const base = { ...(t.saved[id] || {}), name: project.name || 'Untitled design', updated: j.updated, visibility: j.visibility };
    if (j.canEdit) t.saved[id] = { ...base, owned: true, owner: j.owner, key: undefined };
    else if (key) t.saved[id] = { ...base, key };
    else if (t.saved[id]) t.saved[id] = base;
  });
  renderSteps(); recompute();
}
function cloudDetach() { setCloud(t => { t.current = null; }); }   // a new or pasted project isn't the saved one any more
function updateCloudChip() {
  const el = $('#cloudid'); if (!el) return;
  const s = cloudState(), cur = s.current && s.saved[s.current];
  el.textContent = s.current ? (cur && cur.owned && cur.visibility === 'private' ? 'Private · ' : '') + 'ID ' + s.current : '';
  el.title = s.current ? (editable(cur) ? 'Saved online — Save / load to update or share' : 'Opened from a shared link — saving makes your own copy') : '';
  el.hidden = !s.current;
}
async function copyText(t, status, ok) {
  try { await navigator.clipboard.writeText(t); status.textContent = ok; } catch (e) { prompt('Copy this:', t); }
}

// ---- account
function updateAccountBtn() {
  const b = $('#btn-account'); if (!b) return;
  b.textContent = ACCOUNT ? ACCOUNT.username : 'Sign in';
  b.title = ACCOUNT ? 'Your account' : 'Sign in or create an account to keep your projects';
  updateCloudChip();
}
async function loadAccount() {
  try { ACCOUNT = await api('GET', 'api/auth/me'); } catch (e) { ACCOUNT = null; }
  updateAccountBtn();
}
function accountDialog(after) {
  const status = h('p', { class: 'hint cstatus', role: 'status' });
  const busy = async (btn, fn) => { btn.disabled = true; status.textContent = 'Working…'; try { await fn(); } catch (e) { status.textContent = e.message; } finally { btn.disabled = false; } };
  let ov;
  if (!ACCOUNT) {
    let mode = 'login';
    const user = h('input', { type: 'text', name: 'username', autocomplete: 'username', autocapitalize: 'none', spellcheck: 'false', required: true, minlength: 3, maxlength: 30, id: 'acc-user' });
    const pw = h('input', { type: 'password', name: 'password', autocomplete: 'current-password', required: true, id: 'acc-pw' });
    const pwHint = h('span', { class: 'hint' });
    const submit = h('button', { class: 'btn primary', type: 'submit' });
    const swap = h('button', { class: 'btn', type: 'button' });
    const intro = h('p', { class: 'hint' });
    const setMode = m => {
      mode = m;
      submit.textContent = m === 'login' ? 'Sign in' : 'Create account';
      swap.textContent = m === 'login' ? 'Create an account instead' : 'I already have an account';
      pw.setAttribute('autocomplete', m === 'login' ? 'current-password' : 'new-password');
      pw.minLength = m === 'login' ? 0 : 10;
      pwHint.textContent = m === 'login' ? '' : 'At least 10 characters. A few random words works well.';
      intro.textContent = m === 'login' ? 'Sign in to see your saved projects on any device.' : 'Usernames are 3–30 characters: letters, digits, “.”, “_” or “-”. Your projects stay private unless you make them public.';
      status.textContent = '';
    };
    swap.onclick = () => setMode(mode === 'login' ? 'signup' : 'login');
    const form = h('form', { class: 'acct', onsubmit: e => { e.preventDefault(); busy(submit, async () => {
      ACCOUNT = await api('POST', 'api/auth/' + (mode === 'login' ? 'login' : 'signup'), { username: user.value, password: pw.value });
      track(mode === 'login' ? 'login' : 'sign_up', { method: 'password' });
      pw.value = ''; updateAccountBtn(); ov.remove(); if (after) after();
    }); } },
      intro,
      h('label', { class: 'fld', for: 'acc-user' }, h('span', { class: 'fl' }, 'Username'), user),
      h('label', { class: 'fld', for: 'acc-pw' }, h('span', { class: 'fl' }, 'Password'), pw, pwHint),
      h('div', { class: 'row wrap' }, submit, swap), status);
    setMode('login');
    ov = dialog('Sign in', form);
    setTimeout(() => user.focus(), 0);
    return;
  }
  // signed in: sign out, change password, delete account
  const cur = h('input', { type: 'password', autocomplete: 'current-password', id: 'acc-cur', required: true });
  const nw = h('input', { type: 'password', autocomplete: 'new-password', id: 'acc-new', minlength: 10, required: true });
  const chg = h('button', { class: 'btn', type: 'submit' }, 'Change password');
  const out = h('button', { class: 'btn', type: 'button', onclick: () => busy(out, async () => { await api('POST', 'api/auth/logout'); ACCOUNT = null; updateAccountBtn(); ov.remove(); }) }, 'Sign out');
  const delPw = h('input', { type: 'password', autocomplete: 'current-password', id: 'acc-del' });
  const del = h('button', { class: 'btn danger-btn', type: 'button', onclick: () => {
    if (!delPw.value) { status.textContent = 'Enter your password to delete the account.'; return; }
    if (!confirm(`Delete the account “${ACCOUNT.username}” and all of its saved projects? Public links to them will stop working. This can’t be undone.`)) return;
    busy(del, async () => {
      await api('POST', 'api/auth/delete', { password: delPw.value });
      ACCOUNT = null;
      setCloud(t => { for (const [k, v] of Object.entries(t.saved)) if (v.owned) delete t.saved[k]; if (t.current && !t.saved[t.current]) t.current = null; });
      updateAccountBtn(); ov.remove();
    });
  } }, 'Delete account');
  ov = dialog('Account', h('div', { class: 'acct' },
    h('p', null, 'Signed in as ', h('b', null, ACCOUNT.username), '. ', out),
    h('form', { onsubmit: e => { e.preventDefault(); busy(chg, async () => { await api('POST', 'api/auth/password', { current: cur.value, password: nw.value }); cur.value = nw.value = ''; status.textContent = 'Password changed. You’ve been signed out everywhere else.'; }); } },
      h('h4', null, 'Change password'),
      h('input', { type: 'text', autocomplete: 'username', value: ACCOUNT.username, hidden: true, readonly: true, 'aria-hidden': 'true' }),
      h('div', { class: 'body acct-grid' },
        h('label', { class: 'fld', for: 'acc-cur' }, h('span', { class: 'fl' }, 'Current password'), cur),
        h('label', { class: 'fld', for: 'acc-new' }, h('span', { class: 'fl' }, 'New password'), nw, h('span', { class: 'hint' }, 'At least 10 characters'))),
      chg),
    aiSection(status),
    h('details', { class: 'jsonbox' }, h('summary', null, 'Delete account'),
      h('p', { class: 'hint' }, 'Deletes your account and every project saved to it.'),
      h('div', { class: 'row wrap' }, h('label', { class: 'fld', for: 'acc-del' }, h('span', { class: 'fl' }, 'Password'), delPw), del)),
    status));
}

// AI assistants: the MCP address to add to an assistant, and the apps connected to this account
function aiSection(status) {
  const url = location.origin + '/mcp';
  const list = h('div', { class: 'mylist' }, h('p', { class: 'hint' }, 'Loading…'));
  const when = ms => new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
  const draw = async () => {
    let grants = [];
    try { grants = (await api('GET', 'api/auth/oauth/grants')).grants || []; } catch (e) { list.replaceChildren(h('p', { class: 'hint' }, e.message)); return; }
    list.replaceChildren(...(grants.length ? grants.map(g => h('div', { class: 'mrow' },
      h('span', { class: 'mname', title: g.client_name }, g.client_name),
      h('span', { class: 'hint' }, (g.scope.includes('designs:write') ? 'can change designs' : 'can look') + ' · since ' + when(g.created)),
      h('button', { class: 'btn', type: 'button', onclick: async ev => {
        ev.target.disabled = true;
        try { await api('POST', 'api/auth/oauth/grants/revoke', { client_id: g.client_id }); status.textContent = `Disconnected ${g.client_name}.`; draw(); }
        catch (e) { status.textContent = e.message; ev.target.disabled = false; }
      } }, 'Disconnect'))) : [h('p', { class: 'hint' }, 'No apps connected yet.')]));
  };
  draw();
  return h('div', null,
    h('h4', null, 'AI assistants'),
    h('p', { class: 'hint' }, 'Add this MCP server to an assistant (Claude, ChatGPT, Cursor, VS Code…). It will ask you to sign in here and choose what it may do with your designs.'),
    h('div', { class: 'row wrap' }, h('code', { class: 'mono' }, url), h('button', { class: 'btn', type: 'button', onclick: () => copyText(url, status, 'MCP address copied.') }, 'Copy')),
    h('p', { class: 'hint' }, 'Connected apps'),
    list);
}

// ---- Save / load dialog
const VIS_LABEL = { private: 'Private — only you', public: 'Public — anyone with the link' };
function visSelect(value, onchange) {
  const sel = h('select', { 'aria-label': 'Who can open it' }, Object.entries(VIS_LABEL).map(([v, l]) => h('option', { value: v }, l)));
  sel.value = value || 'private';
  sel.onchange = () => onchange(sel.value, sel);
  return sel;
}
function projectDialog() {
  const status = h('span', { class: 'hint cstatus', role: 'status' });
  const busy = async (btn, fn) => { btn.disabled = true; status.textContent = 'Working…'; try { await fn(); } catch (e) { status.textContent = e.message; } finally { btn.disabled = false; } };
  const cloudBox = h('div', { class: 'cloud' });
  let newVis = 'private', query = '', qTimer = 0;
  const mineBox = h('div', { class: 'mylist' });
  const drawMine = async () => {
    if (!ACCOUNT) { mineBox.innerHTML = ''; return; }
    let list;
    try { list = (await api('GET', 'api/me/projects' + (query ? '?q=' + encodeURIComponent(query) : ''))).projects; }
    catch (e) { if (e.status === 401) { ACCOUNT = null; updateAccountBtn(); drawCloud(); return; } mineBox.textContent = e.message; return; }
    const cur = cloudState().current;
    mineBox.replaceChildren(...(list.length ? list.map(p => h('div', { class: 'mrow' + (p.id === cur ? ' on' : '') },
      h('span', { class: 'mname', title: p.name }, p.name),
      h('span', { class: 'hint' }, new Date(p.updated).toLocaleDateString()),
      visSelect(p.visibility, (v, sel) => busy(sel, async () => {
        const j = await api('PUT', API + '/' + p.id, { visibility: v });
        setCloud(t => { if (t.saved[p.id]) t.saved[p.id].visibility = j.visibility; });
        status.textContent = v === 'public' ? `“${p.name}” is public — anyone with the link can open it.` : `“${p.name}” is private now.`;
        if (p.id === cloudState().current) drawCloud();
      })),
      h('button', { class: 'btn', title: p.visibility === 'public' ? 'Copy the share link' : 'Private: the link only works for you', onclick: () => copyText(shareLink(p.id), status, p.visibility === 'public' ? 'Link copied — anyone with it can open a copy.' : 'Link copied — it only works for you while the project is private.') }, 'Link'),
      h('button', { class: 'btn', onclick: e => busy(e.target, async () => { await cloudOpen(p.id); ov.remove(); }) }, 'Open'),
      h('button', { class: 'icon danger', title: 'Delete', 'aria-label': `Delete ${p.name}`, onclick: e => confirm(`Delete “${p.name}”? This can’t be undone.`) && busy(e.target, async () => {
        await api('DELETE', API + '/' + p.id);
        setCloud(t => { delete t.saved[p.id]; if (t.current === p.id) t.current = null; });
        status.textContent = 'Deleted.'; drawCloud();
      }) }, ic('x'))))
      : [h('p', { class: 'hint' }, query ? 'No projects match that.' : 'Nothing saved to your account yet.')]));
  };
  const drawCloud = () => {
    const s = cloudState(), cur = s.current, entry = cur && s.saved[cur], mine = editable(entry), owned = mine && entry.owned;
    cloudBox.innerHTML = '';
    // who you are
    cloudBox.append(h('div', { class: 'acctline' }, ACCOUNT
      ? [h('span', { class: 'hint' }, 'Signed in as '), h('b', null, ACCOUNT.username), h('button', { class: 'btn', onclick: () => { ov.remove(); accountDialog(); } }, 'Account')]
      : [h('span', { class: 'hint' }, 'Sign in to keep projects in an account — private or shared, on any device.'), h('button', { class: 'btn', onclick: () => accountDialog(() => { drawCloud(); }) }, 'Sign in')]));
    // the current project
    const saveBtn = h('button', { class: 'btn primary', onclick: () => busy(saveBtn, async () => {
      const r = await cloudSave(false, newVis);
      status.textContent = r.updated ? 'Saved — same ID, link still works.' : ACCOUNT ? `Saved to your account (${newVis}).` : 'Saved. Copy the link or note the ID.';
      drawCloud();
    }) }, mine ? 'Save changes' : ACCOUNT ? 'Save to my account' : 'Save online');
    const newBtn = h('button', { class: 'btn', onclick: () => busy(newBtn, async () => { await cloudSave(true, newVis); status.textContent = 'Saved as a new project.'; drawCloud(); }) }, 'Save as new');
    cloudBox.append(h('h4', null, 'This design'));
    if (cur) {
      const vis = entry && entry.visibility;
      cloudBox.append(h('div', { class: 'cid' },
        h('div', null, h('span', { class: 'hint' }, 'Project ID '), h('code', null, cur),
          mine ? null : h('span', { class: 'hint' }, ' · opened from a shared link — saving makes your own copy')),
        owned ? h('div', { class: 'row wrap' }, h('span', { class: 'hint' }, 'Who can open it'), visSelect(vis, (v, sel) => busy(sel, async () => {
          const j = await api('PUT', API + '/' + cur, { visibility: v });
          setCloud(t => { t.saved[cur].visibility = j.visibility; });
          status.textContent = v === 'public' ? 'Public — anyone with the link can open a copy.' : 'Private — only you can open it.'; drawMine();
        }))) : null,
        h('div', { class: 'row wrap' },
          h('button', { class: 'btn', onclick: () => copyText(shareLink(cur), status, owned && vis === 'private' ? 'Link copied — it only works for you while the project is private.' : 'Link copied — anyone with it can open a copy.') }, 'Copy link'),
          h('button', { class: 'btn', onclick: () => copyText(cur, status, 'ID copied.') }, 'Copy ID'),
          entry && entry.key && !entry.owned ? h('button', { class: 'btn', title: 'Lets another device save changes to this ID — keep it private', onclick: () => copyText(editLink(cur, entry.key), status, 'Edit link copied — keep it private.') }, 'Copy edit link') : null,
          entry && entry.key && !entry.owned && ACCOUNT ? h('button', { class: 'btn', onclick: e => busy(e.target, async () => { await claim(cur); status.textContent = 'Moved into your account.'; drawCloud(); }) }, 'Move to my account') : null)));
    } else {
      cloudBox.append(h('p', { class: 'hint' }, ACCOUNT ? 'Save it to your account. Private projects can only be opened by you; public ones by anyone with the link.' : 'Save to get a project ID and a link. IDs aren’t listed or searchable anywhere — only people you give the ID or link to can open it.'));
    }
    cloudBox.append(h('div', { class: 'row wrap' }, saveBtn,
      ACCOUNT && !mine ? visSelect(newVis, v => { newVis = v; }) : null,
      mine ? newBtn : null));
    // your account's projects
    if (ACCOUNT) {
      const q = h('input', { type: 'search', placeholder: 'Search your projects', 'aria-label': 'Search your projects', value: query });
      q.oninput = () => { query = q.value.trim(); clearTimeout(qTimer); qTimer = setTimeout(drawMine, 200); };
      cloudBox.append(h('h4', null, 'My projects'), h('div', { class: 'row openrow' }, q), mineBox);
      drawMine();
    }
    // open by ID or link
    const ref = h('input', { type: 'text', placeholder: 'Project ID or link', 'aria-label': 'Project ID or link' });
    const openBtn = h('button', { class: 'btn', onclick: () => busy(openBtn, async () => { const r = parseCloudRef(ref.value); if (!r) throw new Error('That doesn’t look like a project ID (16 letters, digits, - or _)'); await cloudOpen(r.id, r.key); ov.remove(); }) }, 'Open');
    ref.onkeydown = e => { if (e.key === 'Enter') openBtn.click(); };
    cloudBox.append(h('h4', null, 'Open a shared project'), h('div', { class: 'row openrow' }, ref, openBtn));
    // anonymous saves from this browser (before signing in)
    const ids = Object.entries(s.saved).filter(([, v]) => v.key && !v.owned).sort((x, y) => (y[1].updated || '').localeCompare(x[1].updated || ''));
    if (ids.length) cloudBox.append(h('details', { class: 'mine', open: !ACCOUNT && ids.length <= 6 }, h('summary', null, `Saved from this browser without an account (${ids.length})`),
      ACCOUNT ? h('p', { class: 'hint' }, 'Move them into your account to see them on any device. They stay public, so existing links keep working.') : null,
      ids.map(([id, v]) => h('div', { class: 'mrow' + (id === cur ? ' on' : '') },
        h('span', { class: 'mname' }, v.name || 'Untitled'), h('code', null, id), h('span', { class: 'hint' }, v.updated ? new Date(v.updated).toLocaleDateString() : ''),
        h('button', { class: 'btn', onclick: e => busy(e.target, async () => { await cloudOpen(id); ov.remove(); }) }, 'Open'),
        ACCOUNT ? h('button', { class: 'btn', onclick: e => busy(e.target, async () => { await claim(id); status.textContent = `“${v.name || id}” moved into your account.`; drawCloud(); }) }, 'Move to account') : null,
        h('button', { class: 'icon danger', title: 'Delete it from the server', 'aria-label': 'Delete', onclick: e => confirm(`Delete “${v.name || id}” from the server? Anyone with its link will lose access.`) && busy(e.target, async () => {
          await api('DELETE', API + '/' + id, null, v.key).catch(err => { if (!/No project/.test(err.message)) throw err; });
          setCloud(t => { delete t.saved[id]; if (t.current === id) t.current = null; }); status.textContent = 'Deleted.'; drawCloud(); }) }, ic('x'))))));
  };
  const claim = async id => {
    const v = cloudState().saved[id];
    const j = await api('POST', `${API}/${id}/claim`, null, v.key);
    setCloud(t => { t.saved[id] = { name: v.name, owned: true, owner: j.owner, updated: j.updated, visibility: j.visibility }; });
  };
  drawCloud();
  const ta = h('textarea', { class: 'mono', rows: 8, spellcheck: 'false' });
  ta.value = JSON.stringify(project, null, 1);
  const file = h('input', { type: 'file', accept: '.json,application/json', onchange: async e => { const f = e.target.files[0]; if (f) { ta.value = await f.text(); status.textContent = 'File loaded — press Load text to use it.'; } } });
  const ov = dialog('Save / load project', h('div', null, cloudBox,
    h('details', { class: 'jsonbox' }, h('summary', null, 'As text (JSON)'),
      h('p', { class: 'hint' }, 'Your work also autosaves in this browser. Copy this text to keep a file copy, or paste one here and press Load text.'), ta,
      h('div', { class: 'row wrap' }, file,
        h('button', { class: 'btn', onclick: () => copyText(ta.value, status, 'Copied.') }, 'Copy text'),
        h('button', { class: 'btn', onclick: () => {
          try { const p = JSON.parse(ta.value); if (!p || !Array.isArray(p.steps)) throw new Error('No steps in that text'); project = p; selected = { step: null, piece: 0 }; cloudDetach(); save(); renderSteps(); recompute(); ov.remove(); }
          catch (e) { status.textContent = 'Couldn’t load: ' + e.message; }
        } }, 'Load text'))),
    status));
}
async function openFromHash() {
  const r = parseCloudRef(location.hash);
  if (!r) return;
  history.replaceState(null, '', location.pathname + location.search);   // don't reload over later edits; keys never linger in the URL
  try { await cloudOpen(r.id, r.key); }
  catch (e) { dialog('Couldn’t open that project', h('p', null, e.message)); }
}
function helpDialog() {
  dialog('How it works', h('div', { class: 'help' },
    h('p', null, 'A design is a list of steps, like a shop process. Each step makes pieces, and later steps pick them up by letter.'),
    h('ul', null,
      h('li', null, h('b', null, 'Stock'), ' — boards of one wood. Length always runs along the grain.'),
      h('li', null, h('b', null, 'Cut into strips'), ' — rip, crosscut or any angle, seen from the top. Widths can be a repeating list.'),
      h('li', null, h('b', null, 'Rotate / flip'), ' — roll strips to bring a cut face up, flip alternate strips end-for-end, stand blocks on end.'),
      h('li', null, h('b', null, 'Glue up'), ' — side by side, stacked, end to end, or a grid of tiles. Offset alternate rows for brick layouts; turn on ', h('i', null, 'Nest'), ' so wedges, angled strips and hexagons slide together instead of butting their outer boxes.'),
      h('li', null, h('b', null, 'Trim / plane'), ' — square up the top or the end (cross-section), shrink to the material, trim edges, plane faces.'),
      h('li', null, h('b', null, 'Finishing'), ' — round the corners, roundover or chamfer the top and bottom edges, rout a juice groove, and add handles: finger grips, through slots, holes or edge scoops. Do these last, in shop order.')),
    h('p', null, h('b', null, 'Bevel cuts: '), 'set ', h('i', null, 'Blade bevel'), ' on a cut step to tilt the blade (up to 45°) while the piece lies flat — that’s how the diamond, half-square-triangle and tumbling-block examples are made. Taper cuts make wedges. ', h('b', null, 'Random: '), 'shuffle and “Random half” flips are repeatable — change the seed for a new layout.'),
    h('p', null, h('b', null, 'Choosing pieces: '), h('code', null, 'A*'), ' every unused piece of step A · ', h('code', null, 'A'), ' the next piece no earlier step has used · ', h('code', null, 'A3'), ' piece 3 · ', h('code', null, 'A2-5'), ' a range · ', h('code', null, '(A B)*'), ' alternate until one runs out · ', h('code', null, '(A B)3'), ' three times.'),
    h('p', null, h('b', null, 'Grain: '), 'end grain shows rings and pores and reads darker; edge grain shows tight straight lines; face grain shows the wide, wavy figure. Switch to ', h('b', null, 'Grain map'), ' to colour them ', h('span', { class: 'gtxt g0' }, 'red'), ', ', h('span', { class: 'gtxt g1' }, 'blue'), ' and ', h('span', { class: 'gtxt g2' }, 'green'), '. Glue lines are drawn between separate pieces.'),
    h('p', null, 'Dimensions accept fractions: ', h('code', null, '1 1/2'), ', ', h('code', null, '3/4'), ', ', h('code', null, '1-1/4'), '.')));
}

// ---------- analytics ----------
// Google Analytics events (only when the build added the tag). Never send names, usernames, project ids or designs.
function track(event, params) {
  try { if (typeof gtag === 'function') gtag('event', event, params || {}); } catch (e) {}
}

// ---------- boot ----------
function boot() {
  $('#addbar').append(...Object.entries(TYPE_INFO).map(([t, inf]) => h('button', { class: 'add ' + inf.cls, onclick: () => addStep(t) }, '+ ' + inf.label)));
  const ex = $('#examples');
  ex.append(h('option', { value: '' }, 'Load example…'));
  const groups = {};
  for (const [k, e] of Object.entries(EXAMPLES)) (groups[e.group || 'More'] || (groups[e.group || 'More'] = [])).push([k, e]);
  for (const g of ['Classics', 'Finished boards', 'Quilt tiles', 'Angles & inlays', 'Stripes & logs', 'More', 'Start fresh']) if (groups[g]) ex.append(h('optgroup', { label: g }, groups[g].map(([k, e]) => h('option', { value: k }, e.name))));
  ex.onchange = () => { const k = ex.value; ex.value = ''; if (!k) return; track('load_example', { example: k }); project = clone(EXAMPLES[k]); selected = { step: null, piece: 0 }; cloudDetach(); save(); renderSteps(); recompute(); };
  $('#projname').oninput = e => { project.name = e.target.value; save(); };
  document.querySelectorAll('[data-units]').forEach(b => b.onclick = () => convertUnits(b.dataset.units));
  document.querySelectorAll('[data-face]').forEach(b => b.onclick = () => { view.face = b.dataset.face; track('change_view', { view: view.face }); drawPreview(); });
  document.querySelectorAll('[data-mode]').forEach(b => b.onclick = () => { view.mode = b.dataset.mode; track('change_look', { look: view.mode }); document.querySelectorAll('[data-mode]').forEach(x => x.classList.toggle('on', x.dataset.mode === view.mode)); updateOutputs(); drawPreview(); });
  $('#glue').onchange = e => { view.glue = e.target.checked; updateOutputs(); drawPreview(); };
  $('#oiled').onchange = e => { view.oiled = e.target.checked; updateOutputs(); drawPreview(); };
  $('#thumbface').onchange = e => { view.thumb = e.target.value; updateOutputs(); };
  const setR = r => { view.renderer = r; track('change_renderer', { renderer: r }); try { localStorage.setItem('cbd-renderer-v2', r); } catch (e) {} document.querySelectorAll('[data-renderer]').forEach(x => x.classList.toggle('on', x.dataset.renderer === r)); if (view.face !== '3d') view.face = '3d'; drawPreview(); refreshInline(); };
  document.querySelectorAll('[data-renderer]').forEach(b => { b.classList.toggle('on', b.dataset.renderer === view.renderer); b.onclick = () => setR(b.dataset.renderer); });
  document.querySelectorAll('[data-cam]').forEach(b => b.onclick = () => b.dataset.cam === 'reset' ? GLR.reset() : GLR.preset(b.dataset.cam));
  document.querySelectorAll('[data-glall]').forEach(b => b.onclick = () => { view.glAll = b.dataset.glall === 'all'; drawPreview(); });
  $('#offcuts').checked = view.waste;
  $('#offcuts').onchange = e => { view.waste = e.target.checked; drawPreview(); updateMaterials(); };
  $('#scaleobj').value = view.scale;
  $('#scaleobj').onchange = e => { view.scale = e.target.value; try { localStorage.setItem('cbd-scale', view.scale); } catch (err) {} drawPreview(); };
  $('#pcollapse').onclick = () => {
    const p = $('#preview'), b = $('#pcollapse'), shut = p.classList.toggle('collapsed');
    b.replaceChildren(ic(shut ? 'chevRight' : 'chevDown'), h('span', null, shut ? 'Show' : 'Hide')); b.setAttribute('aria-expanded', String(!shut));
    if (!shut) drawPreview();
  };
  $('#popts').onclick = () => { const on = $('#preview').classList.toggle('opts'); $('#popts').setAttribute('aria-expanded', String(on)); $('#popts').classList.toggle('on', on); };
  $('#btn-woods').onclick = woodDialog;
  $('#btn-project').onclick = projectDialog;
  $('#cloudid').onclick = projectDialog;
  $('#btn-account').onclick = () => accountDialog();
  updateCloudChip();
  loadAccount();
  window.addEventListener('hashchange', openFromHash);
  $('#btn-help').onclick = helpDialog;
  $('#btn-reseed').onclick = () => { track('new_figure'); project.seed = (project.seed || 0) + 1; save(); recompute(); };
  let rt = 0; window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { drawPreview(); refreshInline(); }, 150); });
  renderSteps(); recompute();
  openFromHash();
}
boot();

</script>
</body>
</html>
