zzPipeRunspec-driven MEP takeoff
 
             
  
  

Pipe run

  
    
  
   
   
   
  
  
    
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.
 
  
  

Riser / drop

  
    
  
   
   
  
  
   
   
  
      
    
Click once on the plan at each riser or drop location. Every click carries the full vertical footage shown above.
 
  
  

Duct run

  
  
  
   
   
  
  
   
   
  
  
   
   
  
  
    
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.
 
  
  

Fixtures & equipment

  
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.
  
   
  
  

Runs in this project

  
  
    
   
  
  

Connections

    
Run ends closer than this (at the sheet's scale) are treated as joined.
    

Spec table

    
    
  

Where the numbers come from

  
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.
 
Loading…
"/>
 
zzPipeRunspec-driven MEP takeoff
 
             
  
  

Pipe run

  
    
  
   
   
   
  
  
    
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.
 
  
  

Riser / drop

  
    
  
   
   
  
  
   
   
  
      
    
Click once on the plan at each riser or drop location. Every click carries the full vertical footage shown above.
 
  
  

Duct run

  
  
  
   
   
  
  
   
   
  
  
   
   
  
  
    
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.
 
  
  

Fixtures & equipment

  
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.
  
   
  
  

Runs in this project

  
  
    
   
  
  

Connections

    
Run ends closer than this (at the sheet's scale) are treated as joined.
    

Spec table

    
    
  

Where the numbers come from

  
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.
 
Loading…
"/>
Back to zzTakeoff Community Channel LogoDevelopers / AI / MCP

Pipe Run HVAC Tool -

<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 &amp; 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 &amp; 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 {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[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>

You must be logged in to post replies. If you don't have an account you can signup here.