<div id="zpr">
<style>
#zpr{--cream:#F7F3EA;--paper:#FFFDF8;--ink:#1D1B16;--mute:#6B6456;--line:#E4DCC8;--gold:#B4924A;--em:#1E5A44;--em2:#174736;--warn:#9A5B13;--bad:#8C2F39;position:fixed;inset:0;display:flex;flex-direction:column;background:var(--cream);color:var(--ink);font:13px/1.4 -apple-system,"Segoe UI",Roboto,sans-serif}
html.darkMode #zpr{--cream:#191815;--paper:#22201C;--ink:#EEE8DA;--mute:#A89F8C;--line:#3A362E;--gold:#C9A961;--em:#3E9C78;--em2:#2F7D60;--warn:#D39A4A;--bad:#D46A74}
#zpr [hidden]{display:none!important}
#zpr *{box-sizing:border-box}
#zpr .hd{padding:10px 12px 0;border-bottom:1px solid var(--gold);background:var(--paper)}
#zpr .brand{display:flex;align-items:baseline;gap:8px}
#zpr .brand b{font:600 19px/1.1 Georgia,"Times New Roman",serif;letter-spacing:.2px}
#zpr .brand span{color:var(--mute);font-size:11px}
#zpr .tabs{display:flex;gap:2px;margin-top:8px;overflow-x:auto}
#zpr .tab{border:0;background:none;padding:6px 8px 7px;color:var(--mute);cursor:pointer;font:600 12px/1 inherit;border-bottom:2px solid transparent;white-space:nowrap}
#zpr .tab.on{color:var(--ink);border-bottom-color:var(--em)}
#zpr .bd{flex:1;overflow:auto;padding:10px 12px 16px}
#zpr .ft{border-top:1px solid var(--line);padding:6px 12px;font-size:11px;color:var(--mute);background:var(--paper);display:flex;gap:6px;align-items:center;justify-content:space-between}
#zpr .pane{display:none}
#zpr .pane.on{display:block}
#zpr h3{font:600 14px/1.2 Georgia,serif;margin:12px 0 6px}
#zpr h3:first-child{margin-top:2px}
#zpr label{display:block;font-size:11px;color:var(--mute);margin:8px 0 3px;text-transform:uppercase;letter-spacing:.5px}
#zpr select,#zpr input{width:100%;padding:6px 7px;border:1px solid var(--line);border-radius:6px;background:var(--paper);color:var(--ink);font:13px inherit}
#zpr .row{display:flex;gap:6px}
#zpr .row>*{flex:1;min-width:0}
#zpr .chips{display:flex;flex-wrap:wrap;gap:4px}
#zpr .chip{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:14px;padding:4px 9px;cursor:pointer;font:12px inherit;display:inline-flex;align-items:center;gap:5px}
#zpr .chip.on{border-color:var(--em);box-shadow:inset 0 0 0 1px var(--em);font-weight:600}
#zpr .dot{width:9px;height:9px;border-radius:50%;display:inline-block}
#zpr .btn{border:1px solid var(--line);background:var(--paper);color:var(--ink);border-radius:7px;padding:7px 10px;cursor:pointer;font:600 12px inherit}
#zpr .btn:hover{border-color:var(--gold)}
#zpr .go{display:block;width:100%;margin-top:12px;background:var(--em);color:#fff;border:0;border-radius:8px;padding:10px;font:600 13px inherit;cursor:pointer}
#zpr .go:hover{background:var(--em2)}
#zpr .go:disabled{opacity:.5;cursor:default}
#zpr .hint{font-size:11px;color:var(--mute);margin-top:6px}
#zpr .card{background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:9px 10px;margin-top:10px}
#zpr .kv{display:flex;justify-content:space-between;font-size:12px;padding:2px 0}
#zpr .kv b{font-variant-numeric:tabular-nums}
#zpr table{width:100%;border-collapse:collapse;font-size:12px}
#zpr td,#zpr th{padding:4px 3px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
#zpr th{font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.4px;font-weight:600}
#zpr td.n,#zpr th.n{text-align:right;font-variant-numeric:tabular-nums}
#zpr .pill{display:inline-block;font-size:10px;padding:1px 6px;border-radius:9px;border:1px solid var(--line);color:var(--mute)}
#zpr .pill.warn{border-color:var(--warn);color:var(--warn)}
#zpr .pill.ok{border-color:var(--em);color:var(--em)}
#zpr .lnk{color:var(--em);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
#zpr .toast{position:absolute;left:10px;right:10px;bottom:34px;background:var(--ink);color:var(--paper);padding:8px 10px;border-radius:8px;font-size:12px;opacity:0;transition:opacity .2s;pointer-events:none}
#zpr .toast.on{opacity:.95}
#zpr .gold{height:1px;background:var(--gold);opacity:.6;margin:12px 0}
#zpr svg text{fill:var(--ink)}
#zpr .glyph{display:flex;gap:10px;align-items:center;margin-top:8px}
#zpr .glyph svg{flex:none}
#zpr .small{font-size:11px}
#zpr input[type=checkbox]{width:auto;margin-right:5px}
#zpr .ck{display:flex;align-items:center;font-size:12px;margin-top:6px;color:var(--ink);text-transform:none;letter-spacing:0}
</style>
<div class="hd">
<div class="brand"><b>zzPipeRun</b><span>spec-driven MEP takeoff</span></div>
<div class="tabs">
<button class="tab on" data-tab="pipe">Pipe</button>
<button class="tab" data-tab="riser">Riser / Drop</button>
<button class="tab" data-tab="duct">Duct</button>
<button class="tab" data-tab="fix">Fixtures</button>
<button class="tab" data-tab="runs">Runs & QA</button>
<button class="tab" data-tab="setup">Setup</button>
</div>
</div>
<div class="bd">
<!-- PIPE -->
<div class="pane on" data-pane="pipe">
<h3>Pipe run</h3>
<label>System</label><div class="chips" id="zp-sys"></div>
<label>Spec</label><select id="zp-spec"></select>
<label>Size</label><div class="chips" id="zp-size"></div>
<div class="row">
<div><label>Insulation</label><select id="zp-ins"><option value="">None</option><option>1/2</option><option>1</option><option>1-1/2</option><option>2</option></select></div>
<div><label>Rod drop, ft</label><input id="zp-rod" type="number" step="0.5" value="2"></div>
<div><label>Waste %</label><input id="zp-waste" type="number" step="1" value="5"></div>
</div>
<div class="card small" id="zp-preview"></div>
<button class="go" id="zp-draw">Draw run</button>
<div class="hint">Trace the run on the plan. Each click is a vertex; a bend becomes an elbow. Start a branch on top of an existing run and it becomes a tee. Hold the right mouse button to pan. Click the green dot to start or continue, the red button to stop.</div>
</div>
<!-- RISER -->
<div class="pane" data-pane="riser">
<h3>Riser / drop</h3>
<label>System</label><div class="chips" id="zr-sys"></div>
<label>Spec</label><select id="zr-spec"></select>
<label>Size</label><div class="chips" id="zr-size"></div>
<div class="row">
<div><label>From elev, ft</label><input id="zr-from" type="number" step="0.25" value="0"></div>
<div><label>To elev, ft</label><input id="zr-to" type="number" step="0.25" value="12"></div>
</div>
<div class="row">
<div><label>Floor to floor, ft</label><input id="zr-flr" type="number" step="0.5" value="12"></div>
<div><label>Insulation</label><select id="zr-ins"><option value="">None</option><option>1/2</option><option>1</option><option>1-1/2</option></select></div>
</div>
<label class="ck"><input type="checkbox" id="zr-top" checked>Elbow at top</label>
<label class="ck"><input type="checkbox" id="zr-bot" checked>Elbow at bottom</label>
<div class="card small" id="zr-preview"></div>
<button class="go" id="zr-draw">Place risers</button>
<div class="hint">Click once on the plan at each riser or drop location. Every click carries the full vertical footage shown above.</div>
</div>
<!-- DUCT -->
<div class="pane" data-pane="duct">
<h3>Duct run</h3>
<label>System</label><div class="chips" id="zd-sys"></div>
<label>Shape</label><div class="chips" id="zd-shape"><button class="chip on" data-v="rect">Rectangular</button><button class="chip" data-v="round">Round / spiral</button></div>
<div class="row">
<div><label id="zd-al">Width, in</label><input id="zd-a" type="number" step="1" value="24"></div>
<div id="zd-bw"><label>Height, in</label><input id="zd-b" type="number" step="1" value="12"></div>
</div>
<div class="row">
<div><label>Joint every, ft</label><input id="zd-j" type="number" step="1" value="5"></div>
<div><label>Hangers @, ft</label><input id="zd-h" type="number" step="1" value="8"></div>
</div>
<div class="row">
<div><label>Wrap, in</label><select id="zd-wrap"><option value="">None</option><option>1-1/2</option><option>2</option></select></div>
<div><label>Liner, in</label><select id="zd-liner"><option value="">None</option><option>1</option><option>1-1/2</option><option>2</option></select></div>
</div>
<div class="card" id="zd-preview"></div>
<button class="go" id="zd-draw">Draw duct</button>
<div class="hint">The run draws at its true width on the plan. Bends become elbows, a run started on another run of the same system becomes a branch tap, and a size change end to end becomes a transition.</div>
</div>
<!-- FIXTURES -->
<div class="pane" data-pane="fix">
<h3>Fixtures & equipment</h3>
<div class="hint" style="margin-top:0">Each fixture counts as an assembly carrying its trim and rough-in. Set the tag used on your drawings, then Count it by hand, or Find it in the plan text to check your count.</div>
<div id="zf-list"></div>
<div class="card" id="zf-result" hidden></div>
</div>
<!-- RUNS & QA -->
<div class="pane" data-pane="runs">
<h3>Runs in this project</h3>
<div id="zq-chart"></div>
<div id="zq-table"></div>
<button class="go" id="zq-analyze">Analyze fittings now</button>
<div class="hint" id="zq-when"></div>
<div class="card" id="zq-open" hidden></div>
</div>
<!-- SETUP -->
<div class="pane" data-pane="setup">
<h3>Connections</h3>
<label>Connect tolerance, ft</label><input id="zs-tol" type="number" step="0.25" value="1">
<div class="hint">Run ends closer than this (at the sheet's scale) are treated as joined.</div>
<label class="ck"><input type="checkbox" id="zs-auto" checked>Re-analyze fittings automatically when runs change</label>
<h3>Spec table</h3>
<select id="zs-spec"></select>
<div id="zs-grid"></div>
<button class="btn" id="zs-reset" style="margin-top:8px">Reset this spec to defaults</button>
<div class="gold"></div>
<h3>Where the numbers come from</h3>
<div class="small" style="color:var(--mute)">Horizontal hanger spacing: IPC 2021 Table 308.5; carbon steel over 2" uses MSS SP-58 water-service spacing. Duct gauge is a simplified largest-side rule at 2" w.g. with G90 weights (26 ga 0.906, 24 ga 1.156, 22 ga 1.406, 20 ga 1.656, 18 ga 2.156 lb/SF); check SMACNA for the job's pressure class. Fixture trim lists are typical, not a licensed labor or material manual. Every quantity lands in zzTakeoff as an ordinary item, so zzPipeEst or your own pricing can pick it up.</div>
</div>
</div>
<div class="ft"><span id="zz-ctx">Loading…</span><span id="zz-layer"></span></div>
<div class="toast" id="zz-toast"></div>
<script>
/* zzPipeRun core - pure data + geometry, no zzTakeoff API calls. Shared by the component and the MCP proof-out. */
var ZPR = (function(){
var F = {"1/2":0.5,"3/4":0.75,"1":1,"1-1/4":1.25,"1-1/2":1.5,"2":2,"2-1/2":2.5,"3":3,"4":4,"5":5,"6":6,"8":8,"10":10,"12":12};
function sizeNum(s){ return F[s] != null ? F[s] : parseFloat(s) || 0; }
var SYSTEMS = [
{k:"DCW", name:"Domestic cold water", color:"#1F5F8B", spec:"CU-L", kind:"pipe"},
{k:"DHW", name:"Domestic hot water", color:"#A23B2A", spec:"CU-L", kind:"pipe"},
{k:"HWR", name:"Hot water return", color:"#C0762E", spec:"CU-L", kind:"pipe"},
{k:"SAN", name:"Sanitary waste", color:"#5B4636", spec:"PVC-DWV", kind:"pipe"},
{k:"VNT", name:"Vent", color:"#7A7A52", spec:"PVC-DWV", kind:"pipe"},
{k:"STM", name:"Storm", color:"#2F6E6A", spec:"CI-NH", kind:"pipe"},
{k:"GAS", name:"Natural gas", color:"#B08A1E", spec:"BS-40T", kind:"pipe"},
{k:"CHW", name:"Chilled water", color:"#2B4C7E", spec:"BS-40G", kind:"pipe"},
{k:"HHW", name:"Heating hot water", color:"#8C2F39", spec:"BS-40G", kind:"pipe"},
{k:"CD", name:"Condensate drain", color:"#4F7C5A", spec:"PVC-40", kind:"pipe"},
{k:"FP", name:"Fire protection", color:"#9E1B1B", spec:"BS-40G", kind:"pipe"}
];
var DUCT_SYSTEMS = [
{k:"SA", name:"Supply air", color:"#1F5F8B"},
{k:"RA", name:"Return air", color:"#2F6E3A"},
{k:"EA", name:"Exhaust air", color:"#7A4A2A"},
{k:"OA", name:"Outside air", color:"#5A5F8B"},
{k:"TA", name:"Transfer air", color:"#6E6A5A"}
];
/* Hanger spacing (ft, horizontal) follows IPC 2021 Table 308.5; grooved/welded steel over 2" follows MSS SP-58 water service. Editable in Setup. */
function steelMSS(n){ return n<=1.25?7 : n<=1.5?9 : n<=2?10 : n<=2.5?11 : n<=3?12 : n<=4?14 : n<=6?17 : 19; }
var SPECS = {
"CU-L": {name:"Copper Type L, wrought solder", short:"Cu L", stick:20, sizes:["1/2","3/4","1","1-1/4","1-1/2","2","2-1/2","3","4"], hang:function(n){return n<=1.25?6:10;}, hanger:"Clevis hanger, copper-plated", el90:"90° elbow C×C", el45:"45° elbow C×C", tee:"Tee C×C×C", cross:"Cross (2 tees)", red:"Reducing coupling", cpl:"Coupling C×C", joint:"Solder joints", has22:false},
"CU-K": {name:"Copper Type K, wrought solder", short:"Cu K", stick:20, sizes:["3/4","1","1-1/4","1-1/2","2","2-1/2","3","4"], hang:function(n){return n<=1.25?6:10;}, hanger:"Clevis hanger, copper-plated", el90:"90° elbow C×C", el45:"45° elbow C×C", tee:"Tee C×C×C", cross:"Cross (2 tees)", red:"Reducing coupling", cpl:"Coupling C×C", joint:"Solder joints", has22:false},
"PEX-A": {name:"PEX-A, expansion fittings", short:"PEX", stick:0, sizes:["1/2","3/4","1","1-1/4","1-1/2","2"], hang:function(n){return n<=1?2.67:4;}, hanger:"PEX support / clip", el90:"PEX 90° elbow", el45:"PEX bend support", tee:"PEX tee", cross:"PEX tee (×2)", red:"PEX reducing coupling", cpl:"PEX coupling", joint:"Expansion joints", has22:false},
"CPVC": {name:"CPVC CTS, solvent weld", short:"CPVC", stick:10, sizes:["1/2","3/4","1","1-1/4","1-1/2","2"], hang:function(n){return n<=1?3:4;}, hanger:"Plastic pipe clamp", el90:"CPVC 90° elbow", el45:"CPVC 45° elbow", tee:"CPVC tee", cross:"CPVC tee (×2)", red:"CPVC reducing bushing", cpl:"CPVC coupling", joint:"Solvent-weld joints", has22:false},
"PVC-DWV":{name:"PVC Sch 40 DWV, solvent weld", short:"PVC DWV", stick:20, sizes:["1-1/2","2","3","4","6","8"], hang:function(){return 4;}, hanger:"Clevis hanger", el90:"1/4 bend (long sweep)", el45:"1/8 bend", el22:"1/16 bend", tee:"Combination wye & 1/8 bend", cross:"Double combination wye", red:"Reducing coupling / bushing", cpl:"DWV coupling", joint:"Solvent-weld joints", has22:true},
"CI-NH": {name:"Cast iron no-hub", short:"CI NH", stick:10, sizes:["2","3","4","5","6","8","10","12"], hang:function(){return 10;}, hanger:"Clevis hanger (within 18\" of each joint)", el90:"No-hub 1/4 bend", el45:"No-hub 1/8 bend", el22:"No-hub 1/16 bend", tee:"No-hub combination wye & 1/8 bend", cross:"No-hub double combination wye", red:"No-hub reducer", cpl:"No-hub coupling (pipe to pipe)", joint:"No-hub couplings at fittings", has22:true},
"BS-40T": {name:"Black steel Sch 40, threaded malleable", short:"BS40 Thd", stick:21, sizes:["1/2","3/4","1","1-1/4","1-1/2","2"], hang:function(){return 12;}, hanger:"Clevis hanger", el90:"Malleable 90° ell", el45:"Malleable 45° ell", tee:"Malleable tee", cross:"Malleable cross", red:"Malleable bell reducer", cpl:"Malleable coupling", joint:"Threaded joints", has22:false},
"BS-40W": {name:"Carbon steel Sch 40, butt weld", short:"BS40 Weld", stick:21, sizes:["2-1/2","3","4","6","8","10","12"], hang:steelMSS, hanger:"Clevis hanger", el90:"Weld 90° LR ell", el45:"Weld 45° ell", tee:"Weld tee", cross:"Weld cross", red:"Weld concentric reducer", cpl:"Butt weld (pipe to pipe)", joint:"Butt welds at fittings", has22:false},
"BS-40G": {name:"Carbon steel Sch 40, roll-grooved", short:"BS40 Grv", stick:21, sizes:["1-1/4","1-1/2","2","2-1/2","3","4","6","8"], hang:steelMSS, hanger:"Clevis hanger", el90:"Grooved 90° elbow", el45:"Grooved 45° elbow", tee:"Grooved tee", cross:"Grooved cross", red:"Grooved concentric reducer", cpl:"Grooved coupling (pipe to pipe)", joint:"Grooved couplings at fittings", has22:false},
"PVC-40": {name:"PVC Sch 40 pressure, solvent weld", short:"PVC 40", stick:20, sizes:["3/4","1","1-1/4","1-1/2","2"], hang:function(){return 4;}, hanger:"Plastic pipe clamp", el90:"PVC 90° elbow", el45:"PVC 45° elbow", tee:"PVC tee", cross:"PVC cross", red:"PVC reducing bushing", cpl:"PVC coupling", joint:"Solvent-weld joints", has22:false}
};
/* Galvanized duct gauge, simplified: largest side, 2" w.g. low pressure. lb/SF for G90 galvanized. Verify vs SMACNA HVAC Duct Construction Standards for the job's pressure class. */
var LBSF = {26:0.906, 24:1.156, 22:1.406, 20:1.656, 18:2.156};
function ductGauge(shape, a, b){
var m = shape==="round" ? a : Math.max(a, b||0);
if (shape==="round") return m<=14?26 : m<=26?24 : m<=36?22 : m<=50?20 : 18;
return m<=12?26 : m<=30?24 : m<=54?22 : m<=84?20 : 18;
}
function ductPerim(shape, a, b){ return shape==="round" ? Math.PI*a/12 : 2*(a+b)/12; } /* ft of surface per ft of duct */
function ftIn(ft){ var neg = ft<0; ft=Math.abs(ft); var f=Math.floor(ft+1e-9); var i=Math.round((ft-f)*12); if(i===12){f++;i=0;} return (neg?"-":"")+f+"'-"+i+"\""; }
function dimMeta(ft){ var f=Math.floor(Math.abs(ft)+1e-9); var inch=(Math.abs(ft)-f)*12; var wi=Math.floor(inch+1e-9); var frac=inch-wi; var den=16, num=Math.round(frac*den); if(num===den){wi++;num=0;} return {ft:f, in:wi, num:num, den:den}; }
/* ---------- geometry ---------- */
function d2(a,b){ var dx=a.x-b.x, dy=a.y-b.y; return Math.sqrt(dx*dx+dy*dy); }
function segDist(p,a,b){ var vx=b.x-a.x, vy=b.y-a.y, L=vx*vx+vy*vy; if(!L) return {d:d2(p,a),t:0}; var t=((p.x-a.x)*vx+(p.y-a.y)*vy)/L; t=Math.max(0,Math.min(1,t)); var q={x:a.x+t*vx,y:a.y+t*vy}; return {d:d2(p,q),t:t,q:q}; }
function unit(a,b){ var dx=b.x-a.x, dy=b.y-a.y, L=Math.sqrt(dx*dx+dy*dy)||1; return {x:dx/L,y:dy/L}; }
function angBetween(u,v){ var c=u.x*v.x+u.y*v.y; c=Math.max(-1,Math.min(1,c)); return Math.acos(c)*180/Math.PI; }
function bucket(defl){ if (defl<12) return null; if (defl<33.75) return "22"; if (defl<67.5) return "45"; if (defl<=135) return "90"; return "2x90"; }
function pxLen(pts){ var s=0; for(var i=1;i<pts.length;i++) s+=d2(pts[i-1],pts[i]); return s; }
/* drop vertices that make a segment shorter than minPx (the finishing double-click leaves one) */
function clean(pts, minPx){ if (pts.length<3) return pts; var o=[pts[0]]; for (var i=1;i<pts.length;i++){ if (d2(o[o.length-1],pts[i])>=minPx) o.push(pts[i]); else if (i===pts.length-1 && o.length>1) o[o.length-1]=pts[i]; } return o; }
function dedupe(pts){ var o=[]; for(var i=0;i<pts.length;i++){ if(!o.length || d2(o[o.length-1],pts[i])>0.5) o.push(pts[i]); } return o; }
/* runs: [{id (draw object), asm, sys, kind, size (number), pageId, pts, scale (px per ft)}]
terminals: [{pageId, x, y, kind:"riser"|"fixture", asm}] (points that make a pipe end "connected")
opts: {tolFt}
returns {byAsm: {asmId: {e90,e45,e22,tees:{branchSize:n},crosses,reducers:{size:n},open}}, open:[{pageId,x,y,asm}], connections:n} */
function analyze(runs, terminals, opts){
var tolFt = (opts && opts.tolFt) || 1;
var byAsm = {}, open = [], connections = 0;
function A(id){ return byAsm[id] || (byAsm[id] = {e90:0,e45:0,e22:0,tees:{},crosses:0,reducers:{},open:0,taps:0}); }
runs.forEach(function(r){ r.pts = clean(dedupe(r.pts||[]), Math.max(3, 0.3*(r.scale||20))); A(r.asm); });
runs = runs.filter(function(r){ return r.pts.length>=2; });
/* 1. bends inside each drawn run */
runs.forEach(function(r){
for (var i=1;i<r.pts.length-1;i++){
var b = bucket(angBetween(unit(r.pts[i-1],r.pts[i]), unit(r.pts[i],r.pts[i+1])));
if (b==="90") A(r.asm).e90++; else if (b==="45") A(r.asm).e45++; else if (b==="22") A(r.asm).e22++; else if (b==="2x90") A(r.asm).e90+=2;
}
});
/* 2. endpoints */
var ends = [];
runs.forEach(function(r, ri){
var n = r.pts.length;
ends.push({ri:ri, p:r.pts[0], dir:unit(r.pts[1], r.pts[0]), tol:Math.max(tolFt*(r.scale||20), 8)});
ends.push({ri:ri, p:r.pts[n-1], dir:unit(r.pts[n-2], r.pts[n-1]), tol:Math.max(tolFt*(r.scale||20), 8)});
});
/* 3. cluster endpoints (same page + system) */
var parent = ends.map(function(_,i){return i;});
function find(i){ while(parent[i]!==i){ parent[i]=parent[parent[i]]; i=parent[i]; } return i; }
for (var i=0;i<ends.length;i++) for (var j=i+1;j<ends.length;j++){
var a=ends[i], b=ends[j], ra=runs[a.ri], rb=runs[b.ri];
if (a.ri===b.ri || ra.pageId!==rb.pageId || ra.sys!==rb.sys || ra.kind!==rb.kind) continue;
if (d2(a.p,b.p) <= Math.max(a.tol,b.tol)) parent[find(i)] = find(j);
}
var clusters = {};
ends.forEach(function(e,i){ var k=find(i); (clusters[k]=clusters[k]||[]).push(e); });
/* 4. classify each cluster */
Object.keys(clusters).forEach(function(k){
var C = clusters[k], r0 = runs[C[0].ri];
var cx=0, cy=0; C.forEach(function(e){cx+=e.p.x; cy+=e.p.y;}); var P={x:cx/C.length, y:cy/C.length};
var tol = C[0].tol;
var members = {}; C.forEach(function(e){ members[e.ri]=1; });
/* does the junction land on the interior of another run of this system? -> branch connection */
var main = null, best = 1e9;
runs.forEach(function(r, ri){
if (members[ri] || r.pageId!==r0.pageId || r.sys!==r0.sys || r.kind!==r0.kind) return;
for (var s=1;s<r.pts.length;s++){
var sd = segDist(P, r.pts[s-1], r.pts[s]);
if (sd.d <= tol && sd.d < best){
var nearEnd = (s===1 && d2(sd.q, r.pts[0])<=tol) || (s===r.pts.length-1 && d2(sd.q, r.pts[r.pts.length-1])<=tol);
if (!nearEnd){ best = sd.d; main = {ri:ri, seg:s, q:sd.q}; }
}
}
});
if (main){
var M = runs[main.ri];
connections++;
var branches = C.map(function(e){ return runs[e.ri]; });
if (C.length>=2){
/* two branches on opposite sides of the main -> cross; otherwise count tees */
A(M.asm).crosses += Math.floor(C.length/2);
if (C.length % 2) addTee(M, branches[branches.length-1]);
} else addTee(M, branches[0]);
return;
}
if (C.length===1){
var e = C[0];
var connected = (terminals||[]).some(function(t){ return t.pageId===r0.pageId && d2(t, e.p) <= e.tol*1.5; });
if (!connected){ A(runs[e.ri].asm).open++; open.push({pageId:r0.pageId, x:e.p.x, y:e.p.y, asm:runs[e.ri].asm}); }
return;
}
connections++;
if (C.length===2){
var ea=C[0], eb=C[1], ra=runs[ea.ri], rb=runs[eb.ri];
var defl = 180 - angBetween(ea.dir, eb.dir); /* straight continuation = outward dirs opposite */
var big = ra.size>=rb.size ? ra : rb, small = big===ra ? rb : ra;
var b = bucket(defl);
if (b==="90") A(big.asm).e90++; else if (b==="45") A(big.asm).e45++; else if (b==="22") A(big.asm).e22++; else if (b==="2x90") A(big.asm).e90+=2;
if (ra.size !== rb.size){ var rk = String(small.size); A(big.asm).reducers[rk] = (A(big.asm).reducers[rk]||0)+1; }
return;
}
if (C.length===3){
/* the most collinear pair is the main */
var pairs=[[0,1,2],[0,2,1],[1,2,0]], bestP=null, bestA=-1;
pairs.forEach(function(p){ var ang = angBetween(C[p[0]].dir, C[p[1]].dir); if (ang>bestA){ bestA=ang; bestP=p; } });
var m1 = runs[C[bestP[0]].ri], m2 = runs[C[bestP[1]].ri], br = runs[C[bestP[2]].ri];
var mm = m1.size>=m2.size ? m1 : m2;
addTee(mm, br);
if (m1.size!==m2.size){ var sm = mm===m1?m2:m1; A(mm.asm).reducers[String(sm.size)] = (A(mm.asm).reducers[String(sm.size)]||0)+1; }
return;
}
/* 4+ ends at one point: cross */
var biggest = C.map(function(e){return runs[e.ri];}).sort(function(x,y){return y.size-x.size;})[0];
A(biggest.asm).crosses++;
});
function addTee(M, br){
var k = String(br.size);
if (M.kind==="duct"){ A(M.asm).taps++; A(M.asm).tees[k] = (A(M.asm).tees[k]||0)+1; return; }
A(M.asm).tees[k] = (A(M.asm).tees[k]||0)+1;
}
return {byAsm:byAsm, open:open, connections:connections, runs:runs.length};
}
function fmtSize(n){ var inv={}; Object.keys(F).forEach(function(k){ inv[F[k]]=k; }); return (inv[n]||String(n))+"\""; }
/* fitting rows written as static quantities after an analysis */
function autoItems(meta, res, specOverride){
var rows = [];
if (meta.kind==="duct"){
var round = meta.shape==="round";
if (res.e90) rows.push({name: round?"Round 90° elbow (adjustable)":"Rect 90° elbow w/ turning vanes", qty:res.e90});
if (res.e45) rows.push({name: round?"Round 45° elbow":"Rect 45° elbow", qty:res.e45});
if (res.e22) rows.push({name: round?"Round 22½° elbow":"Rect 22½° elbow", qty:res.e22});
Object.keys(res.tees).forEach(function(k){ rows.push({name:"Branch tap / takeoff to "+k+"\" branch", qty:res.tees[k]}); });
if (res.crosses) rows.push({name:"Double branch tap (cross)", qty:res.crosses});
Object.keys(res.reducers).forEach(function(k){ rows.push({name:"Transition to "+k+"\"", qty:res.reducers[k]}); });
if ((meta.v||1)>=2 && (res.e90+res.e45+res.e22)) rows.push({name:(round?"Strap hanger":"Trapeze hanger")+" at each change of direction", qty:res.e90+res.e45+res.e22});
return rows;
}
var S = specOverride || SPECS[meta.spec] || SPECS["CU-L"];
var e45 = res.e45 + (S.has22 ? 0 : res.e22);
if (res.e90) rows.push({name:S.el90, qty:res.e90});
if (e45) rows.push({name:S.el45, qty:e45});
if (S.has22 && res.e22) rows.push({name:S.el22, qty:res.e22});
var teeN = 0;
Object.keys(res.tees).forEach(function(k){
var b = parseFloat(k); teeN += res.tees[k];
var nm = b===meta.size ? S.tee : S.tee+" (reducing, "+fmtSize(meta.size)+" × "+fmtSize(b)+" branch)";
rows.push({name:nm, qty:res.tees[k]});
});
if (res.crosses) rows.push({name:S.cross, qty:res.crosses});
var redN = 0;
Object.keys(res.reducers).forEach(function(k){ redN += res.reducers[k]; rows.push({name:S.red+" "+fmtSize(meta.size)+" × "+fmtSize(parseFloat(k)), qty:res.reducers[k]}); });
var joints = 2*(res.e90+res.e45+res.e22) + 3*teeN + 4*res.crosses + 2*redN;
if (joints) rows.push({name:S.joint+" (fitting ends)", qty:joints, units:"EA"});
var bends = res.e90+res.e45+res.e22;
if ((meta.v||1)>=2 && bends){ rows.push({name:S.hanger+" at each change of direction", qty:bends}); if (meta.rod) rows.push({name:"3/8\" threaded rod at direction hangers, "+meta.rod+"' drop", qty:bends*meta.rod, units:"LF"}); }
if (meta.ins && (res.e90+res.e45+res.e22+teeN+res.crosses)) rows.push({name:"Insulation fitting covers "+meta.ins+"\"", qty:res.e90+res.e45+res.e22+teeN+res.crosses});
return rows;
}
return {SYSTEMS:SYSTEMS, DUCT_SYSTEMS:DUCT_SYSTEMS, SPECS:SPECS, LBSF:LBSF, sizeNum:sizeNum, fmtSize:fmtSize, ductGauge:ductGauge, ductPerim:ductPerim, ftIn:ftIn, dimMeta:dimMeta, analyze:analyze, autoItems:autoItems, pxLen:pxLen, steelMSS:steelMSS};
})();
if (typeof module !== "undefined") module.exports = ZPR;
(function(){
var $ = function(s){ return document.querySelector("#zpr "+s); };
var $ = function(s){ return Array.prototype.slice.call(document.querySelectorAll("#zpr "+s)); };
function has(n){ return typeof window[n] !== "undefined"; }
function esc(s){ return String(s==null?"":s).replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); }
function toast(m, ms){ var t=$("#zz-toast"); t.textContent=m; t.classList.add("on"); clearTimeout(toast.t); toast.t=setTimeout(function(){ t.classList.remove("on"); }, ms||3200); }
function r2(n){ return Math.round(n*100)/100; }
function recs(x){ return (x && x.records) ? x.records : (Array.isArray(x) ? x : []); }
function pname(t){ var p=t && t.properties && t.properties.name; return p ? String(p.result!=null?p.result:(p.value||"")) : ""; }
var ST = {tab:"pipe", pSys:"DCW", pSpec:"CU-L", pSize:"1", rSys:"DCW", rSpec:"CU-L", rSize:"2", dSys:"SA", dShape:"rect", tolFt:1, auto:true, specEdits:{}, tags:{}, lastSig:{}, lastRun:null};
var CTX = {projectId:null, projectName:"", layerId:"", layerName:""};
var BUSY = false;
/* ---------- persistence ---------- */
async function loadSettings(){
try { var s = has("PluginWorkspaceSettings") ? await PluginWorkspaceSettings.get("zpr.settings") : null; if (s) { ["tolFt","auto","specEdits","tags"].forEach(function(k){ if (s[k]!=null) ST[k]=s[k]; }); } } catch(e){}
try { var u = has("PluginUserSettings") ? await PluginUserSettings.get("zpr.ui") : null; if (u) { ["tab","pSys","pSpec","pSize","rSys","rSpec","rSize","dSys","dShape"].forEach(function(k){ if (u[k]!=null) ST[k]=u[k]; }); } } catch(e){}
}
function saveSettings(){ try { if (has("PluginWorkspaceSettings")) return PluginWorkspaceSettings.set("zpr.settings", {tolFt:ST.tolFt, auto:ST.auto, specEdits:ST.specEdits, tags:ST.tags}); } catch(e){} }
function saveUI(){ try { if (has("PluginUserSettings")) return PluginUserSettings.set("zpr.ui", {tab:ST.tab,pSys:ST.pSys,pSpec:ST.pSpec,pSize:ST.pSize,rSys:ST.rSys,rSpec:ST.rSpec,rSize:ST.rSize,dSys:ST.dSys,dShape:ST.dShape}); } catch(e){} }
function spec(k){ return ZPR.SPECS[k] || ZPR.SPECS["CU-L"]; }
function stickOf(k){ var e=ST.specEdits[k]; return e && e.stick!=null ? +e.stick : spec(k).stick; }
function hangOf(k, size){ var e=ST.specEdits[k]; if (e && e.hang && e.hang[size]!=null) return +e.hang[size]; return spec(k).hang(ZPR.sizeNum(size)); }
function sys(k){ return ZPR.SYSTEMS.filter(function(s){return s.k===k;})[0] || ZPR.SYSTEMS[0]; }
function dsys(k){ return ZPR.DUCT_SYSTEMS.filter(function(s){return s.k===k;})[0] || ZPR.DUCT_SYSTEMS[0]; }
/* ---------- tabs ---------- */
$(".tab").forEach(function(b){ b.addEventListener("click", function(){ setTab(b.getAttribute("data-tab")); }); });
function setTab(t){ ST.tab=t; $(".tab").forEach(function(b){ b.classList.toggle("on", b.getAttribute("data-tab")===t); }); $(".pane").forEach(function(p){ p.classList.toggle("on", p.getAttribute("data-pane")===t); }); saveUI(); if (t==="runs") refreshRuns(false); }
/* ---------- pipe pane ---------- */
function sysChips(el, cur, onPick){
el.innerHTML = ZPR.SYSTEMS.map(function(s){ return '<button class="chip'+(s.k===cur?' on':'')+'" data-k="'+s.k+'" title="'+esc(s.name)+'"><span class="dot" style="background:'+s.color+'"></span>'+s.k+'</button>'; }).join("");
$("#"+el.id+" .chip").forEach(function(b){ b.addEventListener("click", function(){ onPick(b.getAttribute("data-k")); }); });
}
function specOptions(el, cur){ el.innerHTML = Object.keys(ZPR.SPECS).map(function(k){ return '<option value="'+k+'"'+(k===cur?' selected':'')+'>'+esc(ZPR.SPECS[k].name)+'</option>'; }).join(""); }
function sizeChips(el, specKey, cur, onPick){
var sz = spec(specKey).sizes;
el.innerHTML = sz.map(function(s){ return '<button class="chip'+(s===cur?' on':'')+'" data-s="'+s+'">'+s+'"</button>'; }).join("");
$("#"+el.id+" .chip").forEach(function(b){ b.addEventListener("click", function(){ onPick(b.getAttribute("data-s")); }); });
}
function fixSize(specKey, cur){ var sz=spec(specKey).sizes; return sz.indexOf(cur)>=0 ? cur : sz[Math.min(2, sz.length-1)]; }
function renderPipe(){
ST.pSize = fixSize(ST.pSpec, ST.pSize);
sysChips($("#zp-sys"), ST.pSys, function(k){ ST.pSys=k; ST.pSpec=sys(k).spec; renderPipe(); saveUI(); });
specOptions($("#zp-spec"), ST.pSpec);
sizeChips($("#zp-size"), ST.pSpec, ST.pSize, function(s){ ST.pSize=s; renderPipe(); saveUI(); });
var S=spec(ST.pSpec), st=stickOf(ST.pSpec), h=hangOf(ST.pSpec, ST.pSize);
$("#zp-preview").innerHTML =
'<div class="glyph">'+glyphSVG(sys(ST.pSys).color)+'<div><b>'+esc(pipeName())+'</b><br><span style="color:var(--mute)">'+esc(S.name)+'</span></div></div>'+
'<div class="gold" style="margin:8px 0"></div>'+
kv("Stick length", st ? st+" ft" : "coil, no couplings")+kv("Hangers", h+" ft o.c. + each change of direction")+kv("Bends become", S.el90+" / "+S.el45)+kv("Branches become", S.tee)+kv("Joint count", S.joint);
$("#zp-draw").textContent = "Draw "+ST.pSize+"\" "+ST.pSys+" run";
}
function kv(k,v){ return '<div class="kv"><span>'+esc(k)+'</span><b>'+esc(v)+'</b></div>'; }
function glyphSVG(c){
return '<svg width="54" height="40" viewBox="0 0 54 40" aria-hidden="true"><path d="M4 32 H26 V8 H50" fill="none" stroke="'+c+'" stroke-width="3.5" stroke-linejoin="round"/><path d="M26 20 H40" fill="none" stroke="'+c+'" stroke-width="2" opacity=".7"/><circle cx="26" cy="32" r="3.4" fill="var(--paper)" stroke="var(--gold)" stroke-width="1.4"/><circle cx="26" cy="8" r="3.4" fill="var(--paper)" stroke="var(--gold)" stroke-width="1.4"/><rect x="23" y="17" width="6" height="6" fill="var(--paper)" stroke="var(--gold)" stroke-width="1.4"/></svg>';
}
function pipeName(){ return ST.pSys+" · "+spec(ST.pSpec).short+" · "+ST.pSize+"\""; }
$("#zp-spec").addEventListener("change", function(e){ ST.pSpec=e.target.value; renderPipe(); saveUI(); });
/* ---------- riser pane ---------- */
function renderRiser(){
ST.rSize = fixSize(ST.rSpec, ST.rSize);
sysChips($("#zr-sys"), ST.rSys, function(k){ ST.rSys=k; ST.rSpec=sys(k).spec; renderRiser(); saveUI(); });
specOptions($("#zr-spec"), ST.rSpec);
sizeChips($("#zr-size"), ST.rSpec, ST.rSize, function(s){ ST.rSize=s; renderRiser(); saveUI(); });
var R = riserCalc();
$("#zr-preview").innerHTML = kv(R.dir==="RISER"?"Riser":"Drop", ZPR.ftIn(R.from)+" to "+ZPR.ftIn(R.to))+kv("Vertical pipe each", r2(R.rise)+" ft")+kv("Elbows each", R.el)+kv("Couplings each", R.cpl)+kv("Riser clamps each", R.clamps);
$("#zr-draw").textContent = "Place "+ST.rSize+"\" "+ST.rSys+" "+(R.dir==="RISER"?"risers":"drops");
}
function riserCalc(){
var from=+$("#zr-from").value||0, to=+$("#zr-to").value||0, rise=Math.abs(to-from), st=stickOf(ST.rSpec), flr=+$("#zr-flr").value||12;
return {from:from, to:to, rise:rise, dir: to>=from?"RISER":"DROP", el:($("#zr-top").checked?1:0)+($("#zr-bot").checked?1:0), cpl: st ? Math.max(0, Math.ceil(rise/st)-1) : 0, clamps: Math.max(1, Math.ceil(rise/flr))};
}
$("#zr-spec").addEventListener("change", function(e){ ST.rSpec=e.target.value; renderRiser(); saveUI(); });
["#zr-from","#zr-to","#zr-flr","#zr-top","#zr-bot"].forEach(function(s){ $(s).addEventListener("input", renderRiser); $(s).addEventListener("change", renderRiser); });
/* ---------- duct pane ---------- */
function ductCalc(){
var shape=ST.dShape, a=+$("#zd-a").value||0, b=shape==="round"?0:(+$("#zd-b").value||0);
var ga=ZPR.ductGauge(shape,a,b), per=ZPR.ductPerim(shape,a,b), lb=ZPR.LBSF[ga];
var wrap=$("#zd-wrap").value, liner=$("#zd-liner").value, wt=wrap?ZPR.sizeNum(wrap):0;
var wrapPer = wrap ? (shape==="round" ? Math.PI*(a+2*wt)/12 : 2*(a+b+4*wt)/12) : 0;
return {shape:shape,a:a,b:b,ga:ga,per:per,lb:lb,wrap:wrap,liner:liner,wrapPer:wrapPer,j:+$("#zd-j").value||5,h:+$("#zd-h").value||8, label: shape==="round" ? a+"\" Ø" : a+"×"+b};
}
function renderDuct(){
$("#zd-sys").innerHTML = ZPR.DUCT_SYSTEMS.map(function(s){ return '<button class="chip'+(s.k===ST.dSys?' on':'')+'" data-k="'+s.k+'" title="'+esc(s.name)+'"><span class="dot" style="background:'+s.color+'"></span>'+s.k+'</button>'; }).join("");
$("#zd-sys .chip").forEach(function(b){ b.addEventListener("click", function(){ ST.dSys=b.getAttribute("data-k"); renderDuct(); saveUI(); }); });
$("#zd-shape .chip").forEach(function(b){ b.classList.toggle("on", b.getAttribute("data-v")===ST.dShape); });
$("#zd-bw").hidden = ST.dShape==="round";
$("#zd-al").textContent = ST.dShape==="round" ? "Diameter, in" : "Width, in";
var D = ductCalc();
var w = 150, bh = 12, max = 2.2, bar = function(v){ return Math.max(2, Math.round(w*v/max)); };
$("#zd-preview").innerHTML =
'<div class="kv"><span><b style="font:600 14px Georgia,serif">'+esc(D.label)+'</b> '+esc(ST.dSys)+'</span><b>'+D.ga+' ga</b></div>'+
kv("Sheet metal per LF", r2(D.per)+" SF")+kv("Weight per LF", r2(D.per*D.lb)+" lb")+(D.wrap?kv("Wrap per LF", r2(D.wrapPer)+" SF"):"")+
'<svg width="100%" height="'+(bh*5+10)+'" viewBox="0 0 220 '+(bh*5+10)+'" style="margin-top:6px">'+
[26,24,22,20,18].map(function(g,i){ var on=g===D.ga; return '<text x="0" y="'+(i*bh+10)+'" font-size="9">'+g+' ga</text><rect x="34" y="'+(i*bh+2)+'" width="'+bar(ZPR.LBSF[g])+'" height="8" rx="2" fill="'+(on?'var(--em)':'var(--line)')+'"/><text x="'+(38+bar(ZPR.LBSF[g]))+'" y="'+(i*bh+10)+'" font-size="9">'+ZPR.LBSF[g]+' lb/SF</text>'; }).join("")+'</svg>';
$("#zd-draw").textContent = "Draw "+D.label+" "+ST.dSys+" duct";
}
$("#zd-shape .chip").forEach(function(b){ b.addEventListener("click", function(){ ST.dShape=b.getAttribute("data-v"); $("#zd-j").value = ST.dShape==="round"?10:5; $("#zd-h").value = ST.dShape==="round"?10:8; renderDuct(); saveUI(); }); });
["#zd-a","#zd-b","#zd-j","#zd-h","#zd-wrap","#zd-liner"].forEach(function(s){ $(s).addEventListener("input", renderDuct); $(s).addEventListener("change", renderDuct); });
/* ---------- fixture library (typical trim; editable later) ---------- */
var FIX = [
{k:"WC-FV", tag:"WC-1", name:"Water closet, floor, flush valve", sym:"square", items:[["Water closet, floor mount, elongated",1],["Flushometer valve 1.28 gpf",1],["Open-front seat",1],["Closet flange 4\"",1],["Wax ring & closet bolts",1],["Rough-in: 1\" CW stub w/ stop",1],["Rough-in: 3\" waste & 2\" vent",1]]},
{k:"WC-WH", tag:"WC-2", name:"Water closet, wall hung", sym:"square", items:[["Water closet, wall hung",1],["Flushometer valve 1.28 gpf",1],["Open-front seat",1],["Closet carrier",1],["Rough-in: 1\" CW stub w/ stop",1],["Rough-in: 4\" waste & 2\" vent",1]]},
{k:"UR", tag:"U-1", name:"Urinal", sym:"diamond", items:[["Urinal, wall hung",1],["Flushometer valve 0.125 gpf",1],["Urinal carrier",1],["Rough-in: 3/4\" CW stub w/ stop",1],["Rough-in: 2\" waste & 1-1/2\" vent",1]]},
{k:"LAV", tag:"L-1", name:"Lavatory", sym:"circle", items:[["Lavatory, wall hung",1],["Faucet, sensor or metering",1],["P-trap 1-1/4\"",1],["Angle stop 3/8\"",2],["Supply riser 3/8\"",2],["Thermostatic mixing valve (ASSE 1070)",1],["ADA trap & supply guard",1],["Rough-in: 1/2\" CW & HW, 1-1/2\" waste",1]]},
{k:"SINK", tag:"S-1", name:"Sink, break room", sym:"circle", items:[["Sink, stainless, single bowl",1],["Faucet, gooseneck",1],["P-trap 1-1/2\"",1],["Angle stop 3/8\"",2],["Supply riser 3/8\"",2],["Rough-in: 1/2\" CW & HW, 2\" waste",1]]},
{k:"MS", tag:"MS-1", name:"Mop service basin", sym:"square", items:[["Mop basin, floor",1],["Service faucet w/ vacuum breaker",1],["Hose & bracket",1],["Rough-in: 3/4\" CW & HW, 3\" waste",1]]},
{k:"EWC", tag:"EWC-1", name:"Drinking fountain / bottle filler", sym:"triangle", items:[["Electric water cooler, bi-level",1],["Mounting frame / carrier",1],["Angle stop 3/8\"",1],["P-trap 1-1/4\"",1],["Rough-in: 1/2\" CW, 1-1/2\" waste",1]]},
{k:"SHR", tag:"SH-1", name:"Shower", sym:"square", items:[["Shower valve, pressure balance",1],["Shower head & arm",1],["Shower drain",1],["P-trap 2\"",1],["Rough-in: 1/2\" CW & HW, 2\" waste",1]]},
{k:"FD", tag:"FD-1", name:"Floor drain", sym:"circle", items:[["Floor drain w/ strainer",1],["P-trap 3\"",1],["Trap seal / primer",1]]},
{k:"FS", tag:"FS-1", name:"Floor sink", sym:"square", items:[["Floor sink w/ grate",1],["P-trap 3\"",1],["Trap seal / primer",1]]},
{k:"CO", tag:"CO", name:"Cleanout (floor or wall)", sym:"plus", items:[["Cleanout, adjustable",1],["Cleanout plug",1]]},
{k:"HB", tag:"HB-1", name:"Hose bibb / wall hydrant", sym:"x", items:[["Wall hydrant, freeze-proof",1],["Rough-in: 3/4\" CW w/ stop",1]]},
{k:"WH", tag:"WH-1", name:"Water heater", sym:"octagon", items:[["Water heater",1],["T&P relief valve & discharge",1],["Expansion tank",1],["Drain pan",1],["Shut-off valves",2],["Unions / dielectric",2]]},
{k:"RPZ", tag:"RPZ", name:"Backflow preventer (RPZ)", sym:"star", items:[["Reduced pressure backflow assembly",1],["Air-gap drain funnel",1],["Test cocks / isolation valves",2]]}
];
function renderFix(){
$("#zf-list").innerHTML = '<table><thead><tr><th>Fixture</th><th>Tag</th><th></th></tr></thead><tbody>'+FIX.map(function(f){
var tag = ST.tags[f.k] || f.tag;
return '<tr><td>'+esc(f.name)+'<div class="small" style="color:var(--mute)">'+f.items.length+' trim items</div></td><td style="width:62px"><input data-tag="'+f.k+'" value="'+esc(tag)+'"></td><td style="white-space:nowrap"><button class="btn" data-count="'+f.k+'">Count</button> <button class="btn" data-find="'+f.k+'">Find</button></td></tr>';
}).join("")+'</tbody></table>';
$("#zf-list [data-tag]").forEach(function(i){ i.addEventListener("change", function(){ ST.tags[i.getAttribute("data-tag")] = i.value.trim(); saveSettings(); }); });
$("#zf-list [data-count]").forEach(function(b){ b.addEventListener("click", function(){ countFixture(b.getAttribute("data-count")); }); });
$("#zf-list [data-find]").forEach(function(b){ b.addEventListener("click", function(){ findFixture(b.getAttribute("data-find")); }); });
}
/* ---------- setup ---------- */
function renderSetup(){
$("#zs-tol").value = ST.tolFt; $("#zs-auto").checked = !!ST.auto;
var k = $("#zs-spec").value || ST.pSpec;
specOptions($("#zs-spec"), k);
var S = spec(k);
$("#zs-grid").innerHTML = '<label>Stick length, ft (0 = coil)</label><input id="zs-stick" type="number" value="'+stickOf(k)+'">'+
'<table style="margin-top:6px"><thead><tr><th>Size</th><th class="n">Hanger spacing, ft</th></tr></thead><tbody>'+S.sizes.map(function(s){ return '<tr><td>'+s+'"</td><td class="n"><input data-hs="'+s+'" type="number" step="0.5" value="'+hangOf(k,s)+'" style="width:80px;text-align:right"></td></tr>'; }).join("")+'</tbody></table>';
$("#zs-stick").addEventListener("change", function(e){ (ST.specEdits[k]=ST.specEdits[k]||{}).stick = +e.target.value; saveSettings(); renderPipe(); });
$("#zs-grid [data-hs]").forEach(function(i){ i.addEventListener("change", function(){ var e=(ST.specEdits[k]=ST.specEdits[k]||{}); e.hang=e.hang||{}; e.hang[i.getAttribute("data-hs")] = +i.value; saveSettings(); renderPipe(); }); });
}
$("#zs-spec").addEventListener("change", renderSetup);
$("#zs-reset").addEventListener("click", function(){ delete ST.specEdits[$("#zs-spec").value]; saveSettings(); renderSetup(); renderPipe(); toast("Spec reset to defaults"); });
$("#zs-tol").addEventListener("change", function(e){ ST.tolFt = Math.max(0.1, +e.target.value||1); saveSettings(); });
$("#zs-auto").addEventListener("change", function(e){ ST.auto = e.target.checked; saveSettings(); });
/* ---------- zzTakeoff writes ---------- */
async function context(){
try { var p = await Projects.getCurrent(); CTX.projectId = p && p._id; CTX.projectName = pname(p); } catch(e){ CTX.projectId=null; }
try { var L = await Layers.getCurrent(); CTX.layerId = L ? L._id : ""; CTX.layerName = L ? L.name : ""; } catch(e){}
$("#zz-ctx").textContent = CTX.projectId ? CTX.projectName : "Open a project to start";
$("#zz-layer").textContent = CTX.layerName ? "Layer: "+CTX.layerName : "";
}
async function findChild(kind, parentId, name, layerId){
var q = {kind:kind, parentId:parentId||""};
var rs = recs(await Takeoffs.list(q));
for (var i=0;i<rs.length;i++){ if (pname(rs[i]).trim()===name && (!layerId || !rs[i].layerId || rs[i].layerId===layerId)) return rs[i]; }
return null;
}
async function ensureFolder(name, parentId){
var f = await findChild("folder", parentId, name);
if (f) return f;
await Takeoffs.insert({kind:"folder", parentId:parentId||"", properties:{name:{value:name}}});
await saveAll();
return await findChild("folder", parentId, name);
}
async function ensureAssembly(folderPath, name, type, props, items){
var parent = null;
for (var i=0;i<folderPath.length;i++) parent = await ensureFolder(folderPath[i], parent ? parent._id : "");
var a = await findChild("assembly", parent._id, name, CTX.layerId);
if (a) return {asm:a, created:false};
var p = {name:{value:name}}; Object.keys(props).forEach(function(k){ p[k]=props[k]; });
var ins = {kind:"assembly", type:type, parentId:parent._id, properties:p};
if (CTX.layerId) ins.layerId = CTX.layerId;
await Takeoffs.insert(ins);
await saveAll();
a = await findChild("assembly", parent._id, name, CTX.layerId);
if (!a) throw new Error("Could not re-find the new assembly "+name);
for (var j=0;j<items.length;j++){
var it = items[j], ip = {name:{value:it.name}, formula:{value:it.f}, units:{value:it.u||"EA"}, "cost type":{value:it.ct||"material"}, "takeoff type":{value:type}, sku:{value:it.sku||""}};
if (it.w) ip["waste %"] = {value:it.w};
if (it.usage) ip.usage = {value:it.usage};
if (it.desc) ip.description = {value:it.desc};
await Takeoffs.insert({kind:"item", parentId:a._id, properties:ip});
}
await saveAll();
return {asm:a, created:true};
}
async function startDrawing(asm, label){
await setPending(null);
toast("Drawing "+label+" — click the green dot to continue, the red button to stop.", 5000);
try { await Tools.startTakeoff({takeoffId: asm._id}); }
catch(e){ toast("Could not start the tool: "+(e.message||e), 6000); return; }
if (ST.auto) setTimeout(function(){ refreshRuns(true); }, 600);
}
/* inserting takeoffs can remount a sidebar panel - leave a note so the fresh copy starts the tool */
async function setPending(v){ try { if (has("PluginUserSettings")) await PluginUserSettings.set("zpr.pending", v); } catch(e){} }
async function checkPending(){
try {
var p = has("PluginUserSettings") ? await PluginUserSettings.get("zpr.pending") : null;
if (!p || !p.asmId || Date.now()-p.t > 45000) return;
var a = await Takeoffs.get(p.asmId);
if (a) startDrawing(a, p.label);
} catch(e){}
}
async function guard(){
if (!has("Takeoffs") || !has("Tools")) { toast("The zzTakeoff API is not available here."); return false; }
await context();
if (!CTX.projectId){ toast("Open a project first."); return false; }
try { var pg = await Pages.getCurrent(); if (!pg || pg.blankPage) { toast("Open a plan sheet first."); return false; } } catch(e){ toast("Open the takeoff page first."); return false; }
return true;
}
async function run(fn){ if (BUSY) return; BUSY=true; $(".go").forEach(function(b){ b.disabled=true; }); try { await fn(); } catch(e){ toast("Stopped: "+(e.message||e), 7000); } finally { BUSY=false; $(".go").forEach(function(b){ b.disabled=false; }); } }
/* PIPE */
$("#zp-draw").addEventListener("click", function(){ run(async function(){
if (!(await guard())) return;
var S=spec(ST.pSpec), sy=sys(ST.pSys), st=stickOf(ST.pSpec), h=hangOf(ST.pSpec, ST.pSize), rod=+$("#zp-rod").value||0, waste=+$("#zp-waste").value||0, ins=$("#zp-ins").value;
var name = pipeName()+(ins?" · "+ins+"\" ins":"");
var hangF = "roundup([Linear:FT] / "+h+")";
var items = [{name:ST.pSize+"\" "+S.name+" pipe", f:"[Linear:FT]", u:"LF", w:waste, sku:"ZPR-PIPE"}];
if (st) items.push({name:S.cpl+" ("+st+"' lengths)", f:"max(0, roundup([Linear:FT] / "+st+") - [Section Count])", sku:"ZPR-CPL"});
items.push({name:S.hanger+" "+ST.pSize+"\" @ "+h+"' o.c. (spacing)", f:hangF, sku:"ZPR-HGR"});
if (rod) items.push({name:"3/8\" threaded rod, "+rod+"' drop per hanger", f:"("+hangF+") * "+rod, u:"LF", sku:"ZPR-ROD"});
if (ins) items.push({name:"Pipe insulation "+ins+"\" on "+ST.pSize+"\"", f:"[Linear:FT]", u:"LF", sku:"ZPR-INS"});
var meta = {v:2, kind:"pipe", sys:ST.pSys, spec:ST.pSpec, size:ZPR.sizeNum(ST.pSize), sizeLabel:ST.pSize, ins:ins||"", rod:rod};
var props = {color:{value:sy.color}, "measurement type":{value:"linear"}, zzpiperun:{value:JSON.stringify(meta)}};
await setPending({t:Date.now(), label:name, asmName:name});
var r = await ensureAssembly(["zzPipeRun", sy.k+" — "+sy.name], name, "linear", props, items);
await setPending({t:Date.now(), label:name, asmId:r.asm._id});
await startDrawing(r.asm, name);
}); });
/* RISER */
$("#zr-draw").addEventListener("click", function(){ run(async function(){
if (!(await guard())) return;
var S=spec(ST.rSpec), sy=sys(ST.rSys), R=riserCalc(), ins=$("#zr-ins").value;
if (!R.rise){ toast("From and To elevations are the same."); return; }
var name = R.dir+" · "+ST.rSys+" · "+S.short+" · "+ST.rSize+"\" · "+ZPR.ftIn(R.from)+" to "+ZPR.ftIn(R.to);
var items = [{name:ST.rSize+"\" "+S.name+" pipe (vertical)", f:"[Point Count] * "+r2(R.rise), u:"LF", sku:"ZPR-PIPE"}];
if (R.el) items.push({name:S.el90+" (riser)", f:"[Point Count] * "+R.el, sku:"ZPR-FIT"});
if (R.cpl) items.push({name:S.cpl, f:"[Point Count] * "+R.cpl, sku:"ZPR-CPL"});
items.push({name:"Riser clamp "+ST.rSize+"\"", f:"[Point Count] * "+R.clamps, sku:"ZPR-HGR"});
if (R.el) items.push({name:S.joint+" (riser fittings)", f:"[Point Count] * "+(2*R.el), sku:"ZPR-JNT"});
if (ins) items.push({name:"Pipe insulation "+ins+"\" on "+ST.rSize+"\" (vertical)", f:"[Point Count] * "+r2(R.rise), u:"LF", sku:"ZPR-INS"});
var meta = {v:1, kind:"riser", sys:ST.rSys, spec:ST.rSpec, size:ZPR.sizeNum(ST.rSize), from:R.from, to:R.to};
var props = {color:{value:sy.color}, "measurement type":{value:"countWithDiameterAndWallHeight"}, symbol:{value:"circle"},
diameter:{value:Math.max(0.5, ZPR.sizeNum(ST.rSize)/12), metadata:ZPR.dimMeta(Math.max(0.5, ZPR.sizeNum(ST.rSize)/12))},
"wall height":{value:R.rise, metadata:ZPR.dimMeta(R.rise)}, "show symbol text":{value:true}, "symbol text":{value:R.dir==="RISER"?"R":"D"}, zzpiperun:{value:JSON.stringify(meta)}};
await setPending({t:Date.now(), label:name});
var r = await ensureAssembly(["zzPipeRun", sy.k+" — "+sy.name], name, "count", props, items);
await setPending({t:Date.now(), label:name, asmId:r.asm._id});
await startDrawing(r.asm, name);
}); });
/* DUCT */
$("#zd-draw").addEventListener("click", function(){ run(async function(){
if (!(await guard())) return;
var D=ductCalc(), sy=dsys(ST.dSys);
if (!D.a || (D.shape==="rect" && !D.b)){ toast("Enter the duct size."); return; }
var name = ST.dSys+" · "+D.label+" · "+D.ga+" ga";
var hangF = "roundup([Linear:FT] / "+D.h+")";
var items = [
{name:"Galvanized duct "+D.label+", "+D.ga+" ga", f:"[Linear:FT] * "+r2(D.per), u:"SF", sku:"ZPR-DUCT"},
{name:"Sheet metal weight @ "+D.lb+" lb/SF ("+D.ga+" ga), 15% seams & waste", f:"[Linear:FT] * "+r2(D.per)+" * "+D.lb, u:"LB", w:15, sku:"ZPR-LBS"},
{name:"Transverse joints ("+D.j+"' sections)", f:"max(0, roundup([Linear:FT] / "+D.j+") - [Section Count])", sku:"ZPR-JNT"},
{name:(D.shape==="round"?"Strap hanger":"Trapeze hanger")+" @ "+D.h+"' o.c.", f:hangF, sku:"ZPR-HGR"}
];
if (D.wrap) items.push({name:"Duct wrap "+D.wrap+"\" FSK", f:"[Linear:FT] * "+r2(D.wrapPer), u:"SF", sku:"ZPR-WRAP"});
if (D.liner) items.push({name:"Duct liner "+D.liner+"\"", f:"[Linear:FT] * "+r2(D.per), u:"SF", sku:"ZPR-LINER"});
var wft = D.a/12;
var meta = {v:2, kind:"duct", sys:ST.dSys, shape:D.shape, a:D.a, b:D.b, size:D.a, ga:D.ga};
var props = {color:{value:sy.color}, "measurement type":{value:"linearWithWidth"}, width:{value:wft, metadata:ZPR.dimMeta(wft)}, scaled:{value:true}, opacity:{value:45}, zzpiperun:{value:JSON.stringify(meta)}};
await setPending({t:Date.now(), label:name});
var r = await ensureAssembly(["zzPipeRun", "Duct — "+sy.k+" "+sy.name], name, "linear", props, items);
await setPending({t:Date.now(), label:name, asmId:r.asm._id});
await startDrawing(r.asm, name);
}); });
/* FIXTURES */
function fixDef(k){ return FIX.filter(function(f){return f.k===k;})[0]; }
async function fixtureAsm(f){
var tag = ST.tags[f.k] || f.tag, name = tag+" · "+f.name;
var items = f.items.map(function(x){ return {name:x[0], f:x[1]===1?"[Point Count]":"[Point Count] * "+x[1], sku:"ZPR-FIX"}; });
var props = {color:{value:"#1E5A44"}, symbol:{value:f.sym}, "show symbol text":{value:true}, "symbol text":{value:tag}, zzpiperun:{value:JSON.stringify({v:1, kind:"fixture", fix:f.k, tag:tag})}};
return (await ensureAssembly(["zzPipeRun", "Fixtures & equipment"], name, "count", props, items)).asm;
}
function countFixture(k){ run(async function(){
if (!(await guard())) return;
var f = fixDef(k), tag = ST.tags[f.k]||f.tag;
await setPending({t:Date.now(), label:tag});
var a = await fixtureAsm(f);
await setPending({t:Date.now(), label:tag, asmId:a._id});
await startDrawing(a, tag+" "+f.name);
}); }
function findFixture(k){ run(async function(){
if (!has("Pages") || !Pages.searchText){ toast("Plan text search is not available here."); return; }
var f = fixDef(k), tag = (ST.tags[f.k]||f.tag).trim();
var hits = await Pages.searchText({search:tag, pages:"all", matchMode:"phrase", comparison:"wholeWords", includeCoordinates:true});
var pages = recs(await Pages.list()), byId = {}; pages.forEach(function(p){ byId[p._id]=p; });
var rows = Object.keys(hits||{}).map(function(pid){ var p=byId[pid]||{}; var pn=p.properties||{}; var num=pn.number&&(pn.number.result||pn.number.value)||""; var nm=pn.name&&(pn.name.result||pn.name.value)||""; return {pid:pid, label:(num?num+" ":"")+nm, n:(hits[pid]||[]).length, hits:hits[pid]}; });
var total = rows.reduce(function(s,r){return s+r.n;},0);
var counted = 0;
try { var a = await findChild("assembly", (await ensureFolderQuiet()), tag+" · "+f.name); if (a) counted = mval(a,"point count"); } catch(e){}
FINDSTATE = {k:k, rows:rows, asmName:tag+" · "+f.name};
$("#zf-result").hidden = false;
$("#zf-result").innerHTML = '<div class="kv"><span><b style="font:600 14px Georgia,serif">'+esc(tag)+'</b> in plan text</span><b>'+total+' hits</b></div>'+
kv("Counted in zzTakeoff", counted)+
'<div class="hint">Text hits include schedules and legends, so they usually run higher than the real count. Use them to find sheets you have not counted yet.</div>'+
(rows.length ? '<table style="margin-top:6px"><thead><tr><th>Sheet</th><th class="n">Hits</th><th></th></tr></thead><tbody>'+rows.map(function(r,i){ return '<tr><td><span class="lnk" data-go="'+i+'">'+esc(r.label||r.pid)+'</span></td><td class="n">'+r.n+'</td><td><button class="btn" data-mark="'+i+'" title="Drop a count mark on every hit on this sheet">Mark</button></td></tr>'; }).join("")+'</tbody></table>' : '<div class="hint">No text matches. The tag may be drawn as a symbol only, or the sheets need OCR.</div>');
$("#zf-result [data-go]").forEach(function(s){ s.addEventListener("click", function(){ goHit(+s.getAttribute("data-go")); }); });
$("#zf-result [data-mark]").forEach(function(b){ b.addEventListener("click", function(){ markHits(+b.getAttribute("data-mark")); }); });
}); }
var FINDSTATE = null;
async function ensureFolderQuiet(){ var z = await findChild("folder","","zzPipeRun"); if (!z) return "none"; var f = await findChild("folder", z._id, "Fixtures & equipment"); return f ? f._id : "none"; }
async function goHit(i){
var r = FINDSTATE && FINDSTATE.rows[i]; if (!r) return;
try { await Pages.open(r.pid); var h=r.hits[0]; if (h && has("Canvas")) await Canvas.zoomToRect({x1:h.x1-300, y1:h.y1-300, x2:h.x2+300, y2:h.y2+300}); } catch(e){ toast("Could not open that sheet: "+(e.message||e)); }
}
function markHits(i){ run(async function(){
var r = FINDSTATE && FINDSTATE.rows[i]; if (!r) return;
if (!(await guard())) return;
var cur = await Pages.getCurrent();
if (!cur || cur._id !== r.pid){ await Pages.open(r.pid); }
var f = fixDef(FINDSTATE.k), a = await fixtureAsm(f);
if (has("UndoManager")) await UndoManager.newUndoPoint("zzPipeRun: mark "+(ST.tags[f.k]||f.tag));
var cfg = r.hits.map(function(h){ var o = {type:"count", points:[{x:(h.x1+h.x2)/2, y:(h.y1+h.y2)/2}], pageId:r.pid, takeoffId:a._id}; if (CTX.layerId) o.layerId=CTX.layerId; return o; });
if (DrawObjects.insertBatch) await DrawObjects.insertBatch(cfg); else { for (var j=0;j<cfg.length;j++) await DrawObjects.insert(cfg[j]); }
await saveAll();
toast("Marked "+cfg.length+" hits on this sheet. Delete any that land on a schedule or legend.", 6000);
}); }
/* ---------- runs, analysis, QA ---------- */
function metaOf(t){ var p=t.properties&&t.properties.zzpiperun; if (!p) return null; try { return JSON.parse(p.result!=null?p.result:p.value); } catch(e){ return null; } }
async function allOurs(){
var rs = recs(await Takeoffs.list({kind:"assembly"}));
return rs.map(function(t){ var m=metaOf(t); return m ? {t:t, m:m} : null; }).filter(Boolean);
}
async function gather(list){
var runs=[], terms=[], sigParts=[], pageScale={};
for (var i=0;i<list.length;i++){
var o=list[i], dos=recs(await DrawObjects.list({takeoffId:o.t._id}));
dos.forEach(function(d){
var pts = (d.points||[]).map(function(p){ return {x:+p.x, y:+p.y}; });
sigParts.push(o.t._id+":"+d._id+":"+pts.length+":"+pts.map(function(p){return Math.round(p.x)+","+Math.round(p.y);}).join(";"));
if (o.m.kind==="pipe" || o.m.kind==="duct"){
var lm = d.measurements && d.measurements.linear, ft = lm && typeof lm==="object" ? +lm.value : +lm, px = ZPR.pxLen(pts), sc = (ft>0 && px>0) ? px/ft : null;
if (sc) pageScale[d.pageId] = sc;
runs.push({id:d._id, asm:o.t._id, sys:o.m.sys, kind:o.m.kind, size:+o.m.size, pageId:d.pageId, pts:pts, scale:sc});
} else {
pts.forEach(function(p){ terms.push({pageId:d.pageId, x:p.x, y:p.y, kind:o.m.kind, asm:o.t._id}); });
}
});
}
for (var k=0;k<runs.length;k++){ if (!runs[k].scale){ if (pageScale[runs[k].pageId]) runs[k].scale = pageScale[runs[k].pageId]; else { try { var pg=await Pages.get(runs[k].pageId); runs[k].scale = pg && pg.scaleX || 20; pageScale[runs[k].pageId]=runs[k].scale; } catch(e){ runs[k].scale=20; } } } }
return {runs:runs, terms:terms, sig:hash(sigParts.sort().join("|")+"|tol"+ST.tolFt+"|engine2")};
}
function hash(s){ var h=0; for (var i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))%2147483647; return String(h); }
var LAST = null;
async function refreshRuns(writeIfStale){
if (!has("Takeoffs")) return;
await context(); if (!CTX.projectId) return;
var list = await allOurs();
var g = await gather(list);
var res = ZPR.analyze(g.runs, g.terms, {tolFt:ST.tolFt});
var sigKey = "zpr.sig."+CTX.projectId, stored = null;
try { stored = has("PluginWorkspaceSettings") ? await PluginWorkspaceSettings.get(sigKey) : null; } catch(e){}
var stale = !stored || stored.sig !== g.sig;
LAST = {list:list, res:res, stale:stale, at:stored && stored.at};
renderRuns();
if (stale && writeIfStale) await writeFittings(list, res, g.sig);
}
async function writeFittings(list, res, sig){
/* signature first, so the remount that item writes can trigger does not loop */
try { if (has("PluginWorkspaceSettings")) await PluginWorkspaceSettings.set("zpr.sig."+CTX.projectId, {sig:sig, at:new Date().toISOString()}); } catch(e){}
if (has("UndoManager")) await UndoManager.newUndoPoint("zzPipeRun: fittings");
var wrote = 0;
for (var i=0;i<list.length;i++){
var o=list[i]; if (o.m.kind!=="pipe" && o.m.kind!=="duct") continue;
var r = res.byAsm[o.t._id] || {e90:0,e45:0,e22:0,tees:{},crosses:0,reducers:{},open:0};
var rows = ZPR.autoItems(o.m, r);
var kids = recs(await Takeoffs.list({kind:"item", parentId:o.t._id}));
var auto = {}; kids.forEach(function(k){ var sk=k.properties&&k.properties.sku; if (sk && String(sk.value||sk.result)==="ZPR-AUTO") auto[pname(k)] = k; });
var seen = {};
for (var j=0;j<rows.length;j++){
var row = rows[j]; seen[row.name]=1;
if (auto[row.name]){ var cur = auto[row.name].properties.formula; if (String(cur && cur.value) !== String(row.qty)) { await Takeoffs.update(auto[row.name]._id, {properties:{formula:{value:String(row.qty)}}}); wrote++; } }
else { await Takeoffs.insert({kind:"item", parentId:o.t._id, properties:{name:{value:row.name}, formula:{value:String(row.qty)}, units:{value:row.units||"EA"}, "cost type":{value:"material"}, "takeoff type":{value:o.t.type||"linear"}, sku:{value:"ZPR-AUTO"}, description:{value:"Counted by zzPipeRun from the drawn geometry. Re-analyzes when the run changes."}}}); wrote++; }
}
for (var nm in auto){ if (!seen[nm]){ var fv=auto[nm].properties.formula; if (String(fv&&fv.value)!=="0"){ await Takeoffs.update(auto[nm]._id, {properties:{formula:{value:"0"}}}); wrote++; } } }
}
await saveAll();
if (LAST) { LAST.stale=false; LAST.at=new Date().toISOString(); }
renderRuns();
if (wrote) toast("Fittings updated ("+wrote+" line"+(wrote===1?"":"s")+").");
}
$("#zq-analyze").addEventListener("click", function(){ run(async function(){
await context(); if (!CTX.projectId) { toast("Open a project first."); return; }
var list = await allOurs(), g = await gather(list), res = ZPR.analyze(g.runs, g.terms, {tolFt:ST.tolFt});
LAST = {list:list, res:res, stale:true};
await writeFittings(list, res, g.sig);
}); });
function mval(t, key){ var out=0; ["measurement 1","measurement 2","measurement 3"].forEach(function(s){ var m=t.properties&&t.properties[s]; if (m && m.key===key) out=+m.value||0; }); return out; }
function renderRuns(){
if (!LAST){ $("#zq-table").innerHTML='<div class="hint">No zzPipeRun takeoffs in this project yet.</div>'; return; }
var L = LAST, list = L.list;
var bySys = {};
list.forEach(function(o){ if (o.m.kind==="pipe"||o.m.kind==="duct"){ var lf=mval(o.t,"linear"); bySys[o.m.sys]=(bySys[o.m.sys]||0)+lf; } else if (o.m.kind==="riser"){ bySys[o.m.sys]=(bySys[o.m.sys]||0)+mval(o.t,"point count")*Math.abs((o.m.to||0)-(o.m.from||0)); } });
var keys = Object.keys(bySys).sort(function(a,b){return bySys[b]-bySys[a];}), max = Math.max.apply(null, keys.map(function(k){return bySys[k];}).concat([1]));
var colorOf = function(k){ var s=ZPR.SYSTEMS.concat(ZPR.DUCT_SYSTEMS).filter(function(x){return x.k===k;})[0]; return s?s.color:"#888"; };
$("#zq-chart").innerHTML = keys.length ? '<div class="card"><div class="kv"><span style="font:600 13px Georgia,serif">Footage by system</span><span class="pill '+(L.stale?'warn':'ok')+'">'+(L.stale?'fittings out of date':'fittings current')+'</span></div><svg width="100%" viewBox="0 0 240 '+(keys.length*18+4)+'">'+keys.map(function(k,i){ var w=Math.max(2, Math.round(150*bySys[k]/max)); return '<text x="0" y="'+(i*18+13)+'" font-size="10">'+k+'</text><rect x="34" y="'+(i*18+4)+'" width="'+w+'" height="10" rx="2" fill="'+colorOf(k)+'"/><text x="'+(38+w)+'" y="'+(i*18+13)+'" font-size="10">'+Math.round(bySys[k]).toLocaleString()+' ft</text>'; }).join("")+'</svg></div>' : "";
if (!list.length){ $("#zq-table").innerHTML='<div class="hint">No zzPipeRun takeoffs in this project yet. Pick a size on the Pipe tab and draw.</div>'; $("#zq-open").hidden=true; return; }
$("#zq-table").innerHTML = '<table style="margin-top:8px"><thead><tr><th>Run</th><th class="n">Qty</th><th class="n">Fittings</th><th></th></tr></thead><tbody>'+list.map(function(o,i){
var r = L.res.byAsm[o.t._id], fit = r ? (r.e90+r.e45+r.e22+r.crosses+Object.keys(r.tees).reduce(function(s,k){return s+r.tees[k];},0)+Object.keys(r.reducers).reduce(function(s,k){return s+r.reducers[k];},0)) : "";
var q = (o.m.kind==="pipe"||o.m.kind==="duct") ? Math.round(mval(o.t,"linear"))+" ft" : mval(o.t,"point count")+" ea";
return '<tr><td>'+esc(pname(o.t))+(r&&r.open?' <span class="pill warn" title="Run ends that do not connect to anything">'+r.open+' open</span>':'')+'</td><td class="n">'+q+'</td><td class="n">'+fit+'</td><td><button class="btn" data-more="'+i+'" title="Draw more on this run">+</button></td></tr>';
}).join("")+'</tbody></table>';
$("#zq-table [data-more]").forEach(function(b){ b.addEventListener("click", function(){ var o=list[+b.getAttribute("data-more")]; run(async function(){ if (!(await guard())) return; await startDrawing(o.t, pname(o.t)); }); }); });
$("#zq-when").textContent = L.at ? "Fittings last written "+new Date(L.at).toLocaleString()+". "+L.res.connections+" connections found across "+L.res.runs+" drawn runs." : "";
var op = L.res.open;
$("#zq-open").hidden = !op.length;
if (op.length){
var names = {}; list.forEach(function(o){ names[o.t._id]=pname(o.t); });
$("#zq-open").innerHTML = '<div class="kv"><span style="font:600 13px Georgia,serif">Open ends</span><b>'+op.length+'</b></div><div class="hint">Run ends that do not meet another run, a riser or a fixture. Fine at equipment and stub-outs; check the rest for a missed connection.</div><table style="margin-top:4px"><tbody>'+op.slice(0,40).map(function(e,i){ return '<tr><td><span class="lnk" data-oe="'+i+'">'+esc(names[e.asm]||"run")+'</span></td></tr>'; }).join("")+'</tbody></table>';
$("#zq-open [data-oe]").forEach(function(s){ s.addEventListener("click", async function(){ var e=op[+s.getAttribute("data-oe")]; try { var cur=await Pages.getCurrent(); if (!cur||cur._id!==e.pageId) await Pages.open(e.pageId); await Canvas.zoomToRect({x1:e.x-250,y1:e.y-250,x2:e.x+250,y2:e.y+250}); } catch(err){ toast("Could not zoom: "+(err.message||err)); } }); });
}
}
/* watch for finished drawing - analyze when the tool goes idle and the runs changed */
var lastWatch = 0;
async function watch(){
if (!ST.auto || BUSY || document.hidden || !has("getContext")) return;
if (Date.now()-lastWatch < 4000) return; lastWatch = Date.now();
try { var c = await getContext(); if (c && c.activeTool) return; } catch(e){ return; }
try { BUSY = true; await refreshRuns(true); } catch(e){} finally { BUSY = false; }
}
/* ---------- boot ---------- */
(async function(){
await loadSettings();
renderPipe(); renderRiser(); renderDuct(); renderFix(); renderSetup(); setTab(ST.tab);
await context();
await checkPending();
setInterval(watch, 1500);
})();
})();
</script>
</div>